@sabaiway/agent-workflow-kit 5.4.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +51 -4
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
  9. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
  10. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  11. package/bridges/codex-cli-bridge/capability.json +15 -10
  12. package/capability.json +1 -1
  13. package/package.json +1 -1
  14. package/references/modes/dispatch.md +29 -0
  15. package/references/modes/gates.md +6 -3
  16. package/references/modes/procedures.md +2 -0
  17. package/references/modes/receipt-deadline.md +3 -3
  18. package/references/modes/recommendations.md +1 -1
  19. package/references/modes/velocity.md +1 -0
  20. package/tools/commands.mjs +7 -0
  21. package/tools/core-evidence.mjs +37 -3
  22. package/tools/detect-backends.mjs +5 -4
  23. package/tools/dispatch-record.mjs +10 -3
  24. package/tools/dispatch-store.mjs +392 -0
  25. package/tools/dispatch.mjs +1779 -0
  26. package/tools/doc-parity.mjs +10 -2
  27. package/tools/exec-producer.mjs +483 -0
  28. package/tools/exec-receipt.mjs +263 -0
  29. package/tools/flow-check-cores.mjs +253 -0
  30. package/tools/flow-check-git-lane.mjs +56 -0
  31. package/tools/flow-check-rungs.mjs +330 -0
  32. package/tools/flow-check.mjs +23 -611
  33. package/tools/flow-store.mjs +111 -462
  34. package/tools/gates-declaration.mjs +13 -1
  35. package/tools/gates-init.mjs +134 -22
  36. package/tools/procedures.mjs +64 -5
  37. package/tools/receipt-deadline.mjs +25 -3
  38. package/tools/recommendations.mjs +108 -7
  39. package/tools/release-scan.mjs +33 -0
  40. package/tools/source-size-check.mjs +320 -0
  41. package/tools/source-size-config.mjs +244 -0
  42. package/tools/source-size-core.mjs +53 -0
  43. package/tools/source-size-gate-cmd.mjs +55 -0
  44. package/tools/source-size-judge.mjs +114 -0
  45. package/tools/source-size-refusal.mjs +70 -0
  46. package/tools/source-size-report.mjs +254 -0
  47. package/tools/source-size-scope.mjs +145 -0
  48. package/tools/store-append.mjs +444 -0
  49. package/tools/velocity-profile.mjs +24 -3
@@ -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
  }
@@ -5,7 +5,9 @@
5
5
  // ARRIVAL: a newline-terminated parseable receipt line from the dispatched backend starting
6
6
  // at/after the watermark offset, or — PREFERRED whenever a dispatch nonce is supplied and its
7
7
  // finding manifest exists — the nonce-matched manifest (the manifest is minted atomically BEFORE
8
- // the receipt append, so its presence is the stronger dispatch-identity signal).
8
+ // the receipt append, so its presence is the stronger dispatch-identity signal). "Receipt line" is
9
+ // decided POSITIVELY, by the minimal core below: a delegation-ledger line carries a `backend` too,
10
+ // and a review waiter waits for a REVIEW answer (D10).
9
11
  //
10
12
  // Watermark semantics (P6/P18, split by surface): the PERSISTED dispatch-ledger watermark stays
11
13
  // the plain byte-length integer; THIS RUNNER additionally binds the receipts-file PREFIX
@@ -32,10 +34,30 @@ export const DEADLINE_POLL_MS = 5000;
32
34
 
33
35
  // The one contract sentence, doc-parity-bound into references/modes/receipt-deadline.md — the
34
36
  // arrival-not-satisfaction split is the tool's identity and must not drift in the mode doc.
35
- export const RECEIPT_DEADLINE_CONTRACT = 'satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present) — never obligation satisfaction';
37
+ export const RECEIPT_DEADLINE_CONTRACT = 'satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable REVIEW receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present), never a delegation-ledger line that merely names the same backend — never obligation satisfaction';
36
38
 
37
39
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
38
40
 
41
+ // The receipts store and the delegation ledger are different files with different schemas, but both
42
+ // are JSONL beside the git dir and both carry a `backend` — so a ledger line reaching this store
43
+ // would satisfy a waiter that matched on the backend alone (D10). The rule is therefore POSITIVE,
44
+ // not a blacklist of foreign kinds: a blacklist goes stale the moment the other family grows a kind,
45
+ // and the two errors are not symmetric — an unrecognised line costs a TIMEOUT that names its
46
+ // watermark (loud), while a false ARRIVED answers a review dispatch with something that is not a
47
+ // review. This is the MINIMAL core every review receipt carries and no delegation record can: the
48
+ // kinds that carry `backend` (dispatch, return) have no `verdict`, and the kind that carries
49
+ // `verdict` (fold) has no `backend`. `fingerprint` must be PRESENT but may be null — an empty
50
+ // fingerprint is legal in some receipt modes, so requiring a value would refuse a real receipt.
51
+ const REVIEW_RECEIPT_SCHEMA = 1;
52
+
53
+ const isReviewReceiptLine = (parsed, backend) =>
54
+ parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
55
+ && parsed.schema === REVIEW_RECEIPT_SCHEMA
56
+ && parsed.backend === backend
57
+ && typeof parsed.artifact === 'string' && parsed.artifact.length > 0
58
+ && typeof parsed.verdict === 'string' && parsed.verdict.length > 0
59
+ && Object.hasOwn(parsed, 'fingerprint');
60
+
39
61
  // Every read rides the kit's ONE race-free reader (flow-store-read's no-follow/non-block
40
62
  // discipline): store identity is never resolved through a link, a FIFO can never block the
41
63
  // bounded wait, and an invalid-UTF-8 store refuses (a byte-unstable store cannot carry a prefix
@@ -97,7 +119,7 @@ export const pollArrival = ({ path, watermark, prefixHash, backend, nonce = null
97
119
  } catch {
98
120
  continue; // a malformed line never satisfies — and never masks a later valid one
99
121
  }
100
- if (parsed && typeof parsed === 'object' && parsed.backend === backend) {
122
+ if (isReviewReceiptLine(parsed, backend)) {
101
123
  return { state: 'satisfied', reason: `a receipt line from backend "${backend}" arrived past watermark offset ${watermark} (${path})` };
102
124
  }
103
125
  }