@event4u/agent-config 14.22.0 → 14.23.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 (111) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +159 -380
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +47 -38
  7. package/dist/agent-src/commands/roadmap/next.md +8 -0
  8. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
  9. package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
  10. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  11. package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -1
  12. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
  13. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +1 -1
  16. package/dist/cli-delegate/archive_completed_roadmaps.js +52 -4
  17. package/dist/cli-delegate/{chunk-TGIPZBCW.js → chunk-2KKXJW25.js} +22 -8
  18. package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
  19. package/dist/cli-delegate/{chunk-BIXEDQPZ.js → chunk-KCNTUSPI.js} +1 -1
  20. package/dist/cli-delegate/cmd_export.js +2 -2
  21. package/dist/cli-delegate/cmd_handoff.js +20 -12
  22. package/dist/cli-delegate/cmd_prune.js +2 -2
  23. package/dist/cli-delegate/cmd_session_recycle.js +3 -13
  24. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  25. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  26. package/dist/cli-delegate/cmd_sync.js +2 -2
  27. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  28. package/dist/cli-delegate/cmd_validate.js +2 -2
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +8 -8
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +1 -1
  33. package/dist/discovery/dormancy-report.md +1 -1
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +2 -2
  36. package/dist/discovery/trust-report.md +1 -1
  37. package/dist/discovery/workspaces.json +2 -2
  38. package/dist/hooks/dispatch.js +880 -289
  39. package/dist/install/install.mjs +15 -3
  40. package/dist/mcp/registry-manifest.json +2 -2
  41. package/dist/mcp/server.json +2 -2
  42. package/dist/mcp/server.mjs +5458 -2453
  43. package/dist/scripts/_lib/lean_projection_mode.js +108 -0
  44. package/dist/scripts/_lib/router_match.js +243 -0
  45. package/dist/scripts/_lib/rule_injection.js +216 -0
  46. package/dist/scripts/condense.js +20 -20
  47. package/dist/scripts/project_thin_rules.js +139 -19
  48. package/dist/server/schemas/settings.js +16 -3
  49. package/docs/MIGRATION.md +56 -0
  50. package/docs/contracts/settings-classes.md +39 -7
  51. package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
  52. package/llms.txt +1 -1
  53. package/package.json +1 -1
  54. package/src/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  55. package/src/config/agent-settings.template.yml +47 -1
  56. package/src/config/continuity-surface.json +7 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-violation-baselines.json +2 -2
  59. package/src/config/hook-token-budget.json +7 -6
  60. package/src/config/packed-binary-manifest.json +1 -1
  61. package/src/config/routing-coverage-seed.json +9 -2
  62. package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
  63. package/src/scripts/_cli/handoff_sessions.ts +14 -44
  64. package/src/scripts/_lib/continuity_slot.ts +295 -0
  65. package/src/scripts/_lib/continuity_writer.ts +253 -0
  66. package/src/scripts/_lib/hook_settings.ts +85 -0
  67. package/src/scripts/_lib/lean_projection_mode.ts +127 -0
  68. package/src/scripts/_lib/packed_binary_predicate.ts +37 -0
  69. package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
  70. package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
  71. package/src/scripts/_lib/session_eol.ts +44 -0
  72. package/src/scripts/_lib/session_index_trust.ts +346 -0
  73. package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
  74. package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
  75. package/src/scripts/audit_mcp_tools.ts +69 -25
  76. package/src/scripts/build_mcp_catalog.ts +24 -1
  77. package/src/scripts/check_host_tree_parity.ts +291 -0
  78. package/src/scripts/check_preamble_payload_budget.ts +136 -7
  79. package/src/scripts/check_rule_projection_integrity.ts +102 -2
  80. package/src/scripts/ci_settle.ts +205 -5
  81. package/src/scripts/code_graph/build.ts +79 -0
  82. package/src/scripts/code_graph/cli.ts +258 -7
  83. package/src/scripts/code_graph/detect.ts +38 -0
  84. package/src/scripts/code_graph/query.ts +25 -2
  85. package/src/scripts/code_graph/sqlite_store.ts +16 -1
  86. package/src/scripts/code_graph/types.ts +52 -3
  87. package/src/scripts/code_graph/validate.ts +3 -1
  88. package/src/scripts/code_graph/verbs.ts +590 -0
  89. package/src/scripts/condense.ts +20 -20
  90. package/src/scripts/handoff_context_hook.ts +39 -1
  91. package/src/scripts/hook_manifest.json +1 -1
  92. package/src/scripts/hook_manifest.yaml +24 -1
  93. package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
  94. package/src/scripts/hooks/code_graph_context_hook.ts +7 -18
  95. package/src/scripts/hooks/push_settle_hook.ts +3 -2
  96. package/src/scripts/hooks/rule_inject_hook.ts +107 -20
  97. package/src/scripts/hooks/session_eol_hook.ts +120 -2
  98. package/src/scripts/hot_context_hook.ts +62 -7
  99. package/src/scripts/lint_roadmap_blockers.ts +164 -1
  100. package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
  101. package/src/scripts/mcp_server/graph_tools.ts +294 -0
  102. package/src/scripts/mcp_server/path_util.ts +31 -0
  103. package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
  104. package/src/scripts/mcp_server/tools.ts +3 -13
  105. package/src/scripts/model_rule_injection.ts +61 -4
  106. package/src/scripts/project_thin_rules.ts +149 -16
  107. package/src/scripts/report_standing_payload_by_host.ts +650 -0
  108. package/src/scripts/schemas/agent-settings.schema.json +7 -1
  109. package/src/scripts/session_memory_index.ts +81 -8
  110. package/src/server/schemas/settings.ts +23 -4
  111. package/src/vendor/grammars/README.md +27 -2
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The trust contract for the session-start memory index.
3
+ *
4
+ * WHY THIS EXISTS, AND WHOSE RULING IT IMPLEMENTS.
5
+ *
6
+ * The `memory.session_index` block injects curated memory at `session_start`.
7
+ * Restored memory is UNTRUSTED CONTEXT arriving on an injection surface, and an
8
+ * AI council (2026-09-07, ruling D3, recorded in
9
+ * `road-to-continuity-writer-activation` step 3.1) required six properties —
10
+ * repository and worktree identity, path canonicalization, freshness, ordering,
11
+ * duplicate invocation and size limits — each with a test, BEFORE the restore
12
+ * moves off the `hot-context` concern. Five of the six did not exist: measured
13
+ * at HEAD, `session_memory_index.ts` implemented size limits only. This module
14
+ * is those six in one place, so the relocation has something to move.
15
+ *
16
+ * THE THREAT, STATED CONCRETELY RATHER THAN AS A CATEGORY.
17
+ *
18
+ * `MEMORY_ROOT` in `memory_lookup.ts` is the RELATIVE path `agents/memory`, so
19
+ * it resolves against the process cwd. The hook therefore chdirs to a workspace
20
+ * root it received from a host payload and reads memory from wherever that
21
+ * landed. Nothing checked that the root was the workspace the session belongs
22
+ * to, nothing resolved symlinks before trusting it, and nothing stopped a second
23
+ * `session_start` in one session from injecting the block twice. So the
24
+ * attacker this module answers is not an intruder: it is a wrong or stale root —
25
+ * a sibling checkout, a worktree pointed at another tree, a leftover directory,
26
+ * a symlink out of the workspace — serving another repository's curated memory
27
+ * into this session as though it were this repository's.
28
+ *
29
+ * WHAT IT DOES NOT CLAIM.
30
+ *
31
+ * It does not authenticate the memory's CONTENT. A curated entry that is wrong,
32
+ * or hostile, and sits at the correct path inside the correct workspace passes
33
+ * every property here — that is the review boundary of `agents/memory/`, not a
34
+ * check's. Nor does it make the index trustworthy in the sense that a model may
35
+ * act on it: the block stays spotlighted as DATA, per `untrusted-input-defense`,
36
+ * and this module bounds only WHICH bytes reach that block.
37
+ */
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+
41
+ /** P6 — the row cap. Owned here so all six properties read from one place. */
42
+ export const SESSION_INDEX_ROW_CAP = 30;
43
+
44
+ /**
45
+ * P3 — the abandonment bound, in days.
46
+ *
47
+ * A STATED DEFAULT, NOT A MEASURED OPTIMUM, said plainly because a number that
48
+ * looks derived and is not is worse than an admitted guess. Curated memory is
49
+ * deliberately long-lived — a stable `product-rules.yml` is not stale at 90
50
+ * days — so the bound is set well beyond any legitimate quiet period and exists
51
+ * to catch an ABANDONED root, not an inactive one. The two conditions that
52
+ * would falsify it are recorded in `road-to-continuity-writer-activation`
53
+ * step 3.1 rather than here.
54
+ */
55
+ export const MAX_ROOT_AGE_DAYS = 400;
56
+
57
+ /**
58
+ * Curated sources, matching the layouts `memory_lookup._iter_curated_entries`
59
+ * actually reads: `<root>/<type>.yml` AND `<root>/<type>/**\/*.yml`, plus the
60
+ * agent-written `intake/*.jsonl`. Reading only the first of those is what made
61
+ * P3 silently no-op on two of the three layouts — R2 finding 4.
62
+ */
63
+ const CURATED_SUFFIXES = ['.yml', '.yaml', '.jsonl'] as const;
64
+
65
+ /** Depth bound on the walk. A memory root is shallow; a cycle is not. */
66
+ const MAX_WALK_DEPTH = 8;
67
+
68
+ export type TrustRefusalCode =
69
+ | 'workspace-root-unresolvable'
70
+ | 'memory-root-absent'
71
+ | 'memory-root-escapes-workspace'
72
+ | 'curated-source-escapes-workspace'
73
+ | 'source-mtime-in-future'
74
+ | 'memory-root-abandoned'
75
+ | 'already-injected-this-session';
76
+
77
+ export interface TrustRefusal {
78
+ readonly ok: false;
79
+ readonly code: TrustRefusalCode;
80
+ readonly detail: string;
81
+ }
82
+
83
+ export interface TrustGrant {
84
+ readonly ok: true;
85
+ /** Canonical, containment-checked absolute path to the memory root. */
86
+ readonly memoryRoot: string;
87
+ /** Canonical absolute path to the workspace the root was checked against. */
88
+ readonly workspaceRoot: string;
89
+ /** P3 — mtime of the newest curated source read, epoch ms; 0 when none. */
90
+ readonly newestSourceMs: number;
91
+ }
92
+
93
+ export type TrustVerdict = TrustGrant | TrustRefusal;
94
+
95
+ function refuse(code: TrustRefusalCode, detail: string): TrustRefusal {
96
+ return { ok: false, code, detail };
97
+ }
98
+
99
+ /**
100
+ * P2 — canonicalize, then compare by SEGMENTS.
101
+ *
102
+ * A string prefix test says `/a/bc` is inside `/a/b`, which is how a
103
+ * containment check passes something it should refuse. Splitting on the
104
+ * separator makes the comparison mean what it says.
105
+ */
106
+ export function isContained(child: string, parent: string): boolean {
107
+ const rel = path.relative(parent, child);
108
+ if (rel === '') return true;
109
+ if (path.isAbsolute(rel)) return false;
110
+ return !rel.split(path.sep).includes('..');
111
+ }
112
+
113
+ /** Canonicalize a path, following symlinks. `null` when it does not resolve. */
114
+ export function canonical(p: string): string | null {
115
+ try {
116
+ return fs.realpathSync(p);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ export interface SourceWalk {
123
+ /** Newest mtime across every curated source read, epoch ms; 0 when none. */
124
+ readonly newestMs: number;
125
+ /** Sources whose canonical path lies outside the workspace. P1's other half. */
126
+ readonly escaped: readonly string[];
127
+ /** How many sources the walk actually read — 0 means "nothing to judge". */
128
+ readonly count: number;
129
+ }
130
+
131
+ /**
132
+ * One recursive walk serving P1's file half and P3's reading, because R2 found
133
+ * both defects in the same omission: the previous version read only `*.yml`
134
+ * directly at the root, which is one of the three layouts
135
+ * `memory_lookup._iter_curated_entries` supports. So freshness silently
136
+ * no-opped on a type-directory corpus (a 3-year-old one was served past the
137
+ * 400-day bound), and containment was never checked below the root at all.
138
+ *
139
+ * The containment half is the sharper of the two. Canonicalizing the ROOT does
140
+ * not stop a symlinked FILE inside it from resolving into another tree, and
141
+ * that is the exact threat this module's docblock names — proven by the
142
+ * reviewer, who read a donor workspace's entry out of a victim session.
143
+ *
144
+ * Per-entry timestamps do not exist (a curated entry is `{id, key, body}`), so
145
+ * freshness stays a property of the source files rather than something invented
146
+ * per row.
147
+ */
148
+ export function walkCuratedSources(memoryRoot: string, workspaceRoot: string): SourceWalk {
149
+ let newestMs = 0;
150
+ let count = 0;
151
+ const escaped: string[] = [];
152
+
153
+ const visit = (dir: string, depth: number): void => {
154
+ if (depth > MAX_WALK_DEPTH) return;
155
+ let names: fs.Dirent[];
156
+ try {
157
+ names = fs.readdirSync(dir, { withFileTypes: true });
158
+ } catch {
159
+ return;
160
+ }
161
+ for (const ent of names) {
162
+ const full = path.join(dir, ent.name);
163
+ // `withFileTypes` reports a symlink as a symlink, so a directory
164
+ // reached through one is followed only after it canonicalizes
165
+ // inside the workspace — which is the containment check itself.
166
+ const resolved = canonical(full);
167
+ if (resolved === null) continue;
168
+ if (!isContained(resolved, workspaceRoot)) {
169
+ escaped.push(full);
170
+ continue;
171
+ }
172
+ let st: fs.Stats;
173
+ try {
174
+ st = fs.statSync(resolved);
175
+ } catch {
176
+ continue;
177
+ }
178
+ if (st.isDirectory()) {
179
+ visit(resolved, depth + 1);
180
+ continue;
181
+ }
182
+ if (!CURATED_SUFFIXES.some((sfx) => ent.name.endsWith(sfx))) continue;
183
+ count += 1;
184
+ if (st.mtimeMs > newestMs) newestMs = st.mtimeMs;
185
+ }
186
+ };
187
+
188
+ visit(memoryRoot, 0);
189
+ return { newestMs, escaped, count };
190
+ }
191
+
192
+ export interface TrustInput {
193
+ /** The workspace root the session belongs to, as the host reported it. */
194
+ readonly workspaceRoot: string;
195
+ /** Relative memory root, matching `memory_lookup.MEMORY_ROOT`. */
196
+ readonly relativeMemoryRoot?: string;
197
+ /** Injected for tests; defaults to now. */
198
+ readonly now?: Date;
199
+ }
200
+
201
+ /**
202
+ * P1 + P2 + P3 — decide whether a memory root may be read at all.
203
+ *
204
+ * Ordered cheapest-first, and each refusal names itself: a caller that logs the
205
+ * code can tell "this workspace has no memory" from "this root pointed
206
+ * somewhere else", which is the distinction the whole module exists for.
207
+ */
208
+ export function verifyMemoryRoot(input: TrustInput): TrustVerdict {
209
+ const now = input.now ?? new Date();
210
+ const rel = input.relativeMemoryRoot ?? path.join('agents', 'memory');
211
+
212
+ // P2 — the workspace root is canonicalized FIRST. Everything downstream
213
+ // compares against the resolved form, never the reported one.
214
+ const workspaceRoot = canonical(input.workspaceRoot);
215
+ if (workspaceRoot === null) {
216
+ return refuse('workspace-root-unresolvable', `workspace root does not resolve: ${input.workspaceRoot}`);
217
+ }
218
+
219
+ // P1 — identity, checked LEXICALLY first and on the canonical path second.
220
+ // The order is load-bearing and a test found it: a `..` root whose target
221
+ // does not exist would otherwise be reported as merely absent, which reads
222
+ // as "this workspace has no memory" when the truth is "this root pointed
223
+ // outside the tree". The escape is the more important fact, so it is the
224
+ // one reported, whether or not the target happens to exist.
225
+ const lexical = path.resolve(workspaceRoot, rel);
226
+ if (!isContained(lexical, workspaceRoot)) {
227
+ return refuse(
228
+ 'memory-root-escapes-workspace',
229
+ `${lexical} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
230
+ );
231
+ }
232
+
233
+ const memoryRoot = canonical(lexical);
234
+ if (memoryRoot === null) {
235
+ return refuse('memory-root-absent', `no memory root at ${path.join(input.workspaceRoot, rel)}`);
236
+ }
237
+
238
+ // The second half of P1, and the half a lexical check cannot see: the path
239
+ // is inside the tree and the SYMLINK it resolves through is not.
240
+ if (!isContained(memoryRoot, workspaceRoot)) {
241
+ return refuse(
242
+ 'memory-root-escapes-workspace',
243
+ `${memoryRoot} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
244
+ );
245
+ }
246
+
247
+ // P1's file half + P3, from one walk. R2 findings 3 and 4.
248
+ const walk = walkCuratedSources(memoryRoot, workspaceRoot);
249
+ if (walk.escaped.length > 0) {
250
+ return refuse(
251
+ 'curated-source-escapes-workspace',
252
+ `${walk.escaped.length} curated source(s) resolve outside ${workspaceRoot}, first ${walk.escaped[0]} — refusing to serve another tree's memory`,
253
+ );
254
+ }
255
+
256
+ // P3 — freshness, in the two directions it can fail.
257
+ const newestSourceMs = walk.newestMs;
258
+ if (newestSourceMs > now.getTime() + 60_000) {
259
+ return refuse(
260
+ 'source-mtime-in-future',
261
+ `newest curated source is dated in the future (${new Date(newestSourceMs).toISOString()}) — clock skew or tampering`,
262
+ );
263
+ }
264
+ if (newestSourceMs > 0) {
265
+ const ageDays = (now.getTime() - newestSourceMs) / 86_400_000;
266
+ if (ageDays > MAX_ROOT_AGE_DAYS) {
267
+ return refuse(
268
+ 'memory-root-abandoned',
269
+ `newest curated source is ${ageDays.toFixed(0)}d old, past the ${MAX_ROOT_AGE_DAYS}d abandonment bound`,
270
+ );
271
+ }
272
+ }
273
+
274
+ return { ok: true, memoryRoot, workspaceRoot, newestSourceMs };
275
+ }
276
+
277
+ export interface OrderableRow {
278
+ readonly id: string;
279
+ readonly title: string;
280
+ readonly tokens_estimate: number;
281
+ }
282
+
283
+ /**
284
+ * P4 — the declared total order.
285
+ *
286
+ * Ordering is a trust property because the CAP truncates: without a declared
287
+ * order, which 30 of 200 entries reach the model is whatever the filesystem
288
+ * happened to yield, and two runs over the same tree can advertise different
289
+ * corpora. Cheapest row first (`tokens_estimate` ascending) so a fixed cap
290
+ * carries the most entries it can, with `id` ascending as the tie-break that
291
+ * makes the order TOTAL rather than merely defined — a comparator with ties is
292
+ * not deterministic under an unstable sort.
293
+ */
294
+ export function orderRows<T extends OrderableRow>(rows: readonly T[]): T[] {
295
+ return [...rows].sort((a, b) => {
296
+ if (a.tokens_estimate !== b.tokens_estimate) return a.tokens_estimate - b.tokens_estimate;
297
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
298
+ });
299
+ }
300
+
301
+ /** P6 — apply the cap after ordering, never before. */
302
+ export function capRows<T>(rows: readonly T[], cap: number = SESSION_INDEX_ROW_CAP): T[] {
303
+ const limit = Number.isFinite(cap) && cap > 0 ? Math.min(cap, SESSION_INDEX_ROW_CAP) : SESSION_INDEX_ROW_CAP;
304
+ return rows.slice(0, limit);
305
+ }
306
+
307
+ /** Where the P5 latch lives. Gitignored runtime state, one file per session. */
308
+ export function latchPath(workspaceRoot: string, sessionId: string): string {
309
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/gu, '_').slice(0, 128);
310
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'session-index-latch', `${safe}.json`);
311
+ }
312
+
313
+ /**
314
+ * P5 — duplicate invocation.
315
+ *
316
+ * `session_start` can fire more than once for one session (a resume, a
317
+ * host-side reconnect, a compaction boundary on some hosts). Two injections
318
+ * double the fixed cost and can present two different corpora as the same
319
+ * session's memory. The latch is create-exclusive (`wx`), so the FIRST caller
320
+ * wins and every later one refuses — an ordering that matters, because
321
+ * check-then-write would let two concurrent starts both pass.
322
+ *
323
+ * Fails OPEN on an unwritable state directory: a hook that cannot latch must
324
+ * still be able to serve the index once, and refusing there would make a
325
+ * read-only workspace lose the feature entirely. That is a deliberate trade and
326
+ * it is the reason this property is a latch rather than a gate.
327
+ */
328
+ export function claimSessionOnce(workspaceRoot: string, sessionId: string): TrustVerdict | { ok: true } {
329
+ if (sessionId.length === 0) {
330
+ // No session id to key on — cannot latch, so do not pretend to.
331
+ return { ok: true };
332
+ }
333
+ const target = latchPath(workspaceRoot, sessionId);
334
+ try {
335
+ fs.mkdirSync(path.dirname(target), { recursive: true });
336
+ fs.writeFileSync(target, JSON.stringify({ claimed_at: new Date().toISOString() }), { flag: 'wx' });
337
+ return { ok: true };
338
+ } catch (exc) {
339
+ const code = (exc as { code?: string }).code;
340
+ if (code === 'EEXIST') {
341
+ return refuse('already-injected-this-session', `session ${sessionId} already received the index`);
342
+ }
343
+ // fail-open — see the docblock
344
+ return { ok: true };
345
+ }
346
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The standing-payload bounds are shrink-only, checked against the base ref.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * `src/config/preamble-payload-budget.json` says of its grace ceiling: *"It may
7
+ * never move UP."* `grace_ceiling_history` in the same file records it moving up
8
+ * twice — 138,212 → 138,273 on 2026-09-02 and 138,273 → 138,490 on 2026-09-08 —
9
+ * each with a careful prose justification. ADR-264 resolved that contradiction
10
+ * against the practice: the sentence stands, the two raises are violations caused
11
+ * by contradictory wording, and standing-rule growth needs a compensating
12
+ * reduction elsewhere.
13
+ *
14
+ * That decision then had no enforcement. A sentence in a JSON string is not a
15
+ * gate, and the file's own history is the evidence: nothing objected either time.
16
+ * This module is the enforcement — and it is the ONLY thing
17
+ * `road-to-a-standing-budget-with-headroom` step 1.3 ships, because the 128-token
18
+ * Iron Law reserve that step designed was refused by an AI council 2/2 on
19
+ * 2026-09-08. See ADR-265.
20
+ *
21
+ * WHAT IT CANNOT DO, STATED PLAINLY
22
+ * ---------------------------------
23
+ * The change under review can edit this file. Both council seats named that as
24
+ * the defect fatal to the reserve — "the checker, its imports, token census,
25
+ * workflow, and status context are all part of the trusted computing base", so a
26
+ * protected record read by an unprotected verifier grants nothing. It is fatal to
27
+ * a mechanism that GRANTS budget and it is not fatal to one that only REFUSES,
28
+ * which is the whole reason the reserve stayed unshipped and this ratchet did
29
+ * not: disabling this check requires visibly editing it, and every gate in this
30
+ * repository already rests on that same review. A ratchet whose only failure mode
31
+ * is "someone deleted the ratchet" is strictly better than the sentence it
32
+ * replaces, which had no failure mode because it did nothing.
33
+ */
34
+ import { spawnSync } from 'node:child_process';
35
+
36
+ /** Repo-relative path of the budget config the bound lives in. */
37
+ export const BUDGET_CONFIG_PATH = 'src/config/preamble-payload-budget.json';
38
+
39
+ /** Injection seam: run git, capture output, never throw on a non-zero exit. */
40
+ export type GitRunner = (args: readonly string[], cwd: string) => {
41
+ ok: boolean;
42
+ stdout: string;
43
+ stderr: string;
44
+ };
45
+
46
+ export const realGit: GitRunner = (args, cwd) => {
47
+ const r = spawnSync('git', [...args], { cwd, encoding: 'utf-8' });
48
+ return { ok: r.status === 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
49
+ };
50
+
51
+ export interface BoundsRatchetVerdict {
52
+ ok: boolean;
53
+ violations: string[];
54
+ /** The bound read at the base ref, or `null` when it could not be read. */
55
+ baseGraceCeiling: number | null;
56
+ /** The ref compared against, or `null` when none resolved. */
57
+ baseRef: string | null;
58
+ /** Why the comparison was skipped, when it was. Printed, never silent. */
59
+ note: string | null;
60
+ }
61
+
62
+ function showAtRef(git: GitRunner, repoRoot: string, ref: string, relPath: string): string | null {
63
+ const r = git(['show', `${ref}:${relPath}`], repoRoot);
64
+ return r.ok ? r.stdout : null;
65
+ }
66
+
67
+ /**
68
+ * Compare the grace ceiling in this tree against the one at the base ref.
69
+ *
70
+ * SKIPS rather than fails when the base ref or the base config cannot be read.
71
+ * That is the opposite of the fail-closed posture the same council demanded for
72
+ * the reserve, and the asymmetry is deliberate: an unreadable base there would
73
+ * have GRANTED budget on an infrastructure failure, whereas here it would REFUSE
74
+ * a change on one. A shallow clone, a first commit and a detached build all
75
+ * legitimately have no base, and a gate that reds on them gets switched off —
76
+ * which is how a repository loses the enforcement it just built. The skip is
77
+ * reported in both the human and JSON output so it cannot pass for a check.
78
+ */
79
+ export function assertBoundsDidNotRise(opts: {
80
+ repoRoot: string;
81
+ baseRef: string | null;
82
+ git?: GitRunner;
83
+ /**
84
+ * The ceiling this run actually compares the payload against.
85
+ *
86
+ * The CI step passes `--ceiling <grace_ceiling>`, so the effective bound can
87
+ * be raised either by editing the config or by passing a bigger number. Both
88
+ * arrive here as one value, which closes the second path without a second
89
+ * check.
90
+ */
91
+ headGraceCeiling: number;
92
+ }): BoundsRatchetVerdict {
93
+ const git = opts.git ?? realGit;
94
+ if (opts.baseRef === null || opts.baseRef.trim() === '') {
95
+ return {
96
+ ok: true,
97
+ violations: [],
98
+ baseGraceCeiling: null,
99
+ baseRef: null,
100
+ note: 'no base ref resolved, so the shrink-only bound was NOT verified in this run',
101
+ };
102
+ }
103
+ const cfg = showAtRef(git, opts.repoRoot, opts.baseRef, BUDGET_CONFIG_PATH);
104
+ if (cfg === null) {
105
+ return {
106
+ ok: true,
107
+ violations: [],
108
+ baseGraceCeiling: null,
109
+ baseRef: opts.baseRef,
110
+ note: `${BUDGET_CONFIG_PATH} could not be read at ${opts.baseRef}, so the shrink-only bound was NOT verified`,
111
+ };
112
+ }
113
+ let baseGrace: number | null = null;
114
+ try {
115
+ const parsed = JSON.parse(cfg) as Record<string, unknown>;
116
+ const delivery = (parsed['ci_delivery'] ?? {}) as Record<string, unknown>;
117
+ const n = Number(delivery['grace_ceiling']);
118
+ baseGrace = Number.isFinite(n) ? n : null;
119
+ } catch {
120
+ return {
121
+ ok: true,
122
+ violations: [],
123
+ baseGraceCeiling: null,
124
+ baseRef: opts.baseRef,
125
+ note: `${BUDGET_CONFIG_PATH} at ${opts.baseRef} is not parseable, so the shrink-only bound was NOT verified`,
126
+ };
127
+ }
128
+ if (baseGrace === null) {
129
+ return {
130
+ ok: true,
131
+ violations: [],
132
+ baseGraceCeiling: null,
133
+ baseRef: opts.baseRef,
134
+ note: `no ci_delivery.grace_ceiling at ${opts.baseRef}, so there is no earlier bound to ratchet against`,
135
+ };
136
+ }
137
+ if (opts.headGraceCeiling > baseGrace) {
138
+ return {
139
+ ok: false,
140
+ baseGraceCeiling: baseGrace,
141
+ baseRef: opts.baseRef,
142
+ note: null,
143
+ violations: [
144
+ `the standing-payload grace ceiling rose from ${String(baseGrace)} to ` +
145
+ `${String(opts.headGraceCeiling)} against ${opts.baseRef}. ` +
146
+ `${BUDGET_CONFIG_PATH} says it may not move upward and ADR-264 decided that ` +
147
+ 'the sentence stands: standing-rule growth needs a compensating reduction ' +
148
+ 'elsewhere, not a bigger ceiling. Lower the addition, migrate the prose out of ' +
149
+ 'the standing rule, or reduce another standing asset by the same amount.',
150
+ ],
151
+ };
152
+ }
153
+ return { ok: true, violations: [], baseGraceCeiling: baseGrace, baseRef: opts.baseRef, note: null };
154
+ }