@sabaiway/agent-workflow-kit 5.5.0 → 5.6.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.
@@ -11,6 +11,7 @@ import { join, isAbsolute } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
13
13
  import { matchesCoverageProducer } from './coverage-producer.mjs';
14
+ import { matchesSourceSizeGate } from './source-size-core.mjs';
14
15
 
15
16
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
16
17
  // user can open (the orchestration-config CONFIG_REL idiom).
@@ -174,7 +175,8 @@ export const coverageDeclarationDefects = (gates, projectDir) => {
174
175
  message:
175
176
  `${GATES_REL}: the canonical coverage checker (${checkers[0].id}) must be the LAST declared gate — ` +
176
177
  `${after.map((g) => g.id).join(', ')} would run after it consumed the lcov. REORDER the declaration by hand ` +
177
- '(the gate itself is fine — this is an ORDERING refusal, and the fill is append-only, so it cannot reorder for you)',
178
+ '(the gate itself is fine — this is an ORDERING refusal about entries that are ALREADY declared: the fill ' +
179
+ 'places a new entry before a trailing checker, but it never reorders what it did not write)',
178
180
  }];
179
181
  }
180
182
  if (!coverageProducerPrecedes(gates, index)) {
@@ -202,6 +204,16 @@ const REVIEW_DEPENDENT_CHECKS = ['review-state', 'commit-guard', 'coverage-check
202
204
  export const isReviewDependentGate = (gate, projectDir) =>
203
205
  REVIEW_DEPENDENT_CHECKS.some((check) => matchesCanonicalCheck(check, gate.cmd, projectDir));
204
206
 
207
+ // isKitOwnedCheckerGate — is this gate one of the KIT's own checkers, rather than something the
208
+ // project declared to verify itself? A separate question from review-dependence, and the two stopped
209
+ // coinciding the moment a kit checker arrived that needs no receipt: the source-size gate is
210
+ // deliberately in neither FINAL_CORE_CHECKS nor REVIEW_DEPENDENT_CHECKS, so every surface asking
211
+ // "does this declaration verify the PROJECT?" through the review-dependent predicate alone read a
212
+ // matrix of nothing but that gate as project verification. Three surfaces asked it, each knowing a
213
+ // different half; this is the one home, so a future kit checker is added once.
214
+ export const isKitOwnedCheckerGate = (gate, projectDir) =>
215
+ isReviewDependentGate(gate, projectDir) || matchesSourceSizeGate(gate.cmd, projectDir);
216
+
205
217
  // ── the pregate subset derivation (#66 / Decision 7 — ONE home for producer and factory) ─────
206
218
 
207
219
  export const unknownPregateExcludeIds = (gates, exclude) => {
@@ -45,11 +45,12 @@
45
45
  // tool path (spaces survive; executes from the project root).
46
46
  //
47
47
  // Write discipline: preview (dry-run) is the DEFAULT and writes NOTHING — a declined offer leaves
48
- // the file byte-identical. `--apply` appends EXACTLY the consented entries (`--only <id>`
48
+ // the file byte-identical. `--apply` writes EXACTLY the consented entries (`--only <id>`
49
49
  // repeatable) through the shared atomic-write core (tools/atomic-write.mjs — exclusive-create
50
- // tmp+rename, TOCTOU re-check, symlink STOPs): append-only, never modifies or removes an existing
51
- // entry, refuses id collisions, refuses a malformed declaration (never writes over what it cannot
52
- // parse). Deployment-gated: docs/ai presence (lstat, no-follow) on EVERY run; the
50
+ // tmp+rename, TOCTOU re-check, symlink STOPs): ADD-ONLY (it never modifies, removes or reorders an
51
+ // existing entry), refuses id collisions, refuses a malformed declaration (never writes over what it
52
+ // cannot parse). WHERE a consented entry lands is a PLACEMENT rule, not a blind append — see
53
+ // placeEntries below. Deployment-gated: docs/ai presence (lstat, no-follow) on EVERY run; the
53
54
  // .workflow-version == lineage-head stamp gate on --apply only (the velocity/gate-hook precedent).
54
55
  //
55
56
  // Exit codes: 0 done / dry-run; 1 precondition STOP (no deployment, stamp, symlink, malformed
@@ -60,10 +61,20 @@ import { join, resolve, dirname } from 'node:path';
60
61
  import { fileURLToPath, pathToFileURL } from 'node:url';
61
62
  import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
62
63
  import { GATES_REL, validateDeclaration } from './run-gates.mjs';
63
- import { coverageDeclarationDefects, isReviewDependentGate } from './gates-declaration.mjs';
64
+ import { canonicalCheckerGates, coverageDeclarationDefects, isKitOwnedCheckerGate } from './gates-declaration.mjs';
64
65
  import { COVERAGE_PRODUCER_BODY, matchesCoverageProducer } from './coverage-producer.mjs';
65
66
  import { loadConfig } from './orchestration-config.mjs';
66
67
  import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
68
+ // The source-size practice, through its PURE READ core only (D-18): this module asks whether the
69
+ // practice is minted, never how to mint it — the checker's writer half stays outside the read graph.
70
+ import {
71
+ INITIAL_ADOPTION_REASON,
72
+ SOURCE_SIZE_GATE_ID,
73
+ SOURCE_SIZE_TOOL_PATH,
74
+ escapeForLine,
75
+ isLineUnsafe,
76
+ loadSourceSizeConfig,
77
+ } from './source-size-core.mjs';
67
78
 
68
79
  const HERE = dirname(fileURLToPath(import.meta.url));
69
80
  const KIT_ROOT = resolve(HERE, '..');
@@ -95,9 +106,11 @@ export const TRUST_CHAIN_DISCLOSURE =
95
106
  const USAGE = `usage: gates-init [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
96
107
 
97
108
  The consented FILL preview for the project's own docs/ai/gates.json (D9). Default is --dry-run:
98
- prints the derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the
99
- consented entries (--only <id> selects a subset; append-only — existing entries are never
100
- modified or removed, an id collision is refused). The offer is CLOSED-WORLD: only a
109
+ prints the derived { id, title, cmd } entries and writes NOTHING. --apply WRITES exactly the
110
+ consented entries (--only <id> selects a subset; add-only — existing entries are never modified,
111
+ removed or reordered, an id collision is refused). A consented entry lands at the END, except that
112
+ a non-checker entry goes BEFORE a declaration's trailing canonical coverage checker, which must
113
+ stay last. The offer is CLOSED-WORLD: only a
101
114
  terminating-class script name (test / lint / type-check / build — never release/publish/deploy,
102
115
  never watch/serve, never a write-mode variant) whose BODY is a member of the literal runner
103
116
  allowlist is offered, as the hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for
@@ -410,6 +423,77 @@ export const flowCheckCandidate = (cwd, deps = {}) => {
410
423
  }
411
424
  };
412
425
 
426
+ // The conditional SOURCE-SIZE candidate — keyed on the practice's own declaration rather than on a
427
+ // recipe: the gate is offered ONLY over a MINTED docs/ai/source-size.json, because on any other
428
+ // state the checker REFUSES by design (an absent config has no declared scope; an authored or
429
+ // half-written one records nothing yet), and a declared gate that refuses is a matrix this preview
430
+ // would have reddened on the user's behalf. The two states are told apart out loud: a project that
431
+ // never declared the practice hears nothing (there is no offer to make), while one that authored it
432
+ // and stopped short hears WHY the gate is not offered yet — the mint is the step it is missing.
433
+ // A path is unrenderable when EITHER guard fires, and both are asked ONCE, before anything about
434
+ // this candidate is printed. They are the same question wearing two coats: double-quoting cannot
435
+ // survive a shell-active byte, and a line-unsafe byte cannot be printed at all — U+2028 quotes
436
+ // perfectly and still breaks the line, so a surface screening only shell metacharacters lets it walk
437
+ // into a declared gate cmd while reading as fully guarded.
438
+ const unrenderableToolPath = (text) => isLineUnsafe(text) || DQ_UNSAFE_PATH_PATTERN.test(text);
439
+
440
+ export const sourceSizeCandidate = (cwd, deps = {}) => {
441
+ const toolPath = deps.sourceSizeTool ?? SOURCE_SIZE_TOOL_PATH;
442
+ try {
443
+ const { state } = loadSourceSizeConfig(resolve(cwd), deps);
444
+ if (state === 'absent') return { candidate: null, note: null };
445
+ // Asked BEFORE the state branches: every branch below prints this path, so a check that only one
446
+ // of them performs is a guard the other silently lacks.
447
+ if (unrenderableToolPath(toolPath)) {
448
+ // The withheld path is NAMED, so it crosses the line-safety boundary before it is rendered —
449
+ // escaped rather than dropped, because two different paths must never print as the same one.
450
+ // The advice still follows the STATE: over an unminted record a hand-declared gate is red on
451
+ // every run, so pointing there would hand the reader their next failure as the way out.
452
+ const lane = state === 'minted'
453
+ ? 'declare the gate by hand'
454
+ : `mint the record first — run source-size-check.mjs --adopt --reason "${INITIAL_ADOPTION_REASON}" with the working directory set to this project, then declare the gate by hand`;
455
+ return {
456
+ candidate: null,
457
+ note:
458
+ `the source-size candidate was withheld: the resolved kit path cannot be rendered into a ` +
459
+ `command — it carries bytes that do not survive double-quoting, or a byte that cannot appear ` +
460
+ `in a printed line at all (${escapeForLine(toolPath)}) — ${lane}`,
461
+ };
462
+ }
463
+ if (state !== 'minted') {
464
+ // The named recovery is the reader's next keystroke, so it is a command they can actually run:
465
+ // the resolved absolute path and an explicit --cwd, carrying the reason a FIRST mint requires
466
+ // (recording a value for the first time is a raise, and the bare verb would refuse). The
467
+ // PROJECT path is screened by the same predicate — a rendered command naming a path the shell
468
+ // would read differently could run somewhere other than the project it names.
469
+ const project = resolve(cwd);
470
+ const recovery = unrenderableToolPath(project)
471
+ ? `run source-size-check.mjs --adopt --reason "${INITIAL_ADOPTION_REASON}" yourself, with the working directory set to this project (no command is printed: this project's path cannot be rendered into one)`
472
+ : `run node "${toolPath}" --adopt --reason "${INITIAL_ADOPTION_REASON}" --cwd "${project}"`;
473
+ return {
474
+ candidate: null,
475
+ note:
476
+ `the source-size candidate was withheld: the declared practice is not yet MINTED (${state}) — ` +
477
+ `the checker refuses until it records this tree, so declaring the gate now would red the matrix; ` +
478
+ `${recovery}, then re-run this preview`,
479
+ };
480
+ }
481
+ return {
482
+ candidate: {
483
+ id: SOURCE_SIZE_GATE_ID,
484
+ title: 'Source files within the declared size caps (the recorded ratchet)',
485
+ cmd: `node "${toolPath}" --check`,
486
+ },
487
+ note: null,
488
+ };
489
+ } catch (err) {
490
+ return {
491
+ candidate: null,
492
+ note: `the source-size practice config is unreadable (${err.message}) — the source-size candidate was not evaluated`,
493
+ };
494
+ }
495
+ };
496
+
413
497
  // Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
414
498
  // any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
415
499
  // "nothing to offer" success.
@@ -424,15 +508,16 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
424
508
  }
425
509
  };
426
510
 
427
- // The full offer: script entries + the conditional review-state / flow-check / coverage-check
428
- // candidates (coverage-check LAST — the `run-gates --final` declaration-shape rule requires the
429
- // checker as the last declared gate, so a whole-offer apply is final-ready by construction). The
430
- // pair keys on plan-execution.review reviewed/council OR a flow block (the P21 trio); flow-check
431
- // itself appears only under a flow block.
511
+ // The full offer: script entries + the conditional review-state / flow-check / source-size /
512
+ // coverage-check candidates (coverage-check LAST — the `run-gates --final` declaration-shape rule
513
+ // requires the checker as the last declared gate, so a whole-offer apply is final-ready by
514
+ // construction). The pair keys on plan-execution.review reviewed/council OR a flow block (the P21
515
+ // trio); flow-check itself appears only under a flow block; source-size only over a minted practice.
432
516
  export const buildOffer = (cwd, deps = {}) => {
433
517
  const scripts = deriveScripts(cwd, deps);
434
518
  const rs = reviewStateCandidate(cwd, deps);
435
519
  const fc = flowCheckCandidate(cwd, deps);
520
+ const ss = sourceSizeCandidate(cwd, deps);
436
521
  const cc = coverageCheckCandidate(cwd, deps);
437
522
  // Decision 3 — the checker is never offered DEAD. The producer may come from this offer or from
438
523
  // a declaration the user already wrote by hand, so the rule reads the merged picture; a
@@ -462,7 +547,7 @@ export const buildOffer = (cwd, deps = {}) => {
462
547
  // readable, no gate → the claim and the advice both hold;
463
548
  // readable, has gate → a green matrix proves plenty and the user already declared their own.
464
549
  const declarationState =
465
- existing.unreadable !== null ? 'unreadable' : existing.gates.some((gate) => !isReviewDependentGate(gate, cwd)) ? 'has-gate' : 'no-gate';
550
+ existing.unreadable !== null ? 'unreadable' : existing.gates.some((gate) => !isKitOwnedCheckerGate(gate, cwd)) ? 'has-gate' : 'no-gate';
466
551
  const noVerificationNote =
467
552
  scripts.entries.length > 0
468
553
  ? null
@@ -471,10 +556,10 @@ export const buildOffer = (cwd, deps = {}) => {
471
556
  ? `, and none is declared either, so a green matrix would prove only that the kit's own checkers ran; declare your own in ${GATES_REL}`
472
557
  : ''
473
558
  }`;
474
- const candidates = [rs.candidate, fc.candidate, withholdCoverage ? null : cc.candidate].filter(Boolean);
559
+ const candidates = [rs.candidate, fc.candidate, ss.candidate, withholdCoverage ? null : cc.candidate].filter(Boolean);
475
560
  return {
476
561
  entries: [...scripts.entries, ...candidates],
477
- notes: [...scripts.notes, noVerificationNote, unreadableNote, rs.note, fc.note, coverageNote].filter(Boolean),
562
+ notes: [...scripts.notes, noVerificationNote, unreadableNote, rs.note, fc.note, ss.note, coverageNote].filter(Boolean),
478
563
  };
479
564
  };
480
565
 
@@ -510,7 +595,7 @@ export const formatPreview = (offer, applyInvocation = null, { explicitOnly = fa
510
595
  return lines.join('\n');
511
596
  };
512
597
 
513
- // ── the existing declaration (append-only source) ──────────────────────────────────────
598
+ // ── the existing declaration (the base every placement is computed against) ────────────
514
599
  const loadExistingDeclaration = (cwd, deps = {}) => {
515
600
  const read = deps.readFile ?? readFileSync;
516
601
  const lstat = deps.lstat ?? lstatSync;
@@ -572,7 +657,30 @@ const readStampValue = (cwd, deps = {}) => {
572
657
  }
573
658
  };
574
659
 
575
- // ── apply (append exactly the consented entries) ───────────────────────────────────────
660
+ // ── the PLACEMENT rule (D-8) ───────────────────────────────────────────────────────────
661
+ // WHERE a consented entry lands. A blind append made the fill unusable on exactly the declarations
662
+ // it should serve best: the canonical coverage checker must be the LAST gate, so appending any
663
+ // other entry after it produced a declaration the written-declaration validator correctly reds —
664
+ // and the only way to consent to a new gate on a final-capable declaration was to hand-edit.
665
+ //
666
+ // The rule is one sentence: a non-checker entry goes BEFORE a trailing canonical checker, everything
667
+ // else goes at the end. It is ADD-ONLY still — existing entries keep their relative order, none is
668
+ // modified or removed; only the insertion point moves. A consented CHECKER stays after the trailing
669
+ // one on purpose: two canonical checkers is a duplicate the validator must refuse by name, and
670
+ // hiding that shape behind a clever placement would refuse it for the wrong reason.
671
+ export const placeEntries = (existingGates, selected, projectDir) => {
672
+ const last = existingGates[existingGates.length - 1];
673
+ const isChecker = (gate) => canonicalCheckerGates([gate], projectDir).length === 1;
674
+ if (last === undefined || !isChecker(last)) return [...existingGates, ...selected];
675
+ return [
676
+ ...existingGates.slice(0, -1),
677
+ ...selected.filter((entry) => !isChecker(entry)),
678
+ last,
679
+ ...selected.filter(isChecker),
680
+ ];
681
+ };
682
+
683
+ // ── apply (write exactly the consented entries, each at its placement) ─────────────────
576
684
  export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
577
685
  assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
578
686
  const stampValue = readStampValue(cwd, deps);
@@ -593,14 +701,14 @@ export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
593
701
  const collisions = selected.filter((e) => existingIds.has(e.id)).map((e) => e.id);
594
702
  if (collisions.length) {
595
703
  throw stop(
596
- `id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (append-only: the ` +
704
+ `id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (add-only: the ` +
597
705
  `fill never modifies or removes an existing entry; pick the others with --only, or edit by hand)`,
598
706
  );
599
707
  }
600
708
 
601
709
  const merged = {
602
710
  _README: existing.outcome === 'loaded' && existing.readme !== undefined ? existing.readme : templateReadme(deps),
603
- gates: [...existingGates, ...selected],
711
+ gates: placeEntries(existingGates, selected, cwd),
604
712
  };
605
713
  validateDeclaration(merged); // every written declaration passes the runner's validator, always
606
714
  // Decision 4 — the coverage invariant is enforced on the declaration that GETS WRITTEN, not on
@@ -610,7 +718,11 @@ export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
610
718
  if (defects.length) throw stop(`${defects[0].message} — nothing was written`);
611
719
  const body = `${JSON.stringify(merged, null, 2)}\n`;
612
720
  const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
613
- return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id), notes: offer.notes };
721
+ const placed = selected.map((e) => e.id);
722
+ // `appended` is the DEPRECATED alias of `placed`, carrying the same array: this result is a public
723
+ // tools/ payload, and dropping a field an external consumer reads would make a placement rule a
724
+ // BREAKING change. The name is the only thing that was ever wrong — the value never was.
725
+ return { outcome: 'written', writtenPath, placed, appended: placed, notes: offer.notes };
614
726
  };
615
727
 
616
728
  // ── CLI ────────────────────────────────────────────────────────────────────────────────
@@ -670,7 +782,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
670
782
  for (const note of result.notes) log(` note: ${note}`); // the user learns WHY (same note as the preview)
671
783
  return EXIT_OK;
672
784
  }
673
- log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
785
+ log(`[agent-workflow-kit] declared ${result.placed.length} consented gate(s) in ${GATES_REL}: ${result.placed.join(', ')}`);
674
786
  for (const note of result.notes) log(` note: ${note}`); // a mixed offer never silently omits what was screened
675
787
  log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
676
788
  return EXIT_OK;
@@ -5,7 +5,8 @@
5
5
  // steps LIVE from the installed agent-workflow-engine (references/procedures.md — AD-016 live read, no
6
6
  // bundled mirror), reads the per-project, hand-edited config (docs/ai/orchestration.json), runs the
7
7
  // read-only backend detector, and prints the activity's steps VERBATIM + the resolved effective recipe
8
- // per slot (default = Reviewed-when-a-backend-is-ready, Council on request, slot-aware incl. Delegated).
8
+ // per slot (default = Reviewed-when-a-backend-is-ready, Council on request, slot-aware incl. Delegated),
9
+ // plus the project's DECLARED source-size practice when it declares one (D-17 U1).
9
10
  //
10
11
  // Invariants (mirror recipes.mjs): pure-where-possible, READ-ONLY (never writes, never commits, never
11
12
  // runs a subscription CLI). The deterministic resolution lives in the kit (resolveActivityRecipe), not
@@ -44,6 +45,10 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from
44
45
  // acyclic — pinned by test/read-graph-purity.test.mjs (FLOW-READ-GRAPH-PURITY).
45
46
  import { resolveFlowStorePath, readFlowStore } from './flow-store-read.mjs';
46
47
  import { CHAIN_KIND } from './flow-record.mjs';
48
+ // The declared source-size practice (D-17 U1), read through the practice's PURE READ core — never
49
+ // source-size-check.mjs, which owns the writer half: this advisor is a read root of
50
+ // test/read-graph-purity.test.mjs, and the core exists so a surface can ask without reaching a writer.
51
+ import { SOURCE_SIZE_CONFIG_REL, SOURCE_SIZE_WHY, loadSourceSizeConfig, practiceFacts } from './source-size-core.mjs';
47
52
  export { CONFIG_REL };
48
53
 
49
54
  // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
@@ -361,6 +366,49 @@ const flowHalvesAdvice = (flow, probe) => {
361
366
  ];
362
367
  };
363
368
 
369
+ // ── the declared source-size practice (D-17 U1) ────────────────────────────────────
370
+ // A practice the agent meets only when a gate refuses is a practice learned too late: the caps, their
371
+ // reason and the plan-time rung ride EVERY named-activity render, so the layout is cut to them while
372
+ // the plan is being written. Composed from the project's live declaration, never from constants here.
373
+ // Each config state speaks as itself: ABSENT renders NOTHING (a project that declares no practice must
374
+ // not be handed invented limits); AUTHORED and INCOMPLETE render the declared caps plus the honest
375
+ // "nothing is recorded yet" line — both are pre-mint states, and treating INCOMPLETE as MINTED would
376
+ // report a half record as the whole tree's debt; MINTED renders the recorded counts too.
377
+ // A config that cannot be read renders ONE loud line carrying the reader's own message and the render
378
+ // still completes: the exit code for a broken source-size config belongs to the practice's own
379
+ // checker (exit 2 there, and its declared gate reds the matrix on it), while THIS tool's exit
380
+ // contract is about its own config and the engine.
381
+
382
+ export const DECLARED_PRACTICE_HEADER = `Declared source-size practice (${SOURCE_SIZE_CONFIG_REL}) — known BEFORE the code is written:`;
383
+
384
+ const declaredPracticeAdvice = (cwd, readFile, lstat) => {
385
+ let declaration;
386
+ try {
387
+ declaration = loadSourceSizeConfig(cwd, { readFile, lstat });
388
+ } catch (err) {
389
+ return [`Declared source-size practice: UNREADABLE — ${(err && err.message) || err} — fix ${SOURCE_SIZE_CONFIG_REL} by hand; a declared practice is never guessed around.`];
390
+ }
391
+ if (declaration.state === 'absent') return [];
392
+ const facts = practiceFacts(declaration.config);
393
+ // The two pre-mint states share a LANE (the ratchet holds nothing, the mint step is next) but not a
394
+ // FACT: an incomplete file carries half the machine record, so "no size is recorded" would be a
395
+ // plain untruth about it — it names the missing half instead. Neither ever prints minted counts.
396
+ // The mint step is named, not rendered: on a project path that does not survive double-quoting the
397
+ // checker deliberately withholds a paste-ready command, and this advisor never re-decides that.
398
+ const unmintedRecord = declaration.state === 'incomplete'
399
+ ? ` recorded: PARTIAL — the machine record is half-written (missing ${declaration.missingMachineKeys.map((key) => `"${key}"`).join(', ')}), so the ratchet holds nothing yet; run \`source-size-check.mjs --check\` for the mint step.`
400
+ : ' recorded: NOTHING YET — the caps are declared but no size is recorded, so the ratchet holds nothing; run `source-size-check.mjs --check` for the mint step.';
401
+ return [
402
+ DECLARED_PRACTICE_HEADER,
403
+ ` caps: ${facts.maxLines} lines · ${facts.maxLineBytes} bytes per line, over ${facts.roots} declared root(s).`,
404
+ declaration.state === 'minted'
405
+ ? ` recorded: ${facts.recordedFiles} file(s) carry a recorded size (debt, not permission) · aggregate ${facts.aggregateLines} line(s), EXACT — growth takes a reasoned bump, never free headroom.`
406
+ : unmintedRecord,
407
+ ` why: ${SOURCE_SIZE_WHY}`,
408
+ ' at plan time: every Step that CREATES a file names the file and its single responsibility, and the planned layout fits these caps — the gate is the backstop, never the teacher.',
409
+ ];
410
+ };
411
+
364
412
  // The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
365
413
  // the closed flag set, the grounding note, the round-2/continue delta, and the guarded passthrough
366
414
  // tiers — every descriptor printed VERBATIM from the registry mirror of the bridge manifest
@@ -397,7 +445,7 @@ const contractLines = ({ cmd, contract, settings }) => {
397
445
  return lines;
398
446
  };
399
447
 
400
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves }) => {
448
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice }) => {
401
449
  const lines = [
402
450
  section,
403
451
  '',
@@ -417,6 +465,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
417
465
  const advice = reviewLoopAdvice(slots, activity);
418
466
  if (advice.length) lines.push('', ...advice);
419
467
  lines.push('', ...costLanesAdvice());
468
+ if (declaredPractice.length) lines.push('', ...declaredPractice);
420
469
  if (warnings.length) {
421
470
  lines.push('', 'warnings:');
422
471
  for (const w of warnings) lines.push(` ⚠ ${w}`);
@@ -424,7 +473,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
424
473
  return lines.join('\n');
425
474
  };
426
475
 
427
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves }) => ({
476
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }) => ({
428
477
  activity,
429
478
  section,
430
479
  slots: Object.fromEntries(
@@ -439,6 +488,9 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
439
488
  costLanes: costLanesAdvice(),
440
489
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
441
490
  autonomy: autonomyAdvice(activity, autonomy),
491
+ // ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
492
+ // a scripted reader and a human can never be told different things about the declared practice.
493
+ declaredPractice,
442
494
  // CONDITIONAL (flow P8): the armed-halves block rides ONLY a flow-carrying config — the unarmed
443
495
  // JSON key set stays byte-exact (unarmed neutrality outranks the additive-key precedent).
444
496
  ...(flowHalves == null ? {} : { flowHalves }),
@@ -461,6 +513,12 @@ ${CONFIG_REL} + the read-only backend detector, and prints both. A per-run
461
513
  --override <slot>=<recipe> (repeatable) overrides the configured/default recipe for that slot.
462
514
  Read-only: never writes, never commits, never runs a subscription CLI.
463
515
 
516
+ Also prints the project's DECLARED source-size practice (${SOURCE_SIZE_CONFIG_REL}) when it declares
517
+ one — the caps, what is recorded, why the practice exists, and the plan-time rung — as the
518
+ declaredPractice block (--json: the same lines under "declaredPractice"). A project with no such
519
+ file renders nothing; a file that cannot be read renders ONE loud UNREADABLE line and still exits 0
520
+ (the practice's own checker owns the exit code for its config).
521
+
464
522
  Exit codes: 0 success (an unsatisfiable override degrades loudly, still 0);
465
523
  2 usage (unknown activity / bad --override); 1 config or engine error
466
524
  (incl. a malformed ${AUTONOMY_REL} — the advisory still renders, the exit flips).`;
@@ -515,9 +573,10 @@ export const main = (argv, ctx = {}) => {
515
573
  // unarmed project keeps byte-identical output (human AND JSON) and never pays the store probe.
516
574
  const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
517
575
  const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
576
+ const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
518
577
  const stdout = json
519
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves }), null, 2)
520
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves });
578
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }), null, 2)
579
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice });
521
580
  if (autonomy?.error) {
522
581
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
523
582
  }
@@ -55,7 +55,13 @@ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-re
55
55
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
57
  import { isFinalCapableDeclaration } from './run-gates.mjs';
58
- import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isReviewDependentGate, GATES_REL } from './gates-declaration.mjs';
58
+ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL } from './gates-declaration.mjs';
59
+ import { matchesCoverageProducer } from './coverage-producer.mjs';
60
+ // Read-only surfaces of the fill (buildOffer) and of the source-size practice (its pure core). The
61
+ // fill's own WRITER is never called from here — the advisor renders its consent-gated command, it
62
+ // does not run it.
63
+ import { buildOffer } from './gates-init.mjs';
64
+ import { INITIAL_ADOPTION_REASON, loadSourceSizeConfig, matchesSourceSizeGate } from './source-size-core.mjs';
59
65
  // The declared-path resolution + segment containment this item's convergence lane shares with the
60
66
  // autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
61
67
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -106,6 +112,10 @@ export const SEVERITIES = Object.freeze({
106
112
  'gates-declaration': SEVERITY_OPTIONAL,
107
113
  'gates-inert': SEVERITY_ATTENTION,
108
114
  'gates-inert.no-verification': SEVERITY_ATTENTION,
115
+ 'source-size': SEVERITY_OPTIONAL,
116
+ // The declared-but-unminted arm reports a CONFIGURED declaration that is broken — a gate certain
117
+ // to refuse on every run — while the base arm stays an offer to enable something unconfigured.
118
+ 'source-size.unminted': SEVERITY_ATTENTION,
109
119
  'gate-hook': SEVERITY_OPTIONAL,
110
120
  'commit-guard': SEVERITY_OPTIONAL,
111
121
  'read-lane': SEVERITY_OPTIONAL,
@@ -166,6 +176,8 @@ export const WHATS = Object.freeze({
166
176
  '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
177
  'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
168
178
  'gates-inert.no-verification': "all {n} declared gate(s) are the kit's own checkers — the matrix runs no project-verification command",
179
+ 'source-size': 'no source-size gate — module size drifts unmeasured, and an over-cap file is invisible instead of recorded debt',
180
+ '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',
169
181
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
170
182
  '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
183
  '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',
@@ -223,6 +235,7 @@ export const BENEFITS = Object.freeze({
223
235
  'review-recipe': 'recipe coverage — the review AND execution recipes you configured actually run instead of silently degrading',
224
236
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
225
237
  'gates-inert': 'honest gates — the declared matrix verifies your project instead of reporting green over a check that ran nothing',
238
+ 'source-size': 'maintainability — a module you can hold whole stays reviewable, and size drift becomes recorded, reasoned debt',
226
239
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
227
240
  'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
228
241
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
@@ -263,6 +276,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
263
276
  // converges the moment any gate exists, so it can never observe this state. The advisor key names
264
277
  // the state it reports (an inert declaration), the capability names what the user gains.
265
278
  { id: 'gates-verification', mode: 'gates', advisorKey: 'gates-inert' },
279
+ // The source-size practice is accepted AS a gate — it needs no mode of its own, and its capability
280
+ // is therefore declared where its declaration lives. It is the DISCOVERY lane for the practice on
281
+ // every deployment, new and existing alike: nothing else tells a project the practice exists.
282
+ { id: 'source-size', mode: 'gates', advisorKey: 'source-size' },
266
283
  { id: 'gate-hook', mode: 'hook', advisorKey: 'gate-hook' },
267
284
  { id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
268
285
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
@@ -432,12 +449,23 @@ const probeGates = ({ root, deps, add, skip }) => {
432
449
  //
433
450
  // Cause A (a canonical coverage checker with no producer anywhere in the declaration) is checked
434
451
  // 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.
452
+ // producer gate is not a kit checker. Its apply follows what the FILL can actually do, which the
453
+ // D-8 placement rule changed: when the checker is the declaration's LAST gate and the project's own
454
+ // scripts yield an offerable producer, the fill now PLACES that producer before the checker, so the
455
+ // remedy is the ordinary consent-gated preview. HAND-APPLY remains exactly where no offerable
456
+ // producer exists, or where the checker is not last — there the edit really is the maintainer's,
457
+ // because the fill never reorders entries it did not write.
438
458
  //
439
459
  // An ABSENT or EMPTY declaration belongs to the gates-declaration item; a malformed one throws out
440
460
  // of the validated reader and becomes this probe's stated skip, never a guess.
461
+ // ONE rule for every fill preview this item renders: name only the entries the fill would ACCEPT.
462
+ // An id the declaration already carries is refused as a collision, so an unrestricted preview hands
463
+ // the reader a lane that cannot converge — and both arms of this item render over a declaration that
464
+ // already carries at least one gate the offer also proposes, so both need the rule. With nothing
465
+ // selectable the bare preview is still the honest render: its own notes say why.
466
+ const fillPreviewFor = (root, ids) =>
467
+ `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}${ids.map((id) => ` --only ${id}`).join('')}`;
468
+
441
469
  export const probeGatesInert = ({ root, deps, add, skip }) => {
442
470
  try {
443
471
  const declaration = loadDeclaration(root, deps);
@@ -451,18 +479,54 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
451
479
  // only --final refuses that shape — a plain run reports every gate PASS.
452
480
  if (coverageProducerPrecedes(gates, gates.indexOf(checkers[0]))) return; // the pair is live
453
481
  const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert']));
482
+ // The fill can only help when it would land the producer in the right place: the checker must
483
+ // 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));
488
+ 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`;
454
501
  add(
455
502
  'gates-inert',
456
503
  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`,
504
+ checkerIsLast && producer
505
+ ? fillPreviewFor(root, [producer.id])
506
+ : `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
507
  );
459
508
  return;
460
509
  }
461
- if (gates.every((gate) => isReviewDependentGate(gate, root))) {
510
+ // The source-size checker is one of the kit's OWN checkers, and it is deliberately NOT
511
+ // review-dependent (it needs no receipt), so the review-dependent predicate alone cannot see it.
512
+ // Left out, a matrix of nothing but that gate reads as carrying project verification — and a
513
+ // 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
+ if (gates.every((gate) => isKitOwnedCheckerGate(gate, root))) {
519
+ const declaredIds = new Set(gates.map((gate) => gate.id));
520
+ // Only PROJECT-verification entries: a non-colliding entry is not enough, it has to be one
521
+ // that RESOLVES the item, and declaring one more kit checker converges nothing — the item
522
+ // would simply fire again on the next run.
523
+ const selectable = buildOffer(root, deps).entries
524
+ .filter((entry) => !declaredIds.has(entry.id) && !isKitOwnedCheckerGate(entry, root))
525
+ .map((entry) => entry.id);
462
526
  add(
463
527
  'gates-inert',
464
528
  fillTemplate(WHATS['gates-inert.no-verification'], { n: gates.length }),
465
- `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`,
529
+ fillPreviewFor(root, selectable),
466
530
  'gates-inert.no-verification',
467
531
  );
468
532
  }
@@ -471,6 +535,42 @@ export const probeGatesInert = ({ root, deps, add, skip }) => {
471
535
  }
472
536
  };
473
537
 
538
+ // The SOURCE-SIZE offer (baseline-practices Plan 1). This is the practice's ONE discovery lane: the
539
+ // checker ships with the kit and refuses until a project declares its own scope, so without this
540
+ // item a deployment would never learn the practice exists — the same OPT-IN-SHIPS-INVISIBLE failure
541
+ // the capability registry was built for. New and existing deployments meet it identically, because
542
+ // the advisor section is mandatory at every upgrade.
543
+ //
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.
550
+ const probeSourceSize = ({ root, deps, add, skip }) => {
551
+ try {
552
+ const declaration = loadDeclaration(root, deps);
553
+ 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))) {
556
+ // A DECLARED gate is not the same fact as a working one: the checker refuses on every config
557
+ // state but MINTED, so a gate declared over an absent or half-written record reds the matrix
558
+ // 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.
560
+ 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');
563
+ return;
564
+ }
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);
569
+ } catch (err) {
570
+ skip('source-size', err);
571
+ }
572
+ };
573
+
474
574
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
475
575
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
476
576
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
@@ -1054,6 +1154,7 @@ const PROBES = Object.freeze([
1054
1154
  probeReviewRecipe,
1055
1155
  probeGates,
1056
1156
  probeGatesInert,
1157
+ probeSourceSize,
1057
1158
  probeCommitGuard,
1058
1159
  probeReadLane,
1059
1160
  probeStateBlockHook,