@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
@@ -0,0 +1,244 @@
1
+ // source-size-config.mjs — the practice's declaration: what docs/ai/source-size.json may say, which
2
+ // of its four states it is in, and how it is read back. Nothing here touches the tree.
3
+ //
4
+ // • CONFIG STATES — ABSENT (no entry at the path) / AUTHORED (authored keys, machine keys absent) /
5
+ // INCOMPLETE (one machine key without the other — a hand-edited half, which routes to the mint
6
+ // lane) / MINTED (both machine keys). A malformed or unknown-keyed config is a loud STOP
7
+ // (exit 2), never a guess, and the template placeholders are REFUSED until replaced, so a
8
+ // printed authoring template can never be pasted into an empty-green scope.
9
+ // • A RECORDED size is debt, not permission: every entry carries a reason, and the reason lands
10
+ // verbatim in the JSON, the commit message and the release CHANGELOG — so it is validated as the
11
+ // single line those three surfaces can carry.
12
+ //
13
+ // Dependency-free, Node >= 22. No side effects on import.
14
+
15
+ import { readFileSync, lstatSync } from 'node:fs';
16
+ import { isAbsolute } from 'node:path';
17
+ import { SOURCE_SIZE_CONFIG_REL, configFail, configPathFor, isLineUnsafe } from './source-size-refusal.mjs';
18
+
19
+ export const SOURCE_SIZE_SCHEMA = 1;
20
+ export const SOURCE_SIZE_DEFAULTS = Object.freeze({ maxLines: 400, maxLineBytes: 1000 });
21
+ export const REASON_MAX_BYTES = 300;
22
+ // The reason a FIRST mint records: every value is new, so every value is a raise, and "this is what
23
+ // the tree already carried when the practice arrived" is the honest sentence for all of them.
24
+ export const INITIAL_ADOPTION_REASON = 'initial adoption';
25
+
26
+ export const AUTHORED_KEYS = Object.freeze(['_README', 'schema', 'defaults', 'roots', 'exclude', 'extensions']);
27
+ export const MACHINE_KEYS = Object.freeze(['baseline', 'aggregate']);
28
+ const ENTRY_KEYS = Object.freeze(['lines', 'maxLineBytes', 'reason']);
29
+
30
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
31
+ // A template placeholder — the authoring refusal prints angle-bracketed values on purpose so the
32
+ // printed file is INERT until a human replaces them; the validator is what makes that promise true.
33
+ const PLACEHOLDER_RE = /^<.*>$/;
34
+ const BAD_SEGMENTS = new Set(['', '.', '..']);
35
+
36
+ const declaredPathDefect = (value, what) => {
37
+ if (typeof value !== 'string' || value.length === 0) return `${what} must be a non-empty string`;
38
+ if (PLACEHOLDER_RE.test(value)) {
39
+ return `${what} still carries the authoring placeholder ${value} — replace it with a real value; the practice never guesses its own scope`;
40
+ }
41
+ if (isAbsolute(value) || value.startsWith('/')) return `${what} "${value}" must be repo-relative, never absolute`;
42
+ if (value.split('/').some((s) => BAD_SEGMENTS.has(s))) return `${what} "${value}" must carry no empty, "." or ".." path segment`;
43
+ return null;
44
+ };
45
+
46
+ const extensionDefect = (value) => {
47
+ if (typeof value !== 'string' || value.length === 0) return 'an "extensions" entry must be a non-empty string';
48
+ if (PLACEHOLDER_RE.test(value)) {
49
+ return `an "extensions" entry still carries the authoring placeholder ${value} — replace it with a real extension; the kit ships NO default file-type list, because a fixed one silently exempts every unlisted language`;
50
+ }
51
+ if (!value.startsWith('.') || value.length < 2 || value.includes('/')) {
52
+ return `an "extensions" entry must look like ".mjs", got ${JSON.stringify(value)}`;
53
+ }
54
+ return null;
55
+ };
56
+
57
+ const positiveIntDefect = (value, what) =>
58
+ Number.isSafeInteger(value) && value > 0 ? null : `${what} must be a positive integer, got ${JSON.stringify(value)}`;
59
+
60
+ const nonNegativeIntDefect = (value, what) =>
61
+ Number.isSafeInteger(value) && value >= 0 ? null : `${what} must be a non-negative integer, got ${JSON.stringify(value)}`;
62
+
63
+ // A reason lands VERBATIM in the JSON entry, the commit message and the release CHANGELOG, so it is
64
+ // a single line under a byte cap — an empty, multiline or control-byte reason is refused (D-3a). It
65
+ // is the ONE value no escaper may rescue: the three destinations would each need different bytes, so
66
+ // the line-safety boundary refuses it at the door instead of rendering it safely.
67
+ export const reasonDefect = (reason) => {
68
+ if (typeof reason !== 'string' || reason.length === 0) return 'a reason must be a non-empty string';
69
+ if (isLineUnsafe(reason)) {
70
+ return 'a reason must be ONE line with no control bytes (it is copied verbatim into JSON, the commit message and the CHANGELOG)';
71
+ }
72
+ const bytes = Buffer.byteLength(reason, 'utf8');
73
+ if (bytes > REASON_MAX_BYTES) return `a reason must be at most ${REASON_MAX_BYTES} UTF-8 bytes, got ${bytes}`;
74
+ return null;
75
+ };
76
+
77
+ // Whole-segment prefix containment: "a/b" contains "a/b" and "a/b/c", never "a/bc".
78
+ export const segmentPrefixOf = (prefix, path) => path === prefix || path.startsWith(`${prefix}/`);
79
+
80
+ // practiceFacts(config) → the numbers every surface that SPEAKS for the practice states: the caps,
81
+ // how much is declared, and how much is recorded. Derived in ONE place so the plan-time render and
82
+ // the checker's own green line can never state two different counts of the same tree.
83
+ // `recordedFiles` / `aggregateLines` are null while that machine half is absent — "not recorded at
84
+ // all" is a different fact from "recorded as zero", and the two states read differently to a human.
85
+ export const practiceFacts = (config) => ({
86
+ maxLines: config.defaults.maxLines,
87
+ maxLineBytes: config.defaults.maxLineBytes,
88
+ roots: config.roots.length,
89
+ recordedFiles: config.baseline === null ? null : Object.keys(config.baseline).length,
90
+ aggregateLines: config.aggregate === null
91
+ ? null
92
+ : Object.values(config.aggregate).reduce((sum, entry) => sum + entry.lines, 0),
93
+ });
94
+
95
+ // `requiredDimensions: 'any'` — a per-file record may pin EITHER dimension or both, because only the
96
+ // dimension that actually violated should be recorded: an entry pinning a dimension that was never
97
+ // over the cap makes the ratchet refuse later changes nobody chose. `'lines'` — a root budget has
98
+ // exactly one dimension, so it is required there.
99
+ const validateEntryMap = (map, what, requiredDimensions, extra) => {
100
+ if (!isPlainObject(map)) throw configFail(`"${what}" must be an object`);
101
+ for (const [key, entry] of Object.entries(map)) {
102
+ if (!isPlainObject(entry)) throw configFail(`"${what}"."${key}" must be an object`);
103
+ const unknown = Object.keys(entry).filter((k) => !ENTRY_KEYS.includes(k));
104
+ if (unknown.length > 0) throw configFail(`"${what}"."${key}" carries unknown key(s): ${unknown.join(', ')}`);
105
+ for (const dimension of ['lines', 'maxLineBytes']) {
106
+ if (!Object.hasOwn(entry, dimension)) continue;
107
+ const defect = nonNegativeIntDefect(entry[dimension], `"${what}"."${key}".${dimension}`);
108
+ if (defect) throw configFail(defect);
109
+ }
110
+ if (requiredDimensions === 'lines' && !Object.hasOwn(entry, 'lines')) {
111
+ throw configFail(nonNegativeIntDefect(entry.lines, `"${what}"."${key}".lines`));
112
+ }
113
+ if (requiredDimensions === 'any' && !Object.hasOwn(entry, 'lines') && !Object.hasOwn(entry, 'maxLineBytes')) {
114
+ throw configFail(`"${what}"."${key}" must record at least one of "lines" or "maxLineBytes" — an entry that pins no dimension records nothing`);
115
+ }
116
+ const reason = reasonDefect(entry.reason);
117
+ if (reason) throw configFail(`"${what}"."${key}".reason: ${reason}`);
118
+ if (extra) extra(key, entry);
119
+ }
120
+ };
121
+
122
+ // validateSourceSizeConfig(parsed) → the normalized config. THROWS configFail (exit 2) on anything it
123
+ // cannot judge — an unknown key included, because a typo'd key would otherwise disarm a rule.
124
+ export const validateSourceSizeConfig = (parsed) => {
125
+ if (!isPlainObject(parsed)) throw configFail(`${SOURCE_SIZE_CONFIG_REL} must contain a JSON object`);
126
+ const known = new Set([...AUTHORED_KEYS, ...MACHINE_KEYS]);
127
+ const unknown = Object.keys(parsed).filter((k) => !known.has(k));
128
+ if (unknown.length > 0) {
129
+ throw configFail(`${SOURCE_SIZE_CONFIG_REL} carries unknown key(s): ${unknown.join(', ')} — known keys are ${[...known].join(', ')}`);
130
+ }
131
+ if (parsed.schema !== SOURCE_SIZE_SCHEMA) {
132
+ throw configFail(`"schema" must be ${SOURCE_SIZE_SCHEMA}, got ${JSON.stringify(parsed.schema)}`);
133
+ }
134
+ if (!isPlainObject(parsed.defaults)) throw configFail('"defaults" must be an object carrying maxLines and maxLineBytes');
135
+ const unknownDefaults = Object.keys(parsed.defaults).filter((k) => !['maxLines', 'maxLineBytes'].includes(k));
136
+ if (unknownDefaults.length > 0) throw configFail(`"defaults" carries unknown key(s): ${unknownDefaults.join(', ')}`);
137
+ for (const key of ['maxLines', 'maxLineBytes']) {
138
+ const defect = positiveIntDefect(parsed.defaults[key], `"defaults".${key}`);
139
+ if (defect) throw configFail(defect);
140
+ }
141
+ for (const key of ['roots', 'extensions']) {
142
+ if (!Array.isArray(parsed[key]) || parsed[key].length === 0) {
143
+ throw configFail(`"${key}" must be a non-empty array — scope is DECLARED, never guessed, so an empty "${key}" is a misdeclaration rather than an empty green`);
144
+ }
145
+ for (const value of parsed[key]) {
146
+ const defect = key === 'roots' ? declaredPathDefect(value, 'a "roots" entry') : extensionDefect(value);
147
+ if (defect) throw configFail(defect);
148
+ }
149
+ }
150
+ // A root declared twice is not "overlapping itself" by the rule below (the rule compares distinct
151
+ // values), yet it double-counts everywhere a root is ITERATED rather than keyed — the printed
152
+ // delta most visibly, which is the durable record of a regeneration.
153
+ const declaredRoots = new Set();
154
+ for (const root of parsed.roots) {
155
+ if (declaredRoots.has(root)) throw configFail(`"roots" declares "${root}" twice — a duplicated root double-counts its files wherever roots are iterated`);
156
+ declaredRoots.add(root);
157
+ }
158
+ for (const outer of parsed.roots) {
159
+ for (const inner of parsed.roots) {
160
+ if (outer !== inner && segmentPrefixOf(outer, inner)) {
161
+ throw configFail(`"roots" entries overlap: "${inner}" sits inside "${outer}" — an overlapping root double-counts its files in the aggregate`);
162
+ }
163
+ }
164
+ }
165
+ if (Object.hasOwn(parsed, 'exclude')) {
166
+ if (!Array.isArray(parsed.exclude)) throw configFail('"exclude" must be an array of literal path prefixes');
167
+ for (const value of parsed.exclude) {
168
+ const defect = declaredPathDefect(value, 'an "exclude" entry');
169
+ if (defect) throw configFail(defect);
170
+ }
171
+ }
172
+ if (Object.hasOwn(parsed, 'baseline')) {
173
+ validateEntryMap(parsed.baseline, 'baseline', 'any', (key) => {
174
+ const defect = declaredPathDefect(key, 'a "baseline" key');
175
+ if (defect) throw configFail(defect);
176
+ });
177
+ }
178
+ if (Object.hasOwn(parsed, 'aggregate')) {
179
+ validateEntryMap(parsed.aggregate, 'aggregate', 'lines', (key, entry) => {
180
+ if (Object.hasOwn(entry, 'maxLineBytes')) {
181
+ throw configFail(`"aggregate"."${key}" carries maxLineBytes — the aggregate budgets LINES only; summing per-file longest-line bytes has no meaning as a budget`);
182
+ }
183
+ });
184
+ }
185
+ return {
186
+ schema: parsed.schema,
187
+ defaults: { ...parsed.defaults },
188
+ roots: [...parsed.roots],
189
+ exclude: Object.hasOwn(parsed, 'exclude') ? [...parsed.exclude] : [],
190
+ extensions: [...parsed.extensions],
191
+ baseline: Object.hasOwn(parsed, 'baseline') ? { ...parsed.baseline } : null,
192
+ aggregate: Object.hasOwn(parsed, 'aggregate') ? { ...parsed.aggregate } : null,
193
+ };
194
+ };
195
+
196
+ // loadSourceSizeConfig(cwd) → { state, path, config, parsed, text, missingMachineKeys }. The states:
197
+ // ABSENT (no file) / AUTHORED (no machine key) / INCOMPLETE (one machine key without the other — a
198
+ // hand-edited half) / MINTED (both). `config` is null only in the ABSENT state; every other state
199
+ // carries a fully validated config, INCOMPLETE included: the key that IS there is judged by the same
200
+ // rules as ever. `parsed` and `text` are the file as written — the writer copies the authored VALUES
201
+ // (and their order) from `parsed`, and compares its own bytes against `text` to know whether it
202
+ // actually changed anything.
203
+ export const loadSourceSizeConfig = (cwd, deps = {}) => {
204
+ const read = deps.readFile ?? readFileSync;
205
+ const lstat = deps.lstat ?? lstatSync;
206
+ const path = configPathFor(cwd);
207
+ // ABSENT means no ENTRY at the path, which only an lstat can answer: reading through a DANGLING
208
+ // SYMLINK fails with the same code as a missing file, and calling that "absent" would tell the
209
+ // reader to author a file the path already holds — through the very link that is broken. The
210
+ // sibling loaders draw the line here for the same reason (orchestration-config.mjs:262-273).
211
+ try {
212
+ lstat(path);
213
+ } catch (err) {
214
+ if (err && err.code === 'ENOENT') return { state: 'absent', path, config: null };
215
+ throw configFail(`${path} could not be read (${err.message})`);
216
+ }
217
+ let raw;
218
+ try {
219
+ raw = read(path, 'utf8');
220
+ } catch (err) {
221
+ throw configFail(`${path} could not be read (${err.message})`);
222
+ }
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(raw);
226
+ } catch (err) {
227
+ throw configFail(`${path} is not valid JSON (${err.message}) — fix it by hand; a malformed config is a STOP, never a guess`);
228
+ }
229
+ const config = validateSourceSizeConfig(parsed);
230
+ const present = MACHINE_KEYS.filter((key) => Object.hasOwn(parsed, key));
231
+ // MINTED means the WHOLE machine half. A file carrying one machine key without the other is a
232
+ // state no regenerator produces — it was hand-edited into it — so it is INCOMPLETE and routes to
233
+ // the mint lane, which writes both. Refusing it as a config error would deadlock the only
234
+ // self-service lane, because the regenerator reads its config through this very function.
235
+ const state = present.length === MACHINE_KEYS.length ? 'minted' : present.length === 0 ? 'authored' : 'incomplete';
236
+ return {
237
+ state,
238
+ path,
239
+ config,
240
+ parsed,
241
+ text: raw,
242
+ missingMachineKeys: MACHINE_KEYS.filter((key) => !present.includes(key)),
243
+ };
244
+ };
@@ -0,0 +1,53 @@
1
+ // source-size-core.mjs — the PURE READ core of the source-size practice (D-18): the ONE import point
2
+ // for every surface that must ask about the practice without reaching a writer. It owns no logic and
3
+ // no write API, and spawns only a read-only git query, so the read-graph purity suite
4
+ // (test/read-graph-purity.test.mjs) stays true however the halves behind it move.
5
+ //
6
+ // The halves, each holding ONE rule set and each within the cap this practice declares:
7
+ // • source-size-refusal.mjs — the two exit classes, and the absolute config path every refusal names
8
+ // • source-size-config.mjs — the config file: its grammar, its four states, its reader
9
+ // • source-size-scope.mjs — which files are judged (D-6) and how big each one is (D-7)
10
+ // • source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker (the canonical matcher)
11
+ //
12
+ // Re-export only: a consumer imports the practice, never a particular half, so a later split moves
13
+ // code without touching a single call site.
14
+
15
+ export {
16
+ SOURCE_SIZE_CONFIG_REL,
17
+ SOURCE_SIZE_STOP,
18
+ SOURCE_SIZE_WHY,
19
+ configFail,
20
+ configPathFor,
21
+ escapeForLine,
22
+ isLineUnsafe,
23
+ jsonForLine,
24
+ scopeFail,
25
+ } from './source-size-refusal.mjs';
26
+
27
+ export {
28
+ AUTHORED_KEYS,
29
+ INITIAL_ADOPTION_REASON,
30
+ MACHINE_KEYS,
31
+ REASON_MAX_BYTES,
32
+ SOURCE_SIZE_DEFAULTS,
33
+ SOURCE_SIZE_SCHEMA,
34
+ loadSourceSizeConfig,
35
+ practiceFacts,
36
+ reasonDefect,
37
+ segmentPrefixOf,
38
+ validateSourceSizeConfig,
39
+ } from './source-size-config.mjs';
40
+
41
+ export {
42
+ countBytes,
43
+ enumerateIndex,
44
+ measureFile,
45
+ resolveScope,
46
+ } from './source-size-scope.mjs';
47
+
48
+ export {
49
+ SOURCE_SIZE_GATE_ID,
50
+ SOURCE_SIZE_TOOL_PATH,
51
+ dqUnsafePath,
52
+ matchesSourceSizeGate,
53
+ } from './source-size-gate-cmd.mjs';
@@ -0,0 +1,55 @@
1
+ // source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker. Mirrors the SHAPE of the
2
+ // review-dependent matcher (gates-declaration.mjs) without joining either of its arrays: this gate is
3
+ // neither a final core check nor review-dependent.
4
+ //
5
+ // Dependency-free, Node >= 22. No side effects on import.
6
+
7
+ import { realpathSync } from 'node:fs';
8
+ import { isAbsolute, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ // STRICT full command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` +
12
+ // END — and the token must realpath-resolve to THIS kit's own checker, so an id squatter never
13
+ // matches.
14
+ //
15
+ // Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
16
+ // execute as written. The token is screened by the rules of the quoting it actually carries, because
17
+ // the two halves are interpreted differently and a single screen would be wrong for one of them:
18
+ // • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
19
+ // • BARE — anything the shell may split, expand or glob makes the executed command different
20
+ // from the string, so a bare token is admitted only from a known-safe alphabet.
21
+ // Either way the point is the same: a path that resolves literally here while the shell would read
22
+ // it differently must never be called canonical, or the matcher certifies a command that never runs.
23
+ export const dqUnsafePath = (text) => [...text].some((ch) => {
24
+ const code = ch.codePointAt(0);
25
+ return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
26
+ });
27
+
28
+ // Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
29
+ // perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
30
+ // through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
31
+ // and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
32
+ const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
33
+ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
34
+ const code = ch.codePointAt(0);
35
+ return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
36
+ });
37
+
38
+ const CHECK_CMD_RE = /^node +(?:"((?:[^"]*[/\\])?source-size-check\.mjs)"|((?:[^\s"]*[/\\])?source-size-check\.mjs)) +--check$/;
39
+ export const SOURCE_SIZE_GATE_ID = 'source-size';
40
+ export const SOURCE_SIZE_TOOL_PATH = fileURLToPath(new URL('./source-size-check.mjs', import.meta.url));
41
+
42
+ export const matchesSourceSizeGate = (cmd, projectDir) => {
43
+ if (typeof cmd !== 'string') return false;
44
+ const match = CHECK_CMD_RE.exec(cmd.trim());
45
+ if (!match) return false;
46
+ const token = match[1] ?? match[2];
47
+ const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
48
+ if (!admissible) return false;
49
+ const abs = isAbsolute(token) ? token : join(projectDir, token);
50
+ try {
51
+ return realpathSync(abs) === realpathSync(SOURCE_SIZE_TOOL_PATH);
52
+ } catch {
53
+ return false; // unresolvable → never canonical (fail closed)
54
+ }
55
+ };
@@ -0,0 +1,114 @@
1
+ // source-size-judge.mjs — the verdict as FACTS, never as words, and the ONE projection everything
2
+ // else reads. Measures every in-scope file once, projects the record the regenerator WOULD write for
3
+ // it, and states each difference against what is recorded today. The rendering half turns those
4
+ // differences into words; the writer half turns the very same projection into the file — so the
5
+ // checker can never demand something the regenerator would not do, nor pass a tree the regenerator
6
+ // would rewrite.
7
+ //
8
+ // The ratchet, stated once (D-3, D-4): a recorded size is DEBT, not permission. It may not grow
9
+ // (that is a raise, and a raise is reasoned); it may not sit ABOVE what the tree measures (a stale
10
+ // record is headroom nobody earned); it may not outlive the violation it records (a file back under
11
+ // the cap keeps no record); and a record whose file is gone is an error, because that is what makes
12
+ // a split or a rename visible instead of silent. The per-root budget rides the same rules over the
13
+ // summed LINES of the root — splitting one file into six buys no headroom at all.
14
+ //
15
+ // Dependency-free, Node >= 22. No writes, no side effects on import.
16
+
17
+ import { measureFile, resolveScope } from './source-size-scope.mjs';
18
+
19
+ export const DIMENSIONS = Object.freeze(['lines', 'maxLineBytes']);
20
+
21
+ // The measured dimension names ARE the baseline-entry keys; `defaults` spells the line cap
22
+ // differently, so the two vocabularies are bridged in exactly one place.
23
+ export const DEFAULT_KEY = Object.freeze({ lines: 'maxLines', maxLineBytes: 'maxLineBytes' });
24
+
25
+ // THE projection: a record exists for a dimension exactly while that dimension violates the declared
26
+ // default. An entry pinning a dimension that was never over the cap would make the ratchet refuse
27
+ // later changes nobody chose, so the entry appears with the violation and disappears with it.
28
+ export const recordFor = (measured, defaults) => Object.fromEntries(
29
+ DIMENSIONS.filter((dimension) => measured[dimension] > defaults[DEFAULT_KEY[dimension]])
30
+ .map((dimension) => [dimension, measured[dimension]]),
31
+ );
32
+
33
+ // A change is a RAISE when it puts a number where there was none, or a bigger one where there was a
34
+ // smaller — the whole class a human's reason is required for. Everything else lowers or removes.
35
+ export const isRaise = ({ from, to }) => to !== null && (from === null || to > from);
36
+
37
+ // A recorded entry is read by OWN key only. A declared root may legitimately be named like an
38
+ // Object.prototype member ("constructor", "toString"), and a plain lookup would then answer with an
39
+ // INHERITED value — which reads as "already recorded", so the raise goes unnoticed and the entry is
40
+ // written with no reason at all, producing a config this tool's own validator refuses.
41
+ export const ownEntry = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
42
+
43
+ // changesFor(target, projected, recorded) → the per-dimension old→new pairs that actually differ.
44
+ // The refusal, the printed delta and the reason an entry ends up with all read THIS.
45
+ export const changesFor = (target, projected, recorded) => DIMENSIONS
46
+ .map((dimension) => ({
47
+ target,
48
+ dimension,
49
+ from: recorded && Object.hasOwn(recorded, dimension) ? recorded[dimension] : null,
50
+ to: Object.hasOwn(projected, dimension) ? projected[dimension] : null,
51
+ }))
52
+ .filter(({ from, to }) => from !== to);
53
+
54
+ const GROWTH_KINDS = new Set(['over-default', 'grew', 'aggregate-grew', 'aggregate-unrecorded']);
55
+
56
+ // D-9: the unmechanizable question — real decomposition, or the same coupling spread thinner? — is
57
+ // asked exactly where a record went DOWN or disappeared, and the checker itself raises it there.
58
+ const LOWERED_KINDS = new Set(['stale', 'record-obsolete', 'entry-gone']);
59
+
60
+ export const hasGrowth = (findings) => findings.some((f) => GROWTH_KINDS.has(f.kind));
61
+ export const hasLoweredRecord = (findings) => findings.some((f) => LOWERED_KINDS.has(f.kind));
62
+
63
+ const findingOfChange = (rel, { dimension, from, to }, measured, defaults) => {
64
+ const allowed = defaults[DEFAULT_KEY[dimension]];
65
+ if (from === null) return { kind: 'over-default', rel, dimension, actual: to, allowed };
66
+ // `to === null` — the file is back under the cap, so the projection records nothing. The measured
67
+ // value is what the reader needs to see; the record is simply obsolete.
68
+ if (to === null) return { kind: 'record-obsolete', rel, dimension, actual: measured[dimension], allowed, recorded: from };
69
+ return to > from
70
+ ? { kind: 'grew', rel, dimension, actual: to, recorded: from }
71
+ : { kind: 'stale', rel, dimension, actual: to, recorded: from };
72
+ };
73
+
74
+ const judgeAggregate = (rootLines, aggregate) => {
75
+ const findings = [];
76
+ for (const [root, actual] of rootLines) {
77
+ if (!Object.hasOwn(aggregate, root)) {
78
+ findings.push({ kind: 'aggregate-unrecorded', root, actual });
79
+ continue;
80
+ }
81
+ const value = aggregate[root].lines;
82
+ if (actual > value) findings.push({ kind: 'aggregate-grew', root, actual, recorded: value });
83
+ else if (actual < value) findings.push({ kind: 'aggregate-stale', root, actual, recorded: value });
84
+ }
85
+ // The recorded set must MIRROR the declared roots: a budget nobody declares any more is a leftover
86
+ // that hides its own root's disappearance, and deleting an entry must never be a way to disarm it.
87
+ for (const root of Object.keys(aggregate)) {
88
+ if (!rootLines.has(root)) findings.push({ kind: 'aggregate-root-gone', root, recorded: aggregate[root].lines });
89
+ }
90
+ return findings;
91
+ };
92
+
93
+ // judgeTree(cwd, config) → { scope, measured, projected, rootLines, findings }. EVERY in-scope file
94
+ // is measured, recorded or not: the fail-closed scope rule has no baseline exception, so an
95
+ // unreadable or non-UTF-8 file refuses even when its size is recorded debt.
96
+ export const judgeTree = (cwd, config, deps = {}) => {
97
+ const scope = resolveScope(cwd, config, deps);
98
+ const measured = new Map(scope.files.map((rel) => [rel, measureFile(cwd, rel, deps)]));
99
+ const projected = new Map(scope.files.map((rel) => [rel, recordFor(measured.get(rel), config.defaults)]));
100
+ const baseline = config.baseline ?? {};
101
+ const findings = [];
102
+ for (const rel of scope.files) {
103
+ findings.push(...changesFor(rel, projected.get(rel), ownEntry(baseline, rel))
104
+ .map((change) => findingOfChange(rel, change, measured.get(rel), config.defaults)));
105
+ }
106
+ for (const rel of Object.keys(baseline)) {
107
+ if (!measured.has(rel)) findings.push({ kind: 'entry-gone', rel, recorded: baseline[rel] });
108
+ }
109
+ const rootLines = new Map(
110
+ [...scope.perRoot].map(([root, files]) => [root, files.reduce((sum, rel) => sum + measured.get(rel).lines, 0)]),
111
+ );
112
+ findings.push(...judgeAggregate(rootLines, config.aggregate ?? {}));
113
+ return { scope, measured, projected, rootLines, findings };
114
+ };
@@ -0,0 +1,70 @@
1
+ // source-size-refusal.mjs — how the source-size practice STOPS, and what every stop must name. The
2
+ // leaf every other half reads, so a refusal raised deep in the scope walk carries the same contract
3
+ // as one raised by the config reader:
4
+ // • exit 2 — the INPUTS are unusable (usage, a malformed config, a failed enumeration); the
5
+ // practice could not judge anything, and no step it could name would change that.
6
+ // • exit 1 — the practice REFUSED: a violation, a stale record, an unverifiable in-scope source
7
+ // file, or a declared state the reader must move on (an absent or unminted config). Every one of
8
+ // these carries a step the reader can perform.
9
+ // • every refusal names the config of the project it ACTUALLY judged, absolute: under a foreign or
10
+ // relative --cwd a repo-relative name points at whatever directory the reader happens to be in.
11
+ // • the WHY rides the RENDERED refusals only — the ones the report composes (absent / unminted /
12
+ // check-FAIL / reason-required). The thrown exit-1 scope refusals (an unverifiable in-scope
13
+ // source file, a non-UTF-8 path, an unmerged index, an empty declared scope) and the exit-2
14
+ // config, usage and enumeration errors do NOT carry it: a sentence about module size explains
15
+ // nothing about a tree that could not be judged at all. That same sentence is what every other
16
+ // surface speaking for the practice quotes (D-17), so it lives here, in the leaf every half
17
+ // already reads: a practice explained in three slightly different sentences is three practices.
18
+ //
19
+ // Dependency-free, Node >= 22. No side effects on import.
20
+
21
+ import { resolve } from 'node:path';
22
+
23
+ export const SOURCE_SIZE_CONFIG_REL = 'docs/ai/source-size.json';
24
+
25
+ // Quoted VERBATIM by every surface that explains the practice: the plan-time render, the checker's
26
+ // rendered refusals, the constraints row a grounded review payload carries. The checker's GREEN line
27
+ // states the practice's FACTS instead — caps, recorded count, aggregate — and never this sentence.
28
+ export const SOURCE_SIZE_WHY = 'A module you can hold whole is the unit of review, test pairing and safe edit; the caps turn size drift into recorded, reasoned debt instead of invisible growth.';
29
+
30
+ export const configPathFor = (cwd) => resolve(cwd, 'docs', 'ai', 'source-size.json');
31
+
32
+ export const SOURCE_SIZE_STOP = 'SOURCE_SIZE_STOP';
33
+
34
+ // ── the line-safety boundary ───────────────────────────────────────────────────────
35
+ // ONE definition of what may never reach a rendered line — C0, DEL, C1, the two Unicode line
36
+ // separators, and a LONE surrogate — and every consumer DERIVED from it. The set is stated once
37
+ // because the alternative was demonstrated across review rounds: each surface guarded part of it,
38
+ // and each partial guard read as complete until a different character walked through it.
39
+ //
40
+ // The lone surrogate is here for a reason the others are not: it does not break a line, it breaks
41
+ // IDENTITY. Written as UTF-8 it becomes the replacement character, byte for byte identical to a
42
+ // name that really contains one — so two different names would print the same, which is the exact
43
+ // property the escaping exists to prevent. A valid PAIR is an ordinary character and is untouched.
44
+ //
45
+ // Three consumers, three jobs:
46
+ // • escapeForLine — a value going into PROSE. Reversible: the backslash is escaped too, so a real
47
+ // newline and a name that literally spells its escape can never render as the same string.
48
+ // • jsonForLine — a value going into the PASTEABLE suggestion, which must stay valid JSON a
49
+ // human copies back into the config. JSON.stringify already escapes C0, the backslash and lone
50
+ // surrogates, so only DEL/C1/separators survive it; escaping exactly those cannot double-escape
51
+ // anything, and the result still JSON.parses back to the original name.
52
+ // • isLineUnsafe — the predicate, for the two values no escaper may touch: a reason (copied
53
+ // VERBATIM into three different files, so it is refused at the door instead — and a
54
+ // non-well-formed one could not land verbatim anywhere) and a rendered command (escapes would
55
+ // change the path the shell receives, so the command is withheld).
56
+ const LINE_UNSAFE_CLASS = '\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029';
57
+ const LONE_SURROGATE = '[\\ud800-\\udbff](?![\\udc00-\\udfff])|(?<![\\ud800-\\udbff])[\\udc00-\\udfff]';
58
+ const UNSAFE = new RegExp(`[${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`, 'g');
59
+ const ESCAPED = new RegExp(`[\\\\${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`, 'g');
60
+ const asEscape = (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
61
+
62
+ export const isLineUnsafe = (text) => new RegExp(`[${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`).test(String(text));
63
+ export const escapeForLine = (text) => String(text).replace(ESCAPED, (ch) => (ch === '\\' ? '\\\\' : asEscape(ch)));
64
+ export const jsonForLine = (value) => JSON.stringify(value).replace(UNSAFE, asEscape);
65
+
66
+ const stopWith = (exitCode) => (message) =>
67
+ Object.assign(new Error(`[agent-workflow-kit] ${escapeForLine(message)}`), { code: SOURCE_SIZE_STOP, exitCode });
68
+
69
+ export const configFail = stopWith(2);
70
+ export const scopeFail = stopWith(1);