feat(optimize): add --subtract, the subtraction axis, behind a deterministic floor
Every command so far asked an addition question — what to add, what to move, what it costs. Nothing asked what is no longer earning its always-loaded rent. This adds that axis as a fourth lensCheck on the existing hybrid motor rather than a new scanner or a 22nd command: the measured payoff (~18% of one file) justifies a mode, not machinery. It is the only lens that proposes REMOVING config, so it carries a guarantee the others don't need: a load-bearing block is never a candidate. Precision is asymmetric — a missed dead line costs a few tokens per turn, a deleted one costs a wrong remote or a broken script — so the floor is decided in code (lib/floor-exclusion.mjs) before the opus judge sees anything, never in prose. Granularity is the leaf block, with two structural exceptions: a paragraph ending in ':' merges with the list it introduces, and an ordered list is a contract whose steps inherit floor from any sibling. Unordered lists deliberately do not inherit — a load-bearing bullet and a disposable one routinely share a list, and container-reasoning is the error the hand-built ground truth exists to catch. Verified against that ground truth (built before any classifier existed), with the comparison machine-checked rather than read by eye: zero load-bearing blocks proposed, 11/18 deletable groups surfaced, ~756 tok ~ 18% of a ~4300 token file — inside the pre-registered band. The first run found five floor violations the synthesized fixture missed; each got a structural rule and a fixture shape so it cannot regress. Three real bugs the dogfood run exposed, all now covered: - JS \b is ASCII-only, so /\bunngå\b/ never matches — every Norwegian keyword ending in æ/ø/å was silently dead. - A bare word/word is not a path; "pros/cons" vetoed the largest deletable block until PATH_RE was tightened to rooted paths and globs. - "Mid-sentence" must key on a preceding lowercase letter; the loose version read **bold labels:** and quoted openers as entities, costing 4 of 11 groups. BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by BP-MECH-001..004 and asserts nothing from the talk that motivated the feature — no "80%", no ablation figure. Suite 1365 -> 1382/0. Frozen v5.0.0 snapshots untouched; plain optimize output byte-identical on identical input (--subtract adds keys only when passed). knowledge-refresh-cli's reference date moved to 2026-08-01: its premise that every seed entry was verified 2026-06-20 expired when BP-SUB-001 got a genuine verification date, and backdating the entry to fit the test would have been a lie about when its source was checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
This commit is contained in:
parent
c0625c568f
commit
e9921d3c9d
13 changed files with 1080 additions and 32 deletions
98
tests/fixtures/subtraction/shapes-claude-md.md
vendored
Normal file
98
tests/fixtures/subtraction/shapes-claude-md.md
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Subtraction shapes — synthesized fixture
|
||||
|
||||
This fixture reproduces the *discriminating shapes* from the hand-built subtraction fasit
|
||||
(økt #40) without reproducing any of the operator's private config text. Every block below
|
||||
exists to make one classification call hard in the same way a real block was hard.
|
||||
|
||||
Both languages appear on purpose: the config this lens was designed against is Norwegian
|
||||
prose with English identifiers, and a detector that only speaks English would score well
|
||||
here and fail in the field.
|
||||
|
||||
## Preferences
|
||||
|
||||
- Language: Norwegian for dialogue, English for code and documentation.
|
||||
- Tone: direct and technical, minimal ceremony.
|
||||
- Format: be concrete and action-oriented, avoid unnecessary text.
|
||||
- Never say "as an AI I cannot"; skip apologies and over-explanation.
|
||||
|
||||
## Kodepreferanser
|
||||
|
||||
- TypeScript > JavaScript
|
||||
- Readability > cleverness
|
||||
- Conventional Commits: `type(scope): description`
|
||||
|
||||
## Oppstart
|
||||
|
||||
1. Run `pwd` to see where you are.
|
||||
2. Read the nearest STATE.md and confirm the active task.
|
||||
3. Sjekk `git status` mot det STATE.md hevder.
|
||||
4. Summarize before you do anything.
|
||||
|
||||
## Arbeidsregler
|
||||
|
||||
- Commit often with descriptive messages.
|
||||
- Test incrementally, and ask when blocked.
|
||||
- Tenk før du koder — uttal antakelser eksplisitt og spør ved tvetydighet.
|
||||
- Aldri gjett på rotårsak; undersøk først.
|
||||
|
||||
## Defensive shell-scripting
|
||||
|
||||
- Alltid siter variabel-ekspansjoner (`"$var"`) — en usitert ekspansjon brøt en zsh-loop.
|
||||
- Hold testkode ASCII-ren; et multibyte-tegn krasjet bash 3.2 under `set -u`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Do not one-shot large features.
|
||||
- Never declare "done" without running the tests.
|
||||
- Aldri jobb i et annet repo enn det økten står i. Hvert repo eier sin egen
|
||||
versjonsplan og sin egen tilstand. Skriver du der, tar du beslutninger på
|
||||
deres vegne uten deres kontekst — `coord-send` er mekanismen, send arbeidet
|
||||
dit i stedet.
|
||||
- Do not refactor as part of a bugfix without approval.
|
||||
|
||||
## Git
|
||||
|
||||
- Aldri GitHub. Kun Forgejo (`git.example-forge.test`). Aldri `gh` CLI.
|
||||
- Etter `git commit`: push til Forgejo umiddelbart.
|
||||
|
||||
## Miljø
|
||||
|
||||
- System bash er **3.2**: ingen `declare -A`, ingen `readarray`.
|
||||
- Alle hook-scripts må være 3.2-kompatible.
|
||||
|
||||
## Modellvalg
|
||||
|
||||
Default for alt arbeid er Opus 4.8 med xhigh effort. Ingen annen modell skal brukes.
|
||||
Sett `model: "opus"` på hver spawn. Haiku: aldri.
|
||||
|
||||
## Secrets
|
||||
|
||||
- ALDRI commit secrets. Bruk `.env`/`.template`-mønsteret.
|
||||
|
||||
### The Iron Law: NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
|
||||
## Arbeidsflyt
|
||||
|
||||
Default for alle oppgaver: forstå problemet, vurder alternativer, presenter dem,
|
||||
og vent på bekreftelse før du implementerer. Ved usikkerhet om en oppgave faller
|
||||
i unntakene: den gjør det ikke.
|
||||
|
||||
**Viktig:** `/harness continue` og andre skills styrer sin egen workflow.
|
||||
|
||||
## Kodeblokk
|
||||
|
||||
```bash
|
||||
# Never run this in production, and always check every time first.
|
||||
echo "avoid unnecessary output"
|
||||
```
|
||||
|
||||
## Kontinuitet
|
||||
|
||||
Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:
|
||||
- **`STATE.md`** — current state-of-play der du jobber. Overskrives alltid ved sesjonsslutt.
|
||||
- **`MEMORY.md`** — varige fakta om bruker. Hold den under 200 linjer.
|
||||
|
||||
## Verktøy
|
||||
|
||||
- Deleger arbeid til subagenter, og bruk Explore for søk.
|
||||
- Skriv alltid kortest mulig kode.
|
||||
228
tests/lib/subtraction-prefilter.test.mjs
Normal file
228
tests/lib/subtraction-prefilter.test.mjs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* subtraction-prefilter tests — the deterministic half of the v5.13 subtraction
|
||||
* lens (`/config-audit optimize --subtract`, CA-OPT / BP-SUB-001).
|
||||
*
|
||||
* The subtraction axis asks the inverse of every other command: *what is no
|
||||
* longer earning its always-loaded rent?* Precision here is ASYMMETRIC — a
|
||||
* missed dead line costs a few tokens per turn, a deleted load-bearing line
|
||||
* costs a wrong remote or a broken script (brief §6.0). So unlike
|
||||
* `lens-prefilter` (recall-first, agent as the gate), this module is
|
||||
* precision-first and carries a BLOCKING deterministic guarantee:
|
||||
*
|
||||
* **a load-bearing block never reaches the judge at all.**
|
||||
*
|
||||
* That guarantee is delivered by two independent mechanisms, and the contract is
|
||||
* therefore asserted on the CANDIDATE LIST rather than on either mechanism:
|
||||
* 1. the compensatory detector does not fire on declarative local facts
|
||||
* ("Language: Norwegian for dialogue") — they are never generated; and
|
||||
* 2. floor-exclusion vetoes any leaf block carrying an underivable local
|
||||
* literal (code span, path, domain, version pin) or a policy invariant.
|
||||
* A test written against `isLoadBearing()` alone would miss group 1 entirely.
|
||||
*
|
||||
* GRANULARITY (the one design choice the fasit left open, §5): leaf-block — one
|
||||
* markdown list item including its wrapped continuation lines, or one paragraph.
|
||||
* It must SPLIT a mixed numbered list and three consecutive bullets, and must
|
||||
* KEEP WHOLE a bullet whose load-bearing literal sits on a continuation line.
|
||||
*
|
||||
* FIXTURE, not the real subject file. The fasit was built against the operator's
|
||||
* private `~/.claude/CLAUDE.md`; that file is machine-dependent, edited between
|
||||
* sessions, and this repo's only remote is a PUBLIC mirror. So the committed
|
||||
* suite runs against a synthesized fixture that reproduces the discriminating
|
||||
* *shapes*, and the run against the real file is the manual dogfood gate (brief
|
||||
* §8), recorded in the worklist — not a suite test.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
splitLeafBlocks,
|
||||
subtractionCandidates,
|
||||
floorExcluded,
|
||||
SUBTRACT_DETECTORS,
|
||||
} from '../../scanners/lib/subtraction-prefilter.mjs';
|
||||
import { isLoadBearing } from '../../scanners/lib/floor-exclusion.mjs';
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
fileURLToPath(new URL('../fixtures/subtraction/shapes-claude-md.md', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const candidateLines = (text) => subtractionCandidates(text).map((c) => c.line);
|
||||
const covers = (cands, line) => cands.some((c) => line >= c.startLine && line <= c.endLine);
|
||||
|
||||
/**
|
||||
* Every line of the fixture that the fasit labels FLOOR or POLICY-FLOOR. If any
|
||||
* of these is covered by a candidate block, the gate fails outright — whatever
|
||||
* the recall. (brief §8, "zero load-bearing blocks proposed for deletion")
|
||||
*/
|
||||
const FLOOR_LINES = [
|
||||
13, 14, // declarative preference facts (B-04 / B-05 shape) — must never fire
|
||||
22, // format contract in a code span (B-30c) — adjacent to two other calls
|
||||
26, 27, 28, 29, // ordered list = contract (B-16 / B-17): siblings 27-28 floor the whole run
|
||||
91, 92, 93, // list stem + its floor items (B-09) — the stem carries no literal of its own
|
||||
97, // "bruk Explore for søk" — an entity the mechanism cannot resolve, so it keeps it
|
||||
40, 41, // generic-sounding advice wrapped around a local incident (B-27 — THE trap)
|
||||
47, 48, 49, 50, // floor bullet inside a compensatory list (B-32b), literal on a continuation line
|
||||
55, 56, // §8 named anchor: the remote (B-25) + the push workflow (B-23)
|
||||
60, 61, // §8 named anchor: system bash version (B-26)
|
||||
65, 66, // stale-in-content but load-bearing (B-29) — staleness is NOT a deletion signal
|
||||
70, // policy invariant, floor by decision not classification (B-34)
|
||||
72, // absolute policy prohibition as a heading (B-37)
|
||||
80, // local fact about how skills behave (B-38b)
|
||||
85, 86, // inside a fenced code block — never a candidate
|
||||
];
|
||||
|
||||
/** Lines the fasit labels DELETABLE that the mechanism must actually surface. */
|
||||
const MUST_SURFACE = [
|
||||
15, // "avoid unnecessary text" — the B-06 half of the B-05/B-06 adjacent pair
|
||||
16, // "Never say 'as an AI I cannot'" — textbook tier-3 compensatory
|
||||
33, // "Commit often with descriptive messages" (B-19b) — the named pure-behaviour case
|
||||
36, // "Aldri gjett på rotårsak" — Norwegian imperative
|
||||
46, // "Never declare done without running the tests"
|
||||
76, // the workflow paragraph (B-38a) — split from its floor line at 80
|
||||
98, // "Skriv alltid kortest mulig kode" — survives next to an entity-vetoed sibling
|
||||
];
|
||||
|
||||
describe('subtraction-prefilter — detector table', () => {
|
||||
it('registers the subtraction class against BP-SUB-001, separately from LENS_DETECTORS', async () => {
|
||||
const map = new Map(SUBTRACT_DETECTORS.map((d) => [d.lensCheck, d]));
|
||||
assert.ok(map.has('compensatory-instruction'), 'subtraction lensCheck must exist');
|
||||
assert.equal(map.get('compensatory-instruction').registerId, 'BP-SUB-001');
|
||||
|
||||
// Byte-stability: the three mechanism-fit detectors drive the plain
|
||||
// `optimize` payload's `register` block. Adding the subtraction class there
|
||||
// would change plain-optimize output, which the brief forbids.
|
||||
const { LENS_DETECTORS } = await import('../../scanners/lib/lens-prefilter.mjs');
|
||||
assert.equal(LENS_DETECTORS.length, 3, 'plain optimize must still expose exactly 3 detectors');
|
||||
assert.ok(
|
||||
!LENS_DETECTORS.some((d) => d.lensCheck === 'compensatory-instruction'),
|
||||
'the subtraction class must not leak into the plain-optimize detector table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — leaf-block granularity (the declared contract)', () => {
|
||||
it('keeps a wrapped bullet whole, so a continuation-line literal still protects it', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
const b32b = blocks.find((b) => b.startLine === 47);
|
||||
assert.ok(b32b, 'the wrapped bullet must start its own block at line 47');
|
||||
assert.equal(b32b.endLine, 50, 'its continuation lines belong to the same block');
|
||||
});
|
||||
|
||||
it('splits three consecutive bullets into three blocks (three different calls)', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
for (const line of [20, 21, 22]) {
|
||||
assert.ok(
|
||||
blocks.some((b) => b.startLine === line && b.endLine === line),
|
||||
`line ${line} must be its own leaf block`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an ordered list as a contract: a floor sibling floors the whole run', () => {
|
||||
// Steps 2-3 name the file to read and the check to run; steps 1 and 4 are
|
||||
// filler. Splitting them would propose deleting step 4 of a five-step
|
||||
// protocol — so the run inherits floor. (Structural exception 2.)
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
for (const line of [26, 27, 28, 29]) {
|
||||
assert.ok(!covers(cands, line), `ordered step at line ${line} must inherit floor`);
|
||||
}
|
||||
const reasons = floorExcluded(FIXTURE);
|
||||
assert.ok(
|
||||
reasons.some((r) => r.startLine === 29 && r.marker === 'ordered-contract'),
|
||||
'step 4 must be recorded as floored by contract inheritance, not by its own literal',
|
||||
);
|
||||
});
|
||||
|
||||
it('merges a list stem with the items it introduces (structural exception 1)', () => {
|
||||
const blocks = splitLeafBlocks(FIXTURE);
|
||||
const stem = blocks.find((b) => b.startLine === 91);
|
||||
assert.ok(stem, 'the stem paragraph must open a block at line 91');
|
||||
assert.equal(stem.endLine, 93, 'the stem absorbs the list it introduces');
|
||||
// The stem itself carries no literal — it is protected only by the merge.
|
||||
assert.equal(isLoadBearing('Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:'), false);
|
||||
});
|
||||
|
||||
it('never emits a heading as a candidate block', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
assert.ok(!covers(cands, 72), 'the Iron Law heading must not be a deletion candidate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — THE GATE (brief §8, blocking)', () => {
|
||||
it('proposes ZERO load-bearing lines for deletion', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
const violations = FLOOR_LINES.filter((line) => covers(cands, line));
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
`load-bearing lines proposed for deletion: ${violations.join(', ')} — gate fails outright`,
|
||||
);
|
||||
});
|
||||
|
||||
it('still surfaces genuinely compensatory blocks (recall is secondary, not zero)', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
const missed = MUST_SURFACE.filter((line) => !covers(cands, line));
|
||||
assert.deepEqual(missed, [], `expected these to be candidates: ${missed.join(', ')}`);
|
||||
});
|
||||
|
||||
it('separates the adjacent near-identical preference bullets (B-05 vs B-06)', () => {
|
||||
const lines = candidateLines(FIXTURE);
|
||||
assert.ok(!lines.includes(14), 'the tone preference is a fact about the human — keep');
|
||||
assert.ok(lines.includes(15), 'the "avoid unnecessary text" nag is compensatory — surface');
|
||||
});
|
||||
|
||||
it('does not fire inside fenced code blocks', () => {
|
||||
const cands = subtractionCandidates(FIXTURE);
|
||||
assert.ok(!covers(cands, 85) && !covers(cands, 86), 'fenced code is not config prose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('floor-exclusion — the deterministic veto', () => {
|
||||
it('vetoes underivable local literals', () => {
|
||||
assert.ok(isLoadBearing('Aldri GitHub. Kun Forgejo (`git.example.test`).'), 'code span + domain');
|
||||
assert.ok(isLoadBearing('System bash er **3.2**: ingen declare -A.'), 'version pin');
|
||||
assert.ok(isLoadBearing('Read the nearest STATE.md before starting.'), 'concrete filename');
|
||||
assert.ok(isLoadBearing('Tokens live in ~/.zshenv on this machine.'), 'home path');
|
||||
});
|
||||
|
||||
it('vetoes policy invariants regardless of phrasing (§6.0 carve-out)', () => {
|
||||
assert.ok(isLoadBearing('ALDRI commit secrets.'), 'secrets are floor by decision');
|
||||
});
|
||||
|
||||
it('does not veto generic behaviour correction carrying no local fact', () => {
|
||||
assert.equal(isLoadBearing('Commit often with descriptive messages.'), false);
|
||||
assert.equal(isLoadBearing('Think before you code and avoid unnecessary text.'), false);
|
||||
});
|
||||
|
||||
it('vetoes an unresolvable mid-sentence entity, but not a bold label or a quoted opener', () => {
|
||||
// The conservative default: a product/tool name is local vocabulary the
|
||||
// mechanism cannot resolve without a dictionary, so it keeps the block.
|
||||
assert.ok(isLoadBearing('push til deres egne Forgejo-remotes også'), 'mid-sentence entity');
|
||||
// …but these are sentence openings dressed in punctuation. Reading them as
|
||||
// entities cost 4 of 11 deletable groups in the dogfood run.
|
||||
assert.equal(isLoadBearing('- **Format:** Konkret og handlingsorientert'), false, 'bold label');
|
||||
assert.equal(isLoadBearing('- **Aldri:** "Som AI kan jeg ikke..."'), false, 'quoted opener + acronym');
|
||||
});
|
||||
|
||||
it('does not treat a bare word/word as a path (it is not one)', () => {
|
||||
// "pros/cons" vetoed the largest deletable block until PATH_RE was tightened.
|
||||
assert.equal(isLoadBearing('Presenter alternativene med pros/cons og foreslå ett valg.'), false);
|
||||
assert.ok(isLoadBearing('Store baselines under ~/.config-audit/baselines/.'), 'a real path still vetoes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtraction-prefilter — Unicode word boundaries', () => {
|
||||
// JS `\b` is ASCII-only, so /\bunngå\b/ never matches: the trailing "å" is
|
||||
// not a word character. Every Norwegian keyword ending in æ/ø/å was silently
|
||||
// dead until the dogfood run surfaced it.
|
||||
it('matches Norwegian keywords ending in æ/ø/å', () => {
|
||||
const c = subtractionCandidates('- Vær konkret og handlingsorientert, unngå overflødig tekst her.');
|
||||
assert.equal(c.length, 1, '"unngå" must be detected despite the trailing å');
|
||||
});
|
||||
|
||||
it('still requires a whole-word match', () => {
|
||||
assert.deepEqual(subtractionCandidates('- Systemet er uunngåelig komplekst og stort.'), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,10 +9,18 @@ import { tmpdir } from 'node:os';
|
|||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const CLI = resolve(__dirname, '../../scanners/knowledge-refresh-cli.mjs');
|
||||
|
||||
// The bundled register's seed entries are all verified 2026-06-20, so a reference
|
||||
// date of 2026-06-21 makes every entry exactly 1 day old — deterministic regardless
|
||||
// of the real clock. We exploit that to exercise both the fresh and stale branches.
|
||||
const REF = '2026-06-21';
|
||||
// The reference date must sit AFTER the newest entry's `verified` date, so every
|
||||
// entry is at least one day old and both the fresh and stale branches are
|
||||
// reachable deterministically, regardless of the real clock.
|
||||
//
|
||||
// It used to be 2026-06-21, on the premise that every seed entry was verified
|
||||
// 2026-06-20. That premise expired when BP-SUB-001 was added with a genuine
|
||||
// 2026-07-31 verification date — an entry verified *after* the reference date has
|
||||
// a negative age and counts as fresh, so `--stale-after 0` no longer emptied the
|
||||
// fresh bucket. Backdating the entry to fit the test would have been a lie about
|
||||
// when its source was checked; moving the reference date is the honest fix.
|
||||
// Bump this again when a newer entry lands.
|
||||
const REF = '2026-08-01';
|
||||
|
||||
function runCli(extraArgs) {
|
||||
try {
|
||||
|
|
@ -56,7 +64,7 @@ describe('knowledge-refresh-cli — payload shape', () => {
|
|||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.status, 'ok');
|
||||
assert.match(out.registerPath, /knowledge\/best-practices\.json$/);
|
||||
assert.equal(out.referenceDate, '2026-06-21');
|
||||
assert.equal(out.referenceDate, REF);
|
||||
assert.equal(out.staleAfterDays, 90);
|
||||
assert.ok(Array.isArray(out.stale));
|
||||
assert.ok(Array.isArray(out.fresh));
|
||||
|
|
@ -78,7 +86,12 @@ describe('knowledge-refresh-cli — payload shape', () => {
|
|||
const s = out.stale[0];
|
||||
assert.match(s.id, /^BP-[A-Z]+-\d{3}$/);
|
||||
assert.match(s.verified, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(s.ageDays, 1);
|
||||
// Was `=== 1`, which only held while every entry shared one verified date.
|
||||
// Assert the invariant that actually matters: ageDays is a positive integer
|
||||
// and agrees with this entry's own `verified` date against the reference.
|
||||
const expectedAge = Math.round((Date.parse(REF) - Date.parse(s.verified)) / 86400000);
|
||||
assert.equal(s.ageDays, expectedAge);
|
||||
assert.ok(s.ageDays >= 1, 'a stale entry is at least a day old');
|
||||
assert.ok(s.url && s.claim);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue