@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
@@ -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,
@@ -35,11 +35,44 @@ const ATTRIBUTION = [
35
35
  { re: /reviewed by (claude|codex|chatgpt|gpt|gemini|copilot|cursor|the (ai|model|agent))/i, label: 'AI review attribution' },
36
36
  { re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
37
37
  ];
38
+ // A line whose FIRST non-space token opens a comment (or a markdown heading) — the surface where a
39
+ // backend name beside a disposition is a credit rather than a data value.
40
+ //
41
+ // STATED RESIDUAL of the two anchored rules below, named rather than implied: they see only lines
42
+ // that OPEN with a comment marker, so a credit inside a string literal, in ordinary markdown prose
43
+ // outside a heading, or in a trailing inline comment is invisible to them. Closing that class needs
44
+ // a code/comment/string lexer this scanner deliberately does not have (the version-pin rung below
45
+ // records what hand-rolling one cost). The UNANCHORED pair narrows the gap for the one construction
46
+ // that has actually shipped past this gate; nothing here proves no attribution exists — it is a
47
+ // high-signal guard, not a proof.
48
+ const COMMENT_LINE = String.raw`^\s*(?://|/\*|\*|#)`;
49
+ const DISPOSITIONS = 'CONFIRM|REFUTE|REVISE|SHIP';
50
+ // Spelled per letter rather than flagged: the four rules below must be case-insensitive on the NAME
51
+ // and case-SENSITIVE on the disposition, and a regex flag cannot apply to half a pattern. All four
52
+ // share this constant, so an ALL-CAPS credit cannot slip past one of them.
53
+ const BACKEND_ANY_CASE = '[Aa][Gg][Yy]|[Cc][Oo][Dd][Ee][Xx]';
54
+
38
55
  const REVIEWER_IDENTITY = [
39
56
  // backend-then-round: a bridge name, a separator, then r<N> (with optional +/round suffixes).
40
57
  { re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
41
58
  // round-then-backend (reverse order): r<N>, a separator, then a bridge name — the release-review gap.
42
59
  { re: /\br\d+(?:\s+|-)(?:agy|codex)\b/i, label: 'reviewer-round identity' },
60
+ // backend beside a DISPOSITION, in a COMMENT: the form a fold note takes when it credits who
61
+ // decided instead of stating what was decided. Two narrowings make it usable. The line must be a
62
+ // comment, because a receipt FIXTURE legitimately pairs a backend field with a verdict value and
63
+ // that is data, not attribution. And the disposition half is case-SENSITIVE, because prose says
64
+ // "ship" and "revise" constantly while a recorded disposition is written in caps.
65
+ { re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${BACKEND_ANY_CASE})\\b[^\\n]{0,24}\\b(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
66
+ { re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${DISPOSITIONS})\\b[^\\n]{0,24}\\b(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
67
+ // The exact PARENTHESISED credit — an open paren, a bridge name, a comma, a disposition, and the
68
+ // reverse order — matched ANYWHERE on the line, so it also lands inside a string literal, a
69
+ // markdown sentence and a trailing inline comment, none of which the comment anchor above can
70
+ // see. It stays clear of data because a fixture QUOTES its values, and a quote sits exactly where
71
+ // this rule requires the bare disposition word. Both orders END on a word boundary, so a longer
72
+ // word sharing a disposition's prefix is not a credit — the boundary is what a commit gate needs,
73
+ // and requiring the CLOSING paren instead would drop the hyphenated form this rule exists for.
74
+ { re: new RegExp(`\\((?:${BACKEND_ANY_CASE}),\\s*(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
75
+ { re: new RegExp(`\\((?:${DISPOSITIONS}),\\s*(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
43
76
  ];
44
77
 
45
78
  const allowlistCovers = (matched, allowlist) =>
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ // source-size-check.mjs — the source-size practice's CLI and its writer half. The pure read core
3
+ // (config, scope, counting, the canonical gate-cmd matcher) lives in source-size-core.mjs, the
4
+ // verdict in source-size-judge.mjs and its wording in source-size-report.mjs; all three are imported
5
+ // here. NOTHING in the read graph imports THIS module, so the advisor surfaces can ask about the
6
+ // practice without ever reaching a writer (D-18).
7
+ //
8
+ // What --check judges: every in-scope file against the config's `defaults`, and every RECORDED size
9
+ // against the ratchet instead — a record may not grow, may not sit above what the tree measures, and
10
+ // may not outlive its file. Each declared root carries the same ratchet over its summed lines, so
11
+ // splitting one big file into six buys no headroom.
12
+ //
13
+ // What --write-baseline does: regenerates the machine keys from the tree, and ONLY the machine keys.
14
+ // The authored half is copied through with its VALUES and their ORDER preserved exactly; the file
15
+ // itself is canonically serialized, so its formatting becomes the writer's (that is what makes a
16
+ // regeneration of an unchanged tree reproduce the same bytes). A regeneration that RAISES any value takes
17
+ // --reason "<text>" — the checker cannot invent the human's reason — and that string lands verbatim
18
+ // in the entry it raises. A pure tighten needs none: shrinking is progress. The printed old→new
19
+ // delta is the durable record where docs/ai is git-hidden; it is what the commit message carries.
20
+ //
21
+ // What --adopt does: mints the record and declares the gate — the whole adoption as ONE consented
22
+ // line, because the alternative is a two-step ceremony whose halves can be left half-done. It
23
+ // composes the two writers it already has (this module's mint, the fill's consented apply restricted
24
+ // to this one id) and owns no write of its own.
25
+ //
26
+ // Exit codes: 0 green / 1 violation or refusal / 2 usage, config or enumeration error.
27
+ // Dependency-free, Node >= 22. No side effects on import.
28
+
29
+ import { fileURLToPath } from 'node:url';
30
+ import { realpathSync } from 'node:fs';
31
+ import { resolve } from 'node:path';
32
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
33
+ import {
34
+ AUTHORED_KEYS,
35
+ SOURCE_SIZE_CONFIG_REL,
36
+ SOURCE_SIZE_GATE_ID,
37
+ loadSourceSizeConfig,
38
+ matchesSourceSizeGate,
39
+ reasonDefect,
40
+ scopeFail,
41
+ } from './source-size-core.mjs';
42
+ import { changesFor, isRaise, judgeTree, ownEntry } from './source-size-judge.mjs';
43
+ import { GATES_REL, loadDeclaration } from './gates-declaration.mjs';
44
+ import { applyFill } from './gates-init.mjs';
45
+ import {
46
+ absentRefusalLines,
47
+ adoptAbsentRefusalLines,
48
+ checkReportLines,
49
+ gateAlreadyDeclaredLines,
50
+ gateDeclaredLines,
51
+ gateRefusedLines,
52
+ reasonRequiredLines,
53
+ recordNoLongerHoldsLines,
54
+ recordRecognizedLines,
55
+ unmintedRefusalLines,
56
+ writtenLines,
57
+ } from './source-size-report.mjs';
58
+
59
+ const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
60
+ const RECORD_NOUN = 'the source-size record';
61
+
62
+ export const runCheck = ({ cwd, deps = {} }) => {
63
+ const { state, config, missingMachineKeys } = loadSourceSizeConfig(cwd, deps);
64
+ if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
65
+ if (state !== 'minted') return { code: 1, lines: unmintedRefusalLines(cwd, { state, missing: missingMachineKeys }) };
66
+ const verdict = judgeTree(cwd, config, deps);
67
+ return { code: verdict.findings.length === 0 ? 0 : 1, lines: checkReportLines({ cwd, config, verdict }) };
68
+ };
69
+
70
+ // ── the writer ────────────────────────────────────────────────────────────────────────────────────
71
+
72
+ // planRecord computes what the regeneration WOULD write, before any reason is applied — so a raise
73
+ // can be refused without ever building an entry that carries no reason. It reads the JUDGE's
74
+ // projection, never its own: the checker's verdict and the file the writer produces are then two
75
+ // renderings of one computation, and neither can promise what the other would not do.
76
+ const planRecord = ({ config, verdict }) => {
77
+ const oldBaseline = config.baseline ?? {};
78
+ const oldAggregate = config.aggregate ?? {};
79
+ const files = verdict.scope.files.map((rel) => {
80
+ const next = verdict.projected.get(rel);
81
+ const recorded = ownEntry(oldBaseline, rel);
82
+ return { rel, next, recorded, changes: changesFor(rel, next, recorded) };
83
+ });
84
+ const roots = config.roots.map((root) => ({
85
+ root,
86
+ lines: verdict.rootLines.get(root) ?? 0,
87
+ recorded: ownEntry(oldAggregate, root),
88
+ }));
89
+ const deltas = [
90
+ ...files.flatMap(({ changes }) => changes),
91
+ // A record whose file left scope is REMOVED, never kept: that is what makes a split or a rename
92
+ // visible in the delta instead of silently surviving as headroom.
93
+ ...Object.entries(oldBaseline)
94
+ .filter(([rel]) => !verdict.measured.has(rel))
95
+ .flatMap(([rel, recorded]) => changesFor(rel, {}, recorded)),
96
+ ...roots
97
+ .filter(({ lines, recorded }) => (recorded ? recorded.lines : null) !== lines)
98
+ .map(({ root, lines, recorded }) => ({ target: root, dimension: 'aggregate lines', from: recorded ? recorded.lines : null, to: lines })),
99
+ ...Object.entries(oldAggregate)
100
+ .filter(([root]) => !config.roots.includes(root))
101
+ .map(([root, recorded]) => ({ target: root, dimension: 'aggregate lines', from: recorded.lines, to: null })),
102
+ ];
103
+ return { files, roots, deltas, raises: deltas.filter(isRaise) };
104
+ };
105
+
106
+ // The reason of an entry the regeneration did NOT raise is the one already recorded — a tighten
107
+ // rewrites a number, never the human sentence that justified it.
108
+ const materialize = ({ files, roots }, reason) => ({
109
+ baseline: Object.fromEntries(
110
+ files
111
+ .filter(({ next }) => Object.keys(next).length > 0)
112
+ .map(({ rel, next, recorded, changes }) => [rel, {
113
+ ...next,
114
+ reason: changes.some(isRaise) ? reason : recorded.reason,
115
+ }]),
116
+ ),
117
+ aggregate: Object.fromEntries(
118
+ roots.map(({ root, lines, recorded }) => [root, {
119
+ lines,
120
+ reason: !recorded || lines > recorded.lines ? reason : recorded.reason,
121
+ }]),
122
+ ),
123
+ });
124
+
125
+ // The file is CANONICALLY serialized: the authored VALUES and the ORDER the human wrote them in are
126
+ // preserved exactly, their formatting is not — this file is machine-maintained and the writer owns
127
+ // half of it, so one deterministic rendering is what makes "regenerate an unchanged tree and get the
128
+ // same bytes" true at all.
129
+ const serialize = (parsed, machineKeys) => {
130
+ const authored = Object.fromEntries(Object.keys(parsed).filter((key) => AUTHORED_KEYS.includes(key)).map((key) => [key, parsed[key]]));
131
+ return `${JSON.stringify({ ...authored, ...machineKeys }, null, 2)}\n`;
132
+ };
133
+
134
+ export const runWriteBaseline = ({ cwd, reason, deps = {} }) => {
135
+ // Checked BEFORE the config is read, so a project that was never deployed hears about the
136
+ // deployment rather than about a file it has no place to put.
137
+ assertDocsAiDeployment(cwd, deps, { stop: scopeFail, rel: SOURCE_SIZE_CONFIG_REL, noun: RECORD_NOUN });
138
+ const { state, config, parsed, text } = loadSourceSizeConfig(cwd, deps);
139
+ if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
140
+ const verdict = judgeTree(cwd, config, deps);
141
+ const plan = planRecord({ config, verdict });
142
+ if (plan.raises.length > 0 && reason === undefined) return { code: 1, lines: reasonRequiredLines(cwd, plan.deltas) };
143
+ const body = serialize(parsed, materialize(plan, reason));
144
+ // Whether anything was written is decided by the BYTES, not by the delta count: completing a
145
+ // hand-edited half record changes the file while raising nothing at all.
146
+ const changed = body !== text;
147
+ if (changed) writeDocsAiFileAtomic(cwd, SOURCE_SIZE_CONFIG_REL, body, deps, { stop: scopeFail, noun: RECORD_NOUN });
148
+ return { code: 0, lines: writtenLines({ cwd, deltas: plan.deltas, reason: plan.raises.length > 0 ? reason : undefined, changed }) };
149
+ };
150
+
151
+ // ── the adoption verb (D-16) ──────────────────────────────────────────────────────────────────────
152
+
153
+ // Declaring the gate is delegated to the FILL's own consented apply, restricted to this one id: the
154
+ // fill owns every rule about what a written declaration may look like (placement, id collisions, the
155
+ // coverage invariant, the atomic write), and a second writer here would be a second set of those
156
+ // rules — the one that drifts. `--adopt` is therefore a composition, never a re-implementation.
157
+ //
158
+ // The already-declared arm is checked FIRST and through the practice's own matcher: an earlier
159
+ // partial run leaves a minted record and a declared gate, and re-running must converge rather than
160
+ // collide. A gate that merely CARRIES the id without being this checker does not count — it reaches
161
+ // the fill and collides there, loudly, which is the honest answer to a squatter.
162
+ // The READ is inside the try with the write, deliberately: by the time this runs the record is
163
+ // already minted, so ANY failure here — a malformed declaration the reader throws on, just as much
164
+ // as a collision the fill refuses — must still report both halves. Letting the read escape would
165
+ // surface a bare error carrying neither the mint that succeeded nor the exit contract this tool
166
+ // documents.
167
+ const declareGate = (cwd, deps) => {
168
+ try {
169
+ applyFill({ cwd, onlyIds: [SOURCE_SIZE_GATE_ID] }, deps);
170
+ return { code: 0, lines: gateDeclaredLines(GATES_REL, SOURCE_SIZE_GATE_ID) };
171
+ } catch (err) {
172
+ return { code: 1, lines: gateRefusedLines(GATES_REL, err?.message ?? String(err)) };
173
+ }
174
+ };
175
+
176
+ // Is the canonical gate ALREADY there? Asked through the practice's own matcher, so a gate that
177
+ // merely carries the id — running something else entirely — never reads as adopted; it reaches the
178
+ // fill and collides there, loudly, which is the honest answer to a squatter.
179
+ //
180
+ // An unreadable declaration is NOT a verdict here. This read is a shortcut, and aborting on it would
181
+ // report an outcome before the record was settled; the fill re-reads the same file and refuses
182
+ // authoritatively AFTER, so the partial report names both halves truthfully.
183
+ const gateIsDeclared = (cwd, deps) => {
184
+ try {
185
+ const declaration = loadDeclaration(cwd, deps);
186
+ return (declaration.outcome === 'loaded' ? declaration.gates : []).some((gate) => matchesSourceSizeGate(gate.cmd, cwd));
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+
192
+ // A MINTED record is RECOGNIZED, never regenerated. Adoption carries a PINNED reason — the advisor
193
+ // renders it as a fixed string, and its item keeps firing while the gate is undeclared — so a
194
+ // re-run after a partial adoption would let that one sentence raise whatever the tree grew in the
195
+ // meantime. That is exactly the laundering the reason requirement exists to prevent, so the verb
196
+ // asks the checker instead: a record that no longer holds is a ratchet question with its own
197
+ // reasoned lane, and it must be answered BEFORE a gate is declared over it — declaring one that is
198
+ // certain to red the matrix is what the offer rules refuse everywhere else.
199
+ const recognizeRecord = ({ cwd, deps }) => {
200
+ const verdict = runCheck({ cwd, deps });
201
+ if (verdict.code !== 0) return { code: verdict.code, lines: [...verdict.lines, ...recordNoLongerHoldsLines(GATES_REL)] };
202
+ return { code: 0, lines: recordRecognizedLines(cwd) };
203
+ };
204
+
205
+ // --adopt = settle the record, then declare the gate. The order is not a preference: the fill offers
206
+ // the gate ONLY over a minted config (declaring it earlier would declare a gate that refuses), so
207
+ // the record is what makes the declaration offerable at all.
208
+ export const runAdopt = ({ cwd, reason, deps = {} }) => {
209
+ const { state } = loadSourceSizeConfig(cwd, deps);
210
+ if (state === 'absent') return { code: 1, lines: adoptAbsentRefusalLines(cwd) };
211
+ // ADOPTED is asked FIRST, and it is a question about the GATE alone. Idempotence cannot be made
212
+ // conditional on the record still holding: a declared gate reports its own staleness on every run,
213
+ // with the reasoned lane, and stopping here would tell a reader the gate was not declared while it
214
+ // plainly is — which is exactly what re-running the advisor's one-liner on a drifted tree does.
215
+ // Once the gate is there and the record is minted, nothing is left to adopt.
216
+ const declared = gateIsDeclared(cwd, deps);
217
+ if (declared && state === 'minted') return { code: 0, lines: gateAlreadyDeclaredLines(GATES_REL) };
218
+ // Either half carries its OWN self-servable refusals (a raise with no reason, a project with no
219
+ // docs/ai, a record the tree outgrew). They are returned unchanged: re-wording them here would be
220
+ // the second practice this module exists to avoid, and each already names the step that clears it.
221
+ const record = state === 'minted' ? recognizeRecord({ cwd, deps }) : runWriteBaseline({ cwd, reason, deps });
222
+ if (record.code !== 0) return record;
223
+ if (declared) return { code: 0, lines: [...record.lines, ...gateAlreadyDeclaredLines(GATES_REL)] };
224
+ const gate = declareGate(cwd, deps);
225
+ return { code: gate.code, lines: [...record.lines, ...gate.lines] };
226
+ };
227
+
228
+ // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
229
+
230
+ const MODES = Object.freeze(['--check', '--write-baseline', '--adopt']);
231
+
232
+ const HELP = `source-size-check — the declared source-size practice (agent-workflow family).
233
+
234
+ Usage:
235
+ node source-size-check.mjs --check [--cwd <project-root>]
236
+ node source-size-check.mjs --write-baseline [--reason "<text>"] [--cwd <project-root>]
237
+ node source-size-check.mjs --adopt [--reason "<text>"] [--cwd <project-root>]
238
+
239
+ Judges every in-scope file against ${SOURCE_SIZE_CONFIG_REL}: git-tracked files under a declared
240
+ root carrying a declared extension, minus the excluded path-segment prefixes. Scope is DECLARED,
241
+ never guessed — with the config absent the check REFUSES and prints the exact file to author.
242
+ Symlinks and submodule gitlinks are skipped by kind; an unmerged index, a non-UTF-8 in-scope path,
243
+ an unverifiable in-scope file and an empty declared scope are refusals, never silent greens.
244
+
245
+ Counting: lines, and the longest line in BYTES. A terminator never counts (the CR of a CRLF
246
+ included); a last line with no final newline still counts; an empty file is 0 lines.
247
+
248
+ A file carrying a recorded baseline entry is recorded DEBT: it is judged against the record, which
249
+ may not grow, may not sit above the measured size, and may not outlive its file. Each declared root
250
+ carries the same ratchet over its summed lines.
251
+
252
+ --write-baseline regenerates the machine keys (baseline, aggregate) from the tree. The authored keys
253
+ keep their values and their order; the file is canonically serialized, so its formatting is the
254
+ writer's. A regeneration that RAISES any recorded value needs --reason; the string is recorded in
255
+ the entry it raised. A pure tighten needs none.
256
+
257
+ --adopt is the ONE-line adoption: it mints the record and declares the source-size gate (and NOTHING
258
+ else) in docs/ai/gates.json. It is idempotent on an already-adopted project. With the config absent
259
+ it refuses with the exact file to author — that authoring is the practice's single manual step, and
260
+ the refusal says so. A refused declaration exits nonzero and reports both halves: what was minted and
261
+ what was not declared.
262
+
263
+ Exit codes: 0 green; 1 violation or refusal; 2 usage, config or enumeration error.`;
264
+
265
+ const takeOption = (argv, flag) => {
266
+ const at = argv.indexOf(flag);
267
+ if (at === -1) return { rest: argv, value: undefined };
268
+ if (argv[at + 1] === undefined) throw usageFail(`${flag} needs a value`);
269
+ return { rest: [...argv.slice(0, at), ...argv.slice(at + 2)], value: argv[at + 1] };
270
+ };
271
+
272
+ export const main = (argv, ctx = {}) => {
273
+ try {
274
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
275
+ const cwdOption = takeOption(argv, '--cwd');
276
+ const reasonOption = takeOption(cwdOption.rest, '--reason');
277
+ // Resolved BEFORE the run and before anything is rendered: every path this run names is then
278
+ // meaningful from any directory, not only from the one that invoked it.
279
+ const cwd = resolve(ctx.cwd ?? process.cwd(), cwdOption.value ?? '.');
280
+ // Counted over the ARGUMENTS, not over the mode list: filtering the list collapses repeats, so
281
+ // `--adopt --adopt` read as exactly one mode and a WRITE ran under an argument list this very
282
+ // guard had just called invalid.
283
+ const modes = reasonOption.rest.filter((arg) => MODES.includes(arg));
284
+ if (modes.length === 0) throw usageFail(`nothing to do — pass one of ${MODES.join(', ')} (see --help)`);
285
+ if (modes.length > 1) throw usageFail(`pass exactly ONE mode, got: ${modes.join(', ')}`);
286
+ const unknown = reasonOption.rest.filter((arg) => !MODES.includes(arg));
287
+ if (unknown.length > 0) throw usageFail(`unknown argument: ${unknown[0]}`);
288
+ if (reasonOption.value !== undefined) {
289
+ if (modes[0] === '--check') throw usageFail('--reason belongs to --write-baseline and --adopt — a check records nothing');
290
+ const defect = reasonDefect(reasonOption.value);
291
+ if (defect) throw usageFail(defect);
292
+ }
293
+ const deps = ctx.deps ?? {};
294
+ const run = { '--check': runCheck, '--write-baseline': runWriteBaseline, '--adopt': runAdopt }[modes[0]];
295
+ const { code, lines } = run({ cwd, reason: reasonOption.value, deps });
296
+ return { code, stdout: lines.join('\n'), stderr: '' };
297
+ } catch (err) {
298
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `source-size-check: ${err.message}` };
299
+ }
300
+ };
301
+
302
+ // Compared by REAL path, not lexically: ESM resolves a symlinked entry point to its target, so a
303
+ // lexical comparison is false whenever the tool is invoked through a link — and a gate whose cmd
304
+ // names a link would then exit 0 having run nothing, which reads as PASS.
305
+ // Exported as a test seam (the coverage-check keyFor idiom): the unresolvable arm cannot be reached
306
+ // through the CLI, where an existing entry point is a precondition of getting this far.
307
+ export const sameFile = (a, b) => {
308
+ try {
309
+ return realpathSync(a) === realpathSync(b);
310
+ } catch {
311
+ return false;
312
+ }
313
+ };
314
+ const isDirectRun = Boolean(process.argv[1]) && sameFile(fileURLToPath(import.meta.url), process.argv[1]);
315
+ if (isDirectRun) {
316
+ const result = main(process.argv.slice(2));
317
+ if (result.stdout) process.stdout.write(result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
318
+ if (result.stderr) process.stderr.write(result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
319
+ process.exitCode = result.code;
320
+ }