@sabaiway/agent-workflow-kit 5.5.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 (55) hide show
  1. package/CHANGELOG.md +122 -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 +20 -4
  9. package/references/modes/procedures.md +2 -0
  10. package/references/modes/recommendations.md +4 -1
  11. package/references/modes/review-state.md +1 -1
  12. package/references/modes/setup.md +18 -2
  13. package/references/modes/upgrade.md +38 -18
  14. package/references/modes/velocity.md +1 -0
  15. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  16. package/references/scripts/migrate-gates.mjs +295 -60
  17. package/references/scripts/migrate-gates.test.mjs +206 -14
  18. package/references/shared/deploy-tail.md +1 -1
  19. package/references/templates/gates.json +1 -1
  20. package/tools/ack-write.mjs +20 -11
  21. package/tools/atomic-write.mjs +71 -18
  22. package/tools/checker-claim.mjs +100 -0
  23. package/tools/coverage-producer.mjs +43 -6
  24. package/tools/direct-run.mjs +76 -0
  25. package/tools/doc-parity.mjs +34 -3
  26. package/tools/engine-source.mjs +12 -8
  27. package/tools/ensure-configs.mjs +141 -0
  28. package/tools/ensure-ops.mjs +284 -0
  29. package/tools/ensure-vocabulary.mjs +71 -0
  30. package/tools/flow-check-cores.mjs +253 -0
  31. package/tools/flow-check-git-lane.mjs +56 -0
  32. package/tools/flow-check-rungs.mjs +330 -0
  33. package/tools/flow-check.mjs +23 -611
  34. package/tools/gates-declaration.mjs +36 -11
  35. package/tools/gates-init.mjs +140 -25
  36. package/tools/hide-footprint.mjs +21 -3
  37. package/tools/lens-region.mjs +74 -23
  38. package/tools/orchestration-config.mjs +5 -3
  39. package/tools/orchestration-write.mjs +7 -0
  40. package/tools/procedures.mjs +64 -5
  41. package/tools/recommendations.mjs +384 -34
  42. package/tools/refresh-parity.mjs +263 -0
  43. package/tools/run-gates.mjs +8 -5
  44. package/tools/setup-backends.mjs +88 -77
  45. package/tools/source-size-check.mjs +310 -0
  46. package/tools/source-size-config.mjs +244 -0
  47. package/tools/source-size-core.mjs +59 -0
  48. package/tools/source-size-gate-cmd.mjs +27 -0
  49. package/tools/source-size-judge.mjs +114 -0
  50. package/tools/source-size-refusal.mjs +70 -0
  51. package/tools/source-size-report.mjs +254 -0
  52. package/tools/source-size-scope.mjs +145 -0
  53. package/tools/tracked-tree-census.mjs +102 -0
  54. package/tools/upgrade-runlist.mjs +92 -0
  55. package/tools/velocity-profile.mjs +24 -3
@@ -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,7 +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, isReviewDependentGate, GATES_REL } from './gates-declaration.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';
65
+ // Read-only surfaces of the fill (buildOffer) and of the source-size practice (its pure core). The
66
+ // fill's own WRITER is never called from here — the advisor renders its consent-gated command, it
67
+ // does not run it.
68
+ import { buildOffer } from './gates-init.mjs';
69
+ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySourceSizeGate, loadSourceSizeConfig } from './source-size-core.mjs';
59
70
  // The declared-path resolution + segment containment this item's convergence lane shares with the
60
71
  // autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
61
72
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -106,7 +117,26 @@ export const SEVERITIES = Object.freeze({
106
117
  'gates-declaration': SEVERITY_OPTIONAL,
107
118
  'gates-inert': SEVERITY_ATTENTION,
108
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,
126
+ 'source-size': SEVERITY_OPTIONAL,
127
+ // The declared-but-unminted arm reports a CONFIGURED declaration that is broken — a gate certain
128
+ // to refuse on every run — while the base arm stays an offer to enable something unconfigured.
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,
109
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,
110
140
  'commit-guard': SEVERITY_OPTIONAL,
111
141
  'read-lane': SEVERITY_OPTIONAL,
112
142
  'read-lane.stale': SEVERITY_ATTENTION,
@@ -166,7 +196,14 @@ export const WHATS = Object.freeze({
166
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',
167
197
  'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
168
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',
201
+ 'source-size': 'no source-size gate — module size drifts unmeasured, and an over-cap file is invisible instead of recorded debt',
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',
169
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',
170
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',
171
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',
172
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',
@@ -223,6 +260,7 @@ export const BENEFITS = Object.freeze({
223
260
  'review-recipe': 'recipe coverage — the review AND execution recipes you configured actually run instead of silently degrading',
224
261
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
225
262
  'gates-inert': 'honest gates — the declared matrix verifies your project instead of reporting green over a check that ran nothing',
263
+ 'source-size': 'maintainability — a module you can hold whole stays reviewable, and size drift becomes recorded, reasoned debt',
226
264
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
227
265
  'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
228
266
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
@@ -263,6 +301,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
263
301
  // converges the moment any gate exists, so it can never observe this state. The advisor key names
264
302
  // the state it reports (an inert declaration), the capability names what the user gains.
265
303
  { id: 'gates-verification', mode: 'gates', advisorKey: 'gates-inert' },
304
+ // The source-size practice is accepted AS a gate — it needs no mode of its own, and its capability
305
+ // is therefore declared where its declaration lives. It is the DISCOVERY lane for the practice on
306
+ // every deployment, new and existing alike: nothing else tells a project the practice exists.
307
+ { id: 'source-size', mode: 'gates', advisorKey: 'source-size' },
266
308
  { id: 'gate-hook', mode: 'hook', advisorKey: 'gate-hook' },
267
309
  { id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
268
310
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
@@ -402,7 +444,7 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
402
444
  }
403
445
  };
404
446
 
405
- const probeGates = ({ root, deps, add, skip }) => {
447
+ const probeGates = ({ root, deps, add, skip, shared }) => {
406
448
  try {
407
449
  const sg = surveyGateHook(root, deps);
408
450
  if (sg.error) throw new Error(sg.error);
@@ -417,9 +459,32 @@ const probeGates = ({ root, deps, add, skip }) => {
417
459
  add('gates-declaration', fillTemplate(WHATS['gates-declaration'], {}), `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`);
418
460
  return;
419
461
  }
420
- if (sg.declaredGates > 0 && !sg.wired) {
462
+ if (!sg.wired) {
421
463
  add('gate-hook', fillTemplate(WHATS['gate-hook'], { n: sg.declaredGates }), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`);
464
+ return;
422
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
+ );
423
488
  } catch (err) {
424
489
  skip('gate-hook', err);
425
490
  }
@@ -430,14 +495,34 @@ const probeGates = ({ root, deps, add, skip }) => {
430
495
  // decide with — the checker side through canonicalCheckerGates, the producer side through the
431
496
  // closed matchesCoverageProducer — so the advisor can never disagree with what --final accepts.
432
497
  //
433
- // Cause A (a canonical coverage checker with no producer anywhere in the declaration) is checked
434
- // FIRST and reported alone: its remedy declaring the producer also resolves cause B, because a
435
- // producer gate is not a kit checker. Its apply is HAND-APPLY: the producer must precede the
436
- // checker, and the fill is append-only (it refuses by name rather than reordering), so the edit is
437
- // the maintainer's.
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
502
+ // D-8 placement rule changed: when the checker is the declaration's LAST gate and the project's own
503
+ // scripts yield an offerable producer, the fill now PLACES that producer before the checker, so the
504
+ // remedy is the ordinary consent-gated preview. HAND-APPLY remains exactly where no offerable
505
+ // producer exists, or where the checker is not last — there the edit really is the maintainer's,
506
+ // because the fill never reorders entries it did not write.
438
507
  //
439
508
  // An ABSENT or EMPTY declaration belongs to the gates-declaration item; a malformed one throws out
440
509
  // of the validated reader and becomes this probe's stated skip, never a guess.
510
+ // ONE rule for every fill preview this item renders: name only the entries the fill would ACCEPT.
511
+ // An id the declaration already carries is refused as a collision, so an unrestricted preview hands
512
+ // the reader a lane that cannot converge — and both arms of this item render over a declaration that
513
+ // already carries at least one gate the offer also proposes, so both need the rule. With nothing
514
+ // selectable the bare preview is still the honest render: its own notes say why.
515
+ const fillPreviewFor = (root, ids) =>
516
+ `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}${ids.map((id) => ` --only ${id}`).join('')}`;
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
+
441
526
  export const probeGatesInert = ({ root, deps, add, skip }) => {
442
527
  try {
443
528
  const declaration = loadDeclaration(root, deps);
@@ -449,20 +534,135 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
449
534
  // ORDER decides, through the declaration's own shared predicate: a producer declared AFTER the
450
535
  // checker leaves it just as inert as no producer at all (it reads nothing, or stale bytes), and
451
536
  // only --final refuses that shape — a plain run reports every gate PASS.
452
- 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
+ }
453
624
  const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert']));
625
+ // The fill can only help when it would land the producer in the right place: the checker must
626
+ // be LAST (that is the one position the placement rule inserts before) and the offer must
627
+ // actually carry a producer the fill would ACCEPT (computed above).
628
+ const checkerIsLast = checkers[0] === gates[gates.length - 1];
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`;
454
641
  add(
455
642
  'gates-inert',
456
643
  fillTemplate(WHATS['gates-inert'], { id }),
457
- `HAND-APPLY: declare or MOVE a suite gate carrying the coverage reporters BEFORE ${id} in ${GATES_REL} (references/modes/gates.md names the exact form), or drop ${id} — the fill is append-only and cannot reorder for you`,
644
+ checkerIsLast && producer
645
+ ? fillPreviewFor(root, [producer.id])
646
+ : `HAND-APPLY: declare or MOVE a suite gate carrying the coverage reporters BEFORE ${id} in ${GATES_REL} (references/modes/gates.md names the exact form), or drop ${id} — ${blocked}`,
458
647
  );
459
648
  return;
460
649
  }
461
- if (gates.every((gate) => isReviewDependentGate(gate, root))) {
650
+ // The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
651
+ // review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
652
+ // Left out, a matrix of nothing but that gate reads as carrying project verification — and a
653
+ // project that just adopted the practice and declared nothing else would be told it is optimal.
654
+ if (gates.every((gate) => isKitOwnedCheckerGate(gate, root))) {
655
+ const declaredIds = new Set(gates.map((gate) => gate.id));
656
+ // Only PROJECT-verification entries: a non-colliding entry is not enough, it has to be one
657
+ // that RESOLVES the item, and declaring one more kit checker converges nothing — the item
658
+ // would simply fire again on the next run.
659
+ const selectable = buildOffer(root, deps).entries
660
+ .filter((entry) => !declaredIds.has(entry.id) && !isKitOwnedCheckerGate(entry, root))
661
+ .map((entry) => entry.id);
462
662
  add(
463
663
  'gates-inert',
464
664
  fillTemplate(WHATS['gates-inert.no-verification'], { n: gates.length }),
465
- `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`,
665
+ fillPreviewFor(root, selectable),
466
666
  'gates-inert.no-verification',
467
667
  );
468
668
  }
@@ -471,6 +671,89 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
471
671
  }
472
672
  };
473
673
 
674
+ // The SOURCE-SIZE offer (baseline-practices Plan 1). This is the practice's ONE discovery lane: the
675
+ // checker ships with the kit and refuses until a project declares its own scope, so without this
676
+ // item a deployment would never learn the practice exists — the same OPT-IN-SHIPS-INVISIBLE failure
677
+ // the capability registry was built for. New and existing deployments meet it identically, because
678
+ // the advisor section is mandatory at every upgrade.
679
+ //
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.
690
+ const probeSourceSize = ({ root, deps, add, skip }) => {
691
+ try {
692
+ const declaration = loadDeclaration(root, deps);
693
+ const gates = declaration.outcome === 'loaded' ? declaration.gates : [];
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) {
703
+ // A DECLARED gate is not the same fact as a working one: the checker refuses on every config
704
+ // state but MINTED, so a gate declared over an absent or half-written record reds the matrix
705
+ // on every run. Reading the declaration alone would report that deployment as adopted and say
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.
708
+ const { state } = loadSourceSizeConfig(root, deps);
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
+ );
736
+ return;
737
+ }
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);
752
+ } catch (err) {
753
+ skip('source-size', err);
754
+ }
755
+ };
756
+
474
757
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
475
758
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
476
759
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
@@ -527,15 +810,17 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
527
810
  // or un-wired hook is covered there, never double-offered here. The apply is the gate-hook
528
811
  // --read-lane PREVIEW one-liner (its own currency check + posture note fire at the writer; it may
529
812
  // prompt once — it IS a consent flow). Converges once lanes.json enables the lane.
530
- const probeReadLane = ({ root, deps, add, skip }) => {
813
+ const probeReadLane = ({ root, deps, add, skip, shared }) => {
531
814
  try {
532
815
  const sg = surveyGateHook(root, deps);
533
816
  if (sg.error) throw new Error(sg.error);
534
817
  if (!sg.wired) return; // not wired → the gate-hook item covers (no double-fire)
535
- if (!sg.filePlaced) {
536
- // Wired but the placed hook FILE is missing the hook errors on every Bash call and the lane
537
- // 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 = () =>
538
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();
539
824
  return;
540
825
  }
541
826
  if (readReadLaneToggle(root, deps)) {
@@ -543,7 +828,23 @@ const probeReadLane = ({ root, deps, add, skip }) => {
543
828
  // reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
544
829
  // rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
545
830
  // repo's hook.
546
- 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;
547
848
  add(
548
849
  'read-lane',
549
850
  fillTemplate(WHATS['read-lane.stale'], {}),
@@ -725,6 +1026,13 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
725
1026
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
726
1027
  };
727
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
+
728
1036
  // The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
729
1037
  // validator guards — top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
730
1038
  // on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
@@ -733,12 +1041,21 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
733
1041
  export const ACKS_FILE = 'docs/ai/acks.json';
734
1042
  export const ACKS_LANE_KEY = 'sandboxLaneAck';
735
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';
736
1046
  // The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's
737
1047
  // command line → the store key that writer sets. A lane the registry does not name is a usage
738
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.
739
1054
  export const ACK_LANES = Object.freeze({
740
1055
  'sandbox-lane': ACKS_LANE_KEY,
741
1056
  'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
1057
+ 'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
1058
+ 'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
742
1059
  });
743
1060
 
744
1061
  // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
@@ -775,13 +1092,33 @@ const isStateBlockGuardWired = (data) => {
775
1092
  : runsStateBlockGuard(entry)));
776
1093
  };
777
1094
 
778
- // Byte-compare the placed gate hook against the bundled runtime. A read error (an unreadable placed
779
- // hook, a broken kit bundle) propagates the probe states a skip, never a wrong currency verdict.
780
- const isPlacedHookCurrent = (root, deps) => {
781
- const readFile = deps.readFile ?? readFileSync;
782
- const placed = readFile(join(root, GATE_HOOK_REL), 'utf8');
783
- const bundle = readFile(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
784
- 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));
785
1122
  };
786
1123
 
787
1124
  // The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
@@ -851,7 +1188,7 @@ const readReadLaneToggle = (root, deps) => {
851
1188
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
852
1189
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
853
1190
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
854
- 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']);
855
1192
 
856
1193
  const probeSandboxLane = ({ root, deps, add, skip }) => {
857
1194
  try {
@@ -1054,6 +1391,7 @@ const PROBES = Object.freeze([
1054
1391
  probeReviewRecipe,
1055
1392
  probeGates,
1056
1393
  probeGatesInert,
1394
+ probeSourceSize,
1057
1395
  probeCommitGuard,
1058
1396
  probeReadLane,
1059
1397
  probeStateBlockHook,
@@ -1074,27 +1412,39 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1074
1412
  const skip = (key, err) => skips.push({ key, reason: truncatedTo(oneLineOf(err?.message ?? String(err)), SKIP_REASON_CAP) });
1075
1413
  // The runtime shape backstop (D2): every COMPOSED item is validated at construction — a
1076
1414
  // violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
1077
- // severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
1078
- // 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.
1079
1420
  // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
1080
1421
  // the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
1081
1422
  // egress hosts + resolved writable dirs; the worktrees-dir hand-apply-first grant advice; the
1082
1423
  // agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
1083
- 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) => {
1084
1429
  const problems = [];
1085
1430
  if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
1086
- 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)}`);
1087
1432
  if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
1088
1433
  else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
1089
1434
  if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
1090
1435
  if (detail != null && /[\r\n]/.test(detail)) problems.push('recipe detail is not a single line');
1091
1436
  if (problems.length > 0) {
1092
1437
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
1093
- return;
1438
+ return false;
1094
1439
  }
1095
- 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;
1096
1442
  };
1097
- 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 });
1098
1448
  return { root, items, skips };
1099
1449
  };
1100
1450