@sabaiway/agent-workflow-kit 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +7 -1
  7. package/references/modes/doc-parity.md +1 -1
  8. package/references/modes/gates.md +16 -3
  9. package/references/modes/recommendations.md +3 -0
  10. package/references/modes/review-state.md +1 -1
  11. package/references/modes/setup.md +18 -2
  12. package/references/modes/upgrade.md +38 -18
  13. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  14. package/references/scripts/migrate-gates.mjs +295 -60
  15. package/references/scripts/migrate-gates.test.mjs +206 -14
  16. package/references/shared/deploy-tail.md +1 -1
  17. package/references/templates/gates.json +1 -1
  18. package/tools/ack-write.mjs +20 -11
  19. package/tools/atomic-write.mjs +71 -18
  20. package/tools/checker-claim.mjs +100 -0
  21. package/tools/coverage-producer.mjs +43 -6
  22. package/tools/direct-run.mjs +76 -0
  23. package/tools/doc-parity.mjs +34 -3
  24. package/tools/engine-source.mjs +12 -8
  25. package/tools/ensure-configs.mjs +141 -0
  26. package/tools/ensure-ops.mjs +284 -0
  27. package/tools/ensure-vocabulary.mjs +71 -0
  28. package/tools/gates-declaration.mjs +23 -10
  29. package/tools/gates-init.mjs +6 -3
  30. package/tools/hide-footprint.mjs +21 -3
  31. package/tools/lens-region.mjs +74 -23
  32. package/tools/orchestration-config.mjs +5 -3
  33. package/tools/orchestration-write.mjs +7 -0
  34. package/tools/recommendations.mjs +315 -66
  35. package/tools/refresh-parity.mjs +263 -0
  36. package/tools/run-gates.mjs +8 -5
  37. package/tools/setup-backends.mjs +88 -77
  38. package/tools/source-size-check.mjs +6 -16
  39. package/tools/source-size-core.mjs +7 -1
  40. package/tools/source-size-gate-cmd.mjs +18 -46
  41. package/tools/tracked-tree-census.mjs +102 -0
  42. package/tools/upgrade-runlist.mjs +92 -0
@@ -27,8 +27,9 @@
27
27
  //
28
28
  // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
29
29
  // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
30
- // registry/sandbox-masks); the sandbox-masks and settings probes may run read-only git queries.
31
- // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
30
+ // registry/sandbox-masks); the sandbox-masks, settings and tracked-tree-census probes may run
31
+ // read-only git queries. Dependency-free, Node >= 22. No side effects on import (the isDirectRun
32
+ // idiom).
32
33
 
33
34
  import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
34
35
  import { createHash } from 'node:crypto';
@@ -55,13 +56,17 @@ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-re
55
56
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
57
  import { shellQuoteArg } from './review-state.mjs';
57
58
  import { isFinalCapableDeclaration } from './run-gates.mjs';
58
- import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL } from './gates-declaration.mjs';
59
- import { matchesCoverageProducer } from './coverage-producer.mjs';
59
+ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL, LCOV_PRODUCER_KEY } from './gates-declaration.mjs';
60
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
61
+ import { matchesCoverageProducer, isCoverageProducerGate } from './coverage-producer.mjs';
62
+ // How much of the TRACKED tree the changed-line coverage domain can assess at all — the fact that
63
+ // turns "the checker certifies" into "the checker certifies the assessable minority".
64
+ import { takeCensus, censusFact, CENSUS_VERDICT } from './tracked-tree-census.mjs';
60
65
  // Read-only surfaces of the fill (buildOffer) and of the source-size practice (its pure core). The
61
66
  // fill's own WRITER is never called from here — the advisor renders its consent-gated command, it
62
67
  // does not run it.
63
68
  import { buildOffer } from './gates-init.mjs';
64
- import { INITIAL_ADOPTION_REASON, loadSourceSizeConfig, matchesSourceSizeGate } from './source-size-core.mjs';
69
+ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySourceSizeGate, loadSourceSizeConfig } from './source-size-core.mjs';
65
70
  // The declared-path resolution + segment containment this item's convergence lane shares with the
66
71
  // autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
67
72
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -112,11 +117,26 @@ export const SEVERITIES = Object.freeze({
112
117
  'gates-declaration': SEVERITY_OPTIONAL,
113
118
  'gates-inert': SEVERITY_ATTENTION,
114
119
  'gates-inert.no-verification': SEVERITY_ATTENTION,
120
+ // Both third outcomes are attention, and both therefore block the flow-optimal line MECHANICALLY:
121
+ // an added item makes the verdict non-null. One reports a pair that is dead on a tree the closed
122
+ // producer world cannot speak for; the other reports a LIVE pair whose certification reaches only
123
+ // the assessable minority of that tree. Neither changes what a run may certify.
124
+ 'gates-inert.producer-unrecognized': SEVERITY_ATTENTION,
125
+ 'gates-inert.coverage-domain-narrow': SEVERITY_ATTENTION,
115
126
  'source-size': SEVERITY_OPTIONAL,
116
127
  // The declared-but-unminted arm reports a CONFIGURED declaration that is broken — a gate certain
117
128
  // to refuse on every run — while the base arm stays an offer to enable something unconfigured.
118
129
  'source-size.unminted': SEVERITY_ATTENTION,
130
+ // A vendored copy of this checker is a DELIBERATE deployment choice, not a broken declaration —
131
+ // the practice runs, the advisor simply cannot read it through its own tool; the offer is the
132
+ // acknowledgment. An id SQUATTER is the opposite: the id says adopted while nothing measures size.
133
+ 'source-size.adopted-elsewhere': SEVERITY_OPTIONAL,
134
+ 'source-size.id-squatter': SEVERITY_ATTENTION,
119
135
  'gate-hook': SEVERITY_OPTIONAL,
136
+ // A placed hook that predates the marker key goes DARK on a marker-carrying declaration (unknown
137
+ // key → auto-approval off), so a CONFIGURED capability silently stops working: attention, not an
138
+ // offer to enable something.
139
+ 'gate-hook.marker-stale': SEVERITY_ATTENTION,
120
140
  'commit-guard': SEVERITY_OPTIONAL,
121
141
  'read-lane': SEVERITY_OPTIONAL,
122
142
  'read-lane.stale': SEVERITY_ATTENTION,
@@ -176,9 +196,14 @@ export const WHATS = Object.freeze({
176
196
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
177
197
  'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
178
198
  'gates-inert.no-verification': "all {n} declared gate(s) are the kit's own checkers — the matrix runs no project-verification command",
199
+ 'gates-inert.producer-unrecognized': 'the coverage checker ({id}) has NO producer declared and none offerable, on a tree the changed-line domain barely reaches',
200
+ 'gates-inert.coverage-domain-narrow': 'certification covers the assessable minority only — {ext} dominate(s) the tracked tree, and the changed-line domain excludes them',
179
201
  'source-size': 'no source-size gate — module size drifts unmeasured, and an over-cap file is invisible instead of recorded debt',
180
202
  'source-size.unminted': 'the source-size gate is declared but its record ({state}) is not minted — the checker refuses, so this gate reds every run',
203
+ 'source-size.adopted-elsewhere': "the source-size gate runs a DIFFERENT copy of this checker ({n} declared) — adopted here, just not through this kit's own tool",
204
+ 'source-size.id-squatter': 'a gate carries the source-size id but is not this checker — the practice reads as declared while nothing measures module size',
181
205
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
206
+ 'gate-hook.marker-stale': 'the declaration carries the lcovProducer key but the placed hook predates it — the hook goes dark and every gate prompts',
182
207
  'commit-guard': 'the gate matrix is final-run-capable but no commit-guard arms the pre-commit hook — a commit needs no green receipt yet',
183
208
  'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
184
209
  'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
@@ -419,7 +444,7 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
419
444
  }
420
445
  };
421
446
 
422
- const probeGates = ({ root, deps, add, skip }) => {
447
+ const probeGates = ({ root, deps, add, skip, shared }) => {
423
448
  try {
424
449
  const sg = surveyGateHook(root, deps);
425
450
  if (sg.error) throw new Error(sg.error);
@@ -434,9 +459,32 @@ const probeGates = ({ root, deps, add, skip }) => {
434
459
  add('gates-declaration', fillTemplate(WHATS['gates-declaration'], {}), `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`);
435
460
  return;
436
461
  }
437
- if (sg.declaredGates > 0 && !sg.wired) {
462
+ if (!sg.wired) {
438
463
  add('gate-hook', fillTemplate(WHATS['gate-hook'], { n: sg.declaredGates }), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`);
464
+ return;
439
465
  }
466
+ // D8 — the marker REFRESH path. The placed hook validates the declaration through its OWN baked
467
+ // copy and goes dark on any key it does not know (auto-approval off, every gate prompts again),
468
+ // so a declaration carrying the marker key under a hook that predates it silently switches a
469
+ // CONFIGURED capability off with no error anywhere. Deliberately marker-SCOPED: a stale hook is
470
+ // otherwise harmless, and nagging every deployment about hook bytes is not this item's job.
471
+ if (!declarationCarriesMarker(root, deps)) return;
472
+ // Throws on a symlink / directory / unreadable target → the stated skip, never a wrong verdict.
473
+ // ABSENT is not this arm either: a hook that is not placed belongs to the place offers above and
474
+ // in the read-lane item, whose recovery actually places one.
475
+ if (readPlacedHookCurrency(root, deps) !== HOOK_CURRENCY.STALE) return;
476
+ // The writer's OWN stale recovery, in its exact shape: `gate-hook --apply` never overwrites a
477
+ // placed diverged hook (it places only an ABSENT target), so remove-then-reseed is the only lane
478
+ // that converges — and it is maintainer territory, hence HAND-APPLY.
479
+ // The RESULT is what the read-lane item defers to — not these conditions re-derived there. Both
480
+ // probes read the placed hook independently, so a hook that changes between the two reads would
481
+ // otherwise let each conclude the other owns it and leave a dark lane reported by nobody.
482
+ shared.markerStaleRendered = add(
483
+ 'gate-hook',
484
+ fillTemplate(WHATS['gate-hook.marker-stale'], {}),
485
+ `HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
486
+ 'gate-hook.marker-stale',
487
+ );
440
488
  } catch (err) {
441
489
  skip('gate-hook', err);
442
490
  }
@@ -447,9 +495,10 @@ const probeGates = ({ root, deps, add, skip }) => {
447
495
  // decide with — the checker side through canonicalCheckerGates, the producer side through the
448
496
  // closed matchesCoverageProducer — so the advisor can never disagree with what --final accepts.
449
497
  //
450
- // Cause A (a canonical coverage checker with no producer anywhere in the declaration) is checked
451
- // FIRST and reported alone: its remedy declaring the producer also resolves cause B, because a
452
- // producer gate is not a kit checker. Its apply follows what the FILL can actually do, which the
498
+ // A declared CHECKER is answered first and alone, in the arms below: the DOMAIN arm when the pair is
499
+ // live, the marker arm when no producer exists anywhere on a tree the domain cannot reach, and cause
500
+ // A the dead or mis-ordered pair — otherwise. Its remedy also resolves cause B, because a producer
501
+ // gate is not a kit checker. Cause A's apply follows what the FILL can actually do, which the
453
502
  // D-8 placement rule changed: when the checker is the declaration's LAST gate and the project's own
454
503
  // scripts yield an offerable producer, the fill now PLACES that producer before the checker, so the
455
504
  // remedy is the ordinary consent-gated preview. HAND-APPLY remains exactly where no offerable
@@ -466,6 +515,14 @@ const probeGates = ({ root, deps, add, skip }) => {
466
515
  const fillPreviewFor = (root, ids) =>
467
516
  `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}${ids.map((id) => ` --only ${id}`).join('')}`;
468
517
 
518
+ // The census, injectable WHOLE and only whole. Forwarding this probe's `deps` into the leaf would
519
+ // hand it whatever `spawn` another probe's fixture happens to inject, so a masks-probe stub would
520
+ // silently become this probe's git — the census takes its own default and a test replaces the
521
+ // function, never its seams. It THROWS on an unavailable tree; each call site below decides what
522
+ // that means there, because the two arms have different honest answers and one global fallback
523
+ // would be wrong for one of them.
524
+ const readCensus = (root, deps) => (deps.takeCensus ?? takeCensus)(root);
525
+
469
526
  export const probeGatesInert = ({ root, deps, add, skip }) => {
470
527
  try {
471
528
  const declaration = loadDeclaration(root, deps);
@@ -477,27 +534,110 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
477
534
  // ORDER decides, through the declaration's own shared predicate: a producer declared AFTER the
478
535
  // checker leaves it just as inert as no producer at all (it reads nothing, or stale bytes), and
479
536
  // only --final refuses that shape — a plain run reports every gate PASS.
480
- if (coverageProducerPrecedes(gates, gates.indexOf(checkers[0]))) return; // the pair is live
537
+ if (coverageProducerPrecedes(gates, gates.indexOf(checkers[0]))) {
538
+ // The pair is LIVE, and that is exactly where the DOMAIN question becomes the honest one: the
539
+ // checker's changed-line domain is `.mjs/.cjs/.js` by design, so on a tree dominated by what
540
+ // that domain excludes, "certified" means "certified over the assessable minority". An
541
+ // UNAVAILABLE census throws out of here into the stated-skip lane — no census, no optimality
542
+ // claim; this is the ONE arm where nothing else would render, so a silent return would be the
543
+ // false green one layer down.
544
+ const census = readCensus(root, deps);
545
+ if (census.verdict !== CENSUS_VERDICT.NARROW) return; // the domain reaches this tree — converged
546
+ // The ack binds the FACT (verdict + the sorted unsupported extensions), never the counts: a
547
+ // count-bound ack would re-fire on every added file and turn the acknowledgment into a nag.
548
+ const fingerprint = factFingerprint(censusFact(census));
549
+ if (readAckValue(root, deps, ACKS_COVERAGE_DOMAIN_KEY) === fingerprint) return; // acknowledged
550
+ const ext = capList(census.unsupportedExtensions, templateBudget(WHATS['gates-inert.coverage-domain-narrow']), ', ');
551
+ add(
552
+ 'gates-inert',
553
+ fillTemplate(WHATS['gates-inert.coverage-domain-narrow'], { ext }),
554
+ `node ${q(toolPath('ack-write.mjs'))} --lane coverage-domain --fingerprint ${fingerprint} --cwd ${q(root)}`,
555
+ 'gates-inert.coverage-domain-narrow',
556
+ );
557
+ return;
558
+ }
559
+ // The fill's own answer is needed BEFORE the arms split, because one of them makes a claim
560
+ // about it. The PRODUCER entry is kept, not just its existence: a rendered preview must name it
561
+ // with --only. A whole-offer apply collides by construction — the declaration already carries
562
+ // the checker, and the offer carries it too — so an unrestricted preview would hand the reader
563
+ // a lane that refuses instead of the one entry that resolves what the item just reported.
564
+ // Read once and asked TWO different questions, because the two arms need different ones. The
565
+ // fill's selectable entry is collision-filtered: an id the declaration already carries is
566
+ // refused, so counting it would render a preview that cannot fix what the item just reported.
567
+ // The EXPRESSIBILITY question is not filtered at all — a `node --test` script whose offered id
568
+ // happens to be taken is still a suite the recognized producer set can express, and the narrow
569
+ // arm's sentence would be false over it. An offer computation that throws reaches the probe's
570
+ // stated-skip lane, which is the honest answer to "can the fill help here" when nobody knows.
571
+ const declaredIds = new Set(gates.map((gate) => gate.id));
572
+ const offered = buildOffer(root, deps).entries.filter((entry) => matchesCoverageProducer(entry.cmd));
573
+ const producer = offered.find((entry) => !declaredIds.has(entry.id));
574
+ // The dead pair on a tree the recognized producer set cannot speak for. It is deliberately NOT
575
+ // the ordering arm: a producer that EXISTS but sits after the checker is one MOVE away from
576
+ // working, and prescribing the marker there would teach the wrong fix — so this arm requires
577
+ // no producer ANYWHERE in the declaration. The ack lane is closed to it on purpose: a dead pair
578
+ // is broken, not narrow, and removing a producer after an acknowledgment lands right back here.
579
+ //
580
+ // The canonical checker ROWS are excluded from that search, and the exclusion is load-bearing:
581
+ // producer-ness is POSITIONAL everywhere else in this family precisely so a marker on the
582
+ // checker cannot self-pair, and an "anywhere" test without the exclusion re-opens exactly that
583
+ // hole — a checker carrying its own marker would read as its own producer and route a dead pair
584
+ // into the ordering arm.
585
+ //
586
+ // An OFFERED producer also disqualifies this arm, and that is the sharper condition of the
587
+ // two. The census answers how much of the tree the coverage DOMAIN reaches; it says nothing
588
+ // about producers at all. A TS-dominated project whose package.json still carries a
589
+ // `node --test` script has both facts at once, and the arm must not fire over it. The test is
590
+ // the UNFILTERED offer, not the fill's selectable entry: a collision on the offered id blocks
591
+ // the preview without making the producer one bit less real.
592
+ //
593
+ // STATED RESIDUAL, and the reason this arm no longer claims INEXPRESSIBILITY: the fill screens
594
+ // by terminating-class script NAME before it ever looks at a body, so `"ci": "node --test"` is
595
+ // a recognizable producer the offer never carries. The arm therefore says only what it knows —
596
+ // nothing declares one and nothing offers one — and its apply names that gap, because the
597
+ // remedy there is a hand-declared gate rather than the marker.
598
+ if (offered.length === 0 && !gates.some((gate) => !checkers.includes(gate) && isCoverageProducerGate(gate))) {
599
+ // An UNAVAILABLE census is NOT a skip here. The dead pair is a defect on any tree, and the
600
+ // arm below states it truthfully — falling through keeps a non-git deployment byte-identical
601
+ // to what it saw before this outcome existed, and still renders an item, so the flow-optimal
602
+ // line stays blocked either way. Only THAT failure is absorbed: the census leaf tags its own
603
+ // unavailability, and a bug reaching here would otherwise be laundered into an ordinary
604
+ // diagnosis instead of surfacing.
605
+ const narrow = (() => {
606
+ try {
607
+ return readCensus(root, deps).verdict === CENSUS_VERDICT.NARROW;
608
+ } catch (err) {
609
+ if (err?.code === 'CENSUS_UNAVAILABLE') return false;
610
+ throw err;
611
+ }
612
+ })();
613
+ if (narrow) {
614
+ const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert.producer-unrecognized']));
615
+ add(
616
+ 'gates-inert',
617
+ fillTemplate(WHATS['gates-inert.producer-unrecognized'], { id }),
618
+ `HAND-APPLY: mark the gate that actually writes the lcov with "lcovProducer": true in ${GATES_REL} (references/modes/gates.md names the key), or drop ${id} — and note the fill offers only terminating-class script NAMES, so a recognized body under another name (a "ci" script running node --test) is declared by hand, not marked`,
619
+ 'gates-inert.producer-unrecognized',
620
+ );
621
+ return;
622
+ }
623
+ }
481
624
  const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert']));
482
625
  // The fill can only help when it would land the producer in the right place: the checker must
483
626
  // be LAST (that is the one position the placement rule inserts before) and the offer must
484
- // actually carry a producer the fill would ACCEPT. An offered id that is already declared is
485
- // refused as a collision, so counting it here would render a preview that cannot fix what the
486
- // item just reported.
487
- const declaredIds = new Set(gates.map((gate) => gate.id));
627
+ // actually carry a producer the fill would ACCEPT (computed above).
488
628
  const checkerIsLast = checkers[0] === gates[gates.length - 1];
489
- // The PRODUCER entry is kept, not just its existence: the rendered preview must name it with
490
- // --only. A whole-offer apply here collides by construction the declaration already carries
491
- // the checker, and the offer carries it too so an unrestricted preview would hand the reader
492
- // a lane that refuses instead of the one entry that resolves what the item just reported.
493
- const producer = buildOffer(root, deps).entries
494
- .find((entry) => !declaredIds.has(entry.id) && matchesCoverageProducer(entry.cmd));
495
- // The two ways the fill cannot help are DIFFERENT situations and need different sentences: a
496
- // checker that is not last blocks a placement even when a producer is offerable, and saying no
497
- // producer exists there would be plainly false to a reader looking at their own scripts.
498
- const blocked = checkerIsLast
499
- ? `no offerable producer exists here, and the fill never reorders entries it did not write`
500
- : `${id} is not the LAST declared gate, so there is no trailing position to place a producer before, and the fill never reorders entries it did not write`;
629
+ // THREE ways the fill cannot help, and each needs its own sentence, because each names a
630
+ // different edit for the reader to make. A checker that is not last blocks a placement even
631
+ // when a producer is offerable that is about POSITION, so it leads. An offered producer whose
632
+ // id is already taken is not an absent one: the suite is expressible and the fill is merely
633
+ // refused on the collision, so naming the conflicting id turns a dead end into a rename. Only
634
+ // with neither of those is "no offerable producer exists here" a true sentence.
635
+ const colliding = producer === undefined ? offered.find((entry) => declaredIds.has(entry.id)) : undefined;
636
+ const blocked = !checkerIsLast
637
+ ? `${id} is not the LAST declared gate, so there is no trailing position to place a producer before, and the fill never reorders entries it did not write`
638
+ : colliding !== undefined
639
+ ? `a producer IS offerable here, but its id "${colliding.id}" is already declared, so the fill refuses it as a collision — rename that gate, or repoint it at the producer form`
640
+ : `no offerable producer exists here, and the fill never reorders entries it did not write`;
501
641
  add(
502
642
  'gates-inert',
503
643
  fillTemplate(WHATS['gates-inert'], { id }),
@@ -511,10 +651,6 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
511
651
  // review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
512
652
  // Left out, a matrix of nothing but that gate reads as carrying project verification — and a
513
653
  // project that just adopted the practice and declared nothing else would be told it is optimal.
514
- // The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
515
- // review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
516
- // Left out, a matrix of nothing but that gate reads as carrying project verification — and a
517
- // project that just adopted the practice and declared nothing else would be told it is optimal.
518
654
  if (gates.every((gate) => isKitOwnedCheckerGate(gate, root))) {
519
655
  const declaredIds = new Set(gates.map((gate) => gate.id));
520
656
  // Only PROJECT-verification entries: a non-colliding entry is not enough, it has to be one
@@ -541,31 +677,78 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
541
677
  // the capability registry was built for. New and existing deployments meet it identically, because
542
678
  // the advisor section is mandatory at every upgrade.
543
679
  //
544
- // The probe asks ONE question does the declaration carry the canonical source-size gate? through
545
- // the practice's own matcher, so an id squatter (a gate called `source-size` running something else)
546
- // never reads as adopted. It deliberately does NOT key on the config's state: a project with no
547
- // config is exactly the project that needs to hear about the practice, and the apply's own refusal
548
- // is what teaches the one manual step (authoring the scope). A missing declaration is not a skip —
549
- // there is no source-size gate in it either.
680
+ // The probe asks what each declared cmd CLAIMS about this checker, in the three named outcomes
681
+ // because the boolean it used to ask made a DELIBERATELY VENDORED copy read as "no source-size gate
682
+ // at all", and the remedy that false absence rendered (`--adopt`) then collides on the very id
683
+ // already in the declaration. It deliberately does NOT key on the config's state to decide WHETHER
684
+ // to speak: a project with no config is exactly the project that needs to hear about the practice,
685
+ // and the apply's own refusal is what teaches the one manual step (authoring the scope). A missing
686
+ // declaration is not a skip — there is no source-size gate in it either.
687
+ //
688
+ // Precedence over a MIXED declaration is pinned and deterministic: any canonical match answers the
689
+ // practice question outright; else any tool-elsewhere claim; else the id collision; else the offer.
550
690
  const probeSourceSize = ({ root, deps, add, skip }) => {
551
691
  try {
552
692
  const declaration = loadDeclaration(root, deps);
553
693
  const gates = declaration.outcome === 'loaded' ? declaration.gates : [];
554
- const applyLine = `node ${q(toolPath('source-size-check.mjs'))} --adopt --reason "${INITIAL_ADOPTION_REASON}" --cwd ${q(root)}`;
555
- if (gates.some((gate) => matchesSourceSizeGate(gate.cmd, root))) {
694
+ const tool = toolPath('source-size-check.mjs');
695
+ // The reason string is PINNED, not composed: it is copied unchanged into every entry the first
696
+ // mint records, and a first mint records the whole tree — so "this is what the tree already
697
+ // carried when the practice arrived" is the one sentence that is true of all of them.
698
+ const adoptLine = `node ${q(tool)} --adopt --reason "${INITIAL_ADOPTION_REASON}" --cwd ${q(root)}`;
699
+ const claims = gates.map((gate) => ({ gate, claim: classifySourceSizeGate(gate.cmd, root) }));
700
+ const canonical = claims.filter((c) => c.claim === CHECKER_CLAIM.CANONICAL);
701
+ const elsewhere = claims.filter((c) => c.claim === CHECKER_CLAIM.ELSEWHERE);
702
+ if (canonical.length > 0 || elsewhere.length > 0) {
556
703
  // A DECLARED gate is not the same fact as a working one: the checker refuses on every config
557
704
  // state but MINTED, so a gate declared over an absent or half-written record reds the matrix
558
705
  // on every run. Reading the declaration alone would report that deployment as adopted and say
559
- // nothing about the one thing that is wrong with it.
706
+ // nothing about the one thing that is wrong with it. The split changes NOTHING here — the
707
+ // outcome, its attention class and its immunity to every ack are the same whichever copy runs.
560
708
  const { state } = loadSourceSizeConfig(root, deps);
561
- if (state === 'minted') return; // adopted and armed — converged
562
- add('source-size', fillTemplate(WHATS['source-size.unminted'], { state }), applyLine, 'source-size.unminted');
709
+ if (state !== 'minted') {
710
+ // Only the way OUT differs. With the gate declared through another copy, `--adopt` would
711
+ // mint the record and then be refused by the fill on the id it is already looking at, so the
712
+ // rendered verb is the mint alone: the declaration is not the half that is missing.
713
+ const mintLine = `node ${q(tool)} --write-baseline --reason "${INITIAL_ADOPTION_REASON}" --cwd ${q(root)}`;
714
+ add(
715
+ 'source-size',
716
+ fillTemplate(WHATS['source-size.unminted'], { state }),
717
+ canonical.length > 0 ? adoptLine : mintLine,
718
+ 'source-size.unminted',
719
+ );
720
+ return;
721
+ }
722
+ if (canonical.length > 0) return; // adopted through THIS copy and armed — converged
723
+ // A vendored copy is a deployment CHOICE, not a defect: the practice runs, the realpath anchor
724
+ // simply cannot see it as this advisor's own sibling (and never widens — that anchor is what
725
+ // keeps a lookalike from certifying). So the convergence is an acknowledgment, never `--adopt`.
726
+ // The fingerprint binds the claims as AUTHORED, sorted and de-duplicated — not the resolved
727
+ // realpaths, which are machine-specific and would churn a committed ack between machines.
728
+ const fingerprint = factFingerprint(JSON.stringify([...new Set(elsewhere.map((c) => c.gate.cmd))].sort()));
729
+ if (readAckValue(root, deps, ACKS_SOURCE_SIZE_COPY_KEY) === fingerprint) return; // acknowledged
730
+ add(
731
+ 'source-size',
732
+ fillTemplate(WHATS['source-size.adopted-elsewhere'], { n: elsewhere.length }),
733
+ `node ${q(toolPath('ack-write.mjs'))} --lane source-size-copy --fingerprint ${fingerprint} --cwd ${q(root)}`,
734
+ 'source-size.adopted-elsewhere',
735
+ );
563
736
  return;
564
737
  }
565
- // The reason string is PINNED, not composed: it is copied unchanged into every entry the first
566
- // mint records, and a first mint records the whole tree so "this is what the tree already
567
- // carried when the practice arrived" is the one sentence that is true of all of them.
568
- add('source-size', fillTemplate(WHATS['source-size'], {}), applyLine);
738
+ // The id SQUATTER the id says the practice is declared, the cmd is not this checker under any
739
+ // reading. `--adopt` here mints the record and is then refused by the fill on the id collision:
740
+ // correct against a squatter, and useless as a RENDERED remedy, so this arm names the two hand
741
+ // edits that actually resolve it instead.
742
+ if (gates.some((gate) => gate.id === SOURCE_SIZE_GATE_ID)) {
743
+ add(
744
+ 'source-size',
745
+ fillTemplate(WHATS['source-size.id-squatter'], {}),
746
+ `HAND-APPLY: in ${GATES_REL}, either rename the "${SOURCE_SIZE_GATE_ID}" gate to an id of its own, or repoint its cmd at node ${q(tool)} --check — then re-run recommendations`,
747
+ 'source-size.id-squatter',
748
+ );
749
+ return;
750
+ }
751
+ add('source-size', fillTemplate(WHATS['source-size'], {}), adoptLine);
569
752
  } catch (err) {
570
753
  skip('source-size', err);
571
754
  }
@@ -627,15 +810,17 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
627
810
  // or un-wired hook is covered there, never double-offered here. The apply is the gate-hook
628
811
  // --read-lane PREVIEW one-liner (its own currency check + posture note fire at the writer; it may
629
812
  // prompt once — it IS a consent flow). Converges once lanes.json enables the lane.
630
- const probeReadLane = ({ root, deps, add, skip }) => {
813
+ const probeReadLane = ({ root, deps, add, skip, shared }) => {
631
814
  try {
632
815
  const sg = surveyGateHook(root, deps);
633
816
  if (sg.error) throw new Error(sg.error);
634
817
  if (!sg.wired) return; // not wired → the gate-hook item covers (no double-fire)
635
- if (!sg.filePlaced) {
636
- // Wired but the placed hook FILE is missing the hook errors on every Bash call and the lane
637
- // is silently dark; surface it as attention with a place-first recovery (council R2-M2).
818
+ // Wired but the placed hook FILE is missing — the hook errors on every Bash call and the lane
819
+ // is silently dark; surface it as attention with a place-first recovery (council R2-M2).
820
+ const placeRecovery = () =>
638
821
  add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
822
+ if (!sg.filePlaced) {
823
+ placeRecovery();
639
824
  return;
640
825
  }
641
826
  if (readReadLaneToggle(root, deps)) {
@@ -643,7 +828,23 @@ const probeReadLane = ({ root, deps, add, skip }) => {
643
828
  // reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
644
829
  // rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
645
830
  // repo's hook.
646
- if (isPlacedHookCurrent(root, deps)) return; // converged
831
+ const currency = readPlacedHookCurrency(root, deps);
832
+ if (currency === HOOK_CURRENCY.CURRENT) return; // converged
833
+ if (currency === HOOK_CURRENCY.ABSENT) {
834
+ // The survey saw it placed and it is gone by the time we read it. The missing arm is the
835
+ // honest report of that; a reseed recovery whose `rm` targets nothing is not.
836
+ placeRecovery();
837
+ return;
838
+ }
839
+ // ONE render per condition — and the item that renders is the one whose CAUSE is true. A hook
840
+ // that postdates the read-lane but predates the marker key reads lanes.json perfectly well, so
841
+ // this arm's "an old hook never reads lanes.json" would be a false diagnosis over it, while the
842
+ // marker arm's is exact. Both carry the same remove-then-reseed recovery, so deferring costs
843
+ // the reader nothing and buys a true sentence. The condition is that the marker arm REALLY
844
+ // rendered — never the conditions it would have used, re-derived here: each probe reads the
845
+ // placed hook itself, so a hook changing between the two reads could otherwise have both defer
846
+ // to the other and leave the dark lane reported by nobody.
847
+ if (shared.markerStaleRendered === true) return;
647
848
  add(
648
849
  'read-lane',
649
850
  fillTemplate(WHATS['read-lane.stale'], {}),
@@ -825,6 +1026,13 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
825
1026
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
826
1027
  };
827
1028
 
1029
+ // The fingerprint for an acknowledgment whose subject is already a canonical STRING — the census
1030
+ // fact, the sorted set of declared tool-elsewhere claims — rather than a hosts ∪ dirs recipe. Same
1031
+ // 16-hex shape the ack writer validates, so every lane records one comparable token; the canonical
1032
+ // form is the caller's, because only the caller knows which part of its fact is durable and which
1033
+ // is churn (the census binds the verdict + extension set, never per-file counts).
1034
+ export const factFingerprint = (fact) => createHash('sha256').update(fact).digest('hex').slice(0, 16);
1035
+
828
1036
  // The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
829
1037
  // validator guards — top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
830
1038
  // on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
@@ -833,12 +1041,21 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
833
1041
  export const ACKS_FILE = 'docs/ai/acks.json';
834
1042
  export const ACKS_LANE_KEY = 'sandboxLaneAck';
835
1043
  export const ACKS_WORKTREES_DIR_KEY = 'worktreesDirAck';
1044
+ export const ACKS_COVERAGE_DOMAIN_KEY = 'coverageDomainAck';
1045
+ export const ACKS_SOURCE_SIZE_COPY_KEY = 'sourceSizeCopyAck';
836
1046
  // The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's
837
1047
  // command line → the store key that writer sets. A lane the registry does not name is a usage
838
1048
  // refusal at the writer, never a newly-invented key in the shared store.
1049
+ //
1050
+ // An ack lane exists for a state the maintainer can only ANSWER, never converge: a tracked tree the
1051
+ // coverage domain cannot reach, a checker deliberately vendored elsewhere. It is deliberately NOT
1052
+ // available to a state that is simply BROKEN — a dead checker/producer pair is fixed, not
1053
+ // acknowledged, so no lane names it.
839
1054
  export const ACK_LANES = Object.freeze({
840
1055
  'sandbox-lane': ACKS_LANE_KEY,
841
1056
  'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
1057
+ 'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
1058
+ 'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
842
1059
  });
843
1060
 
844
1061
  // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
@@ -875,13 +1092,33 @@ const isStateBlockGuardWired = (data) => {
875
1092
  : runsStateBlockGuard(entry)));
876
1093
  };
877
1094
 
878
- // Byte-compare the placed gate hook against the bundled runtime. A read error (an unreadable placed
879
- // hook, a broken kit bundle) propagates the probe states a skip, never a wrong currency verdict.
880
- const isPlacedHookCurrent = (root, deps) => {
881
- const readFile = deps.readFile ?? readFileSync;
882
- const placed = readFile(join(root, GATE_HOOK_REL), 'utf8');
883
- const bundle = readFile(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
884
- return placed === bundle;
1095
+ // The placed gate hook's currency, as the states a caller must tell APART (D8). Read through the
1096
+ // writer-class fail-closed no-follow primitive, never a bare readFile: a byte-compare that FOLLOWS a
1097
+ // symlink can call a placed hook current because something ELSE is — a wrong verdict, which is worse
1098
+ // than a missing one. `current` and `stale` are answers; `absent` is one the caller disposes of
1099
+ // itself (a hook that is not there belongs to the place offers, never to a refresh arm); a symlink,
1100
+ // a directory and an unreadable target all THROW into the probe's stated-skip lane.
1101
+ const HOOK_CURRENCY = Object.freeze({ CURRENT: 'current', STALE: 'stale', ABSENT: 'absent' });
1102
+ const readPlacedHookCurrency = (root, deps) => {
1103
+ const read = deps.readRegularFileNoFollow ?? readRegularFileNoFollow;
1104
+ const placed = read(join(root, GATE_HOOK_REL));
1105
+ if (placed.outcome === 'absent') return HOOK_CURRENCY.ABSENT;
1106
+ if (placed.outcome === 'foreign') {
1107
+ throw new Error(`${GATE_HOOK_REL} is a ${placed.className}, not a regular file — refusing to judge its currency`);
1108
+ }
1109
+ if (placed.outcome !== 'ok') throw new Error(`${GATE_HOOK_REL} is unreadable (${placed.code})`);
1110
+ const bundle = (deps.readFile ?? readFileSync)(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
1111
+ return placed.content === bundle ? HOOK_CURRENCY.CURRENT : HOOK_CURRENCY.STALE;
1112
+ };
1113
+
1114
+ // Does the declaration carry the marker KEY at all? The question is PRESENCE, never the value: an
1115
+ // older hook rejects a key it does not know whatever that key says, so `lcovProducer: false` — a
1116
+ // perfectly valid declaration the runner accepts — darkens such a hook exactly as `true` does. Asking
1117
+ // through the producer predicate would be wrong twice over: it answers yes for a cmd-recognized
1118
+ // producer that needs nothing from the hook, and no for the false marker that does.
1119
+ const declarationCarriesMarker = (root, deps) => {
1120
+ const declaration = loadDeclaration(root, deps);
1121
+ return declaration.outcome === 'loaded' && declaration.gates.some((gate) => Object.hasOwn(gate, LCOV_PRODUCER_KEY));
885
1122
  };
886
1123
 
887
1124
  // The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
@@ -951,7 +1188,7 @@ const readReadLaneToggle = (root, deps) => {
951
1188
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
952
1189
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
953
1190
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
954
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert']);
1191
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook']);
955
1192
 
956
1193
  const probeSandboxLane = ({ root, deps, add, skip }) => {
957
1194
  try {
@@ -1175,27 +1412,39 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1175
1412
  const skip = (key, err) => skips.push({ key, reason: truncatedTo(oneLineOf(err?.message ?? String(err)), SKIP_REASON_CAP) });
1176
1413
  // The runtime shape backstop (D2): every COMPOSED item is validated at construction — a
1177
1414
  // violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
1178
- // severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
1179
- // its class differs from the base (the invalid-env attention arm).
1415
+ // `variant` defaults to the item key; a per-site arm passes its `<key>.<variant>` entry. It is
1416
+ // BOTH the severity lookup and the machine-readable outcome identifier: the human render says
1417
+ // which item fired, and only this field says which ARM of it did — so a consumer (the pre-publish
1418
+ // smoke asserting a specific false-green never returns) can assert the exact outcome instead of
1419
+ // pattern-matching prose that is free to be reworded.
1180
1420
  // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
1181
1421
  // the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
1182
1422
  // egress hosts + resolved writable dirs; the worktrees-dir hand-apply-first grant advice; the
1183
1423
  // agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
1184
- const add = (key, what, apply, severityKey = key, detail = null) => {
1424
+ // Returns whether the item really RENDERED. One probe's disposition depends on another's having
1425
+ // spoken (the marker-stale ⟷ read-lane.stale precedence), and "the conditions still look right"
1426
+ // is not the same fact as "an item exists" — the shape backstop can refuse, and a condition read
1427
+ // twice can answer twice.
1428
+ const add = (key, what, apply, variant = key, detail = null) => {
1185
1429
  const problems = [];
1186
1430
  if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
1187
- if (!(severityKey in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(severityKey)}`);
1431
+ if (!(variant in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(variant)}`);
1188
1432
  if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
1189
1433
  else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
1190
1434
  if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
1191
1435
  if (detail != null && /[\r\n]/.test(detail)) problems.push('recipe detail is not a single line');
1192
1436
  if (problems.length > 0) {
1193
1437
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
1194
- return;
1438
+ return false;
1195
1439
  }
1196
- items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply, detail });
1440
+ items.push({ key, variant, severity: SEVERITIES[variant], what, benefit: BENEFITS[key], apply, detail });
1441
+ return true;
1197
1442
  };
1198
- for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip });
1443
+ // The per-run scratch a probe uses to tell a LATER probe what it actually did. Written by exactly
1444
+ // one pair today (the marker-stale ⟷ read-lane.stale precedence) and read in the frozen PROBES
1445
+ // order, so the reader can never run first.
1446
+ const shared = {};
1447
+ for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip, shared });
1199
1448
  return { root, items, skips };
1200
1449
  };
1201
1450