@event4u/agent-config 14.13.0 → 14.14.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 (101) 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 +116 -387
  5. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  6. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  7. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  8. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  9. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  10. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +38 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +4 -4
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +1 -1
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +2 -2
  20. package/dist/discovery/trust-report.md +1 -1
  21. package/dist/discovery/workspaces.json +2 -2
  22. package/dist/hooks/dispatch.js +1438 -711
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +112 -17
  26. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  27. package/docs/guidelines/code-clarity.md +18 -0
  28. package/package.json +1 -1
  29. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  30. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  31. package/src/agent-src/scripts/stubs_due.ts +118 -2
  32. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  33. package/src/config/estate-count-budget.json +20 -1
  34. package/src/config/gate-coverage.yml +150 -0
  35. package/src/config/gate-violation-baselines.json +8 -3
  36. package/src/config/preamble-payload-budget.json +19 -11
  37. package/src/config/rule-stub-ceilings.json +14 -7
  38. package/src/scripts/_dispatch.bash +0 -4
  39. package/src/scripts/_lib/activation_ladder.ts +47 -3
  40. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  41. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  42. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  43. package/src/scripts/_lib/carrier_status.ts +118 -0
  44. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  45. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  46. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  47. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  48. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  49. package/src/scripts/_lib/evolution_roi.ts +562 -0
  50. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  51. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  52. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  53. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  54. package/src/scripts/_lib/pathology_archive.ts +393 -0
  55. package/src/scripts/_lib/promotion_capability.ts +315 -0
  56. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  57. package/src/scripts/_lib/promotion_review.ts +294 -0
  58. package/src/scripts/_lib/prompt_shape.ts +92 -0
  59. package/src/scripts/_lib/release_highlights.ts +70 -1
  60. package/src/scripts/_lib/release_material.ts +125 -2
  61. package/src/scripts/_lib/routing_corpus.ts +307 -0
  62. package/src/scripts/_lib/routing_index_input.ts +104 -0
  63. package/src/scripts/_lib/rule_injection.ts +22 -1
  64. package/src/scripts/_lib/semantic_noop.ts +167 -0
  65. package/src/scripts/activation_receipt.ts +258 -0
  66. package/src/scripts/ai_council/cross_exam.ts +128 -0
  67. package/src/scripts/ai_council/explain_route.ts +157 -0
  68. package/src/scripts/ai_council/information_gain.ts +293 -0
  69. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  70. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  71. package/src/scripts/ai_council/minority_retention.ts +171 -0
  72. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  73. package/src/scripts/ai_council/replay_route.ts +223 -0
  74. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  75. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  76. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  77. package/src/scripts/check_adr_frontmatter.ts +4 -9
  78. package/src/scripts/check_estate_count.ts +205 -24
  79. package/src/scripts/check_release_highlights.ts +96 -13
  80. package/src/scripts/check_roadmap_trackable.ts +17 -2
  81. package/src/scripts/corpus_manifest.ts +235 -0
  82. package/src/scripts/evolution_lab.ts +181 -8
  83. package/src/scripts/hook_manifest.json +1 -1
  84. package/src/scripts/hook_manifest.yaml +28 -6
  85. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  86. package/src/scripts/hooks/concern_registry.ts +2 -0
  87. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  88. package/src/scripts/language_mirror_hook.ts +14 -2
  89. package/src/scripts/lint_carrier_integrity.ts +820 -0
  90. package/src/scripts/lint_code_comments.ts +643 -0
  91. package/src/scripts/lint_plan_risk_register.ts +39 -19
  92. package/src/scripts/lint_promotion_paths.ts +779 -0
  93. package/src/scripts/llm_propose.ts +256 -0
  94. package/src/scripts/measure_delivery_sets.ts +361 -0
  95. package/src/scripts/measure_routing_signal.ts +307 -0
  96. package/src/scripts/model_rule_injection.ts +19 -3
  97. package/src/scripts/probe_council_retention.ts +495 -0
  98. package/src/scripts/release.ts +21 -37
  99. package/src/scripts/release_drill.ts +143 -0
  100. package/src/scripts/release_env.ts +35 -0
  101. package/src/scripts/release_publication.ts +84 -0
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * The promotion-path structural invariant — discharge route 1 of the carried
4
+ * blocking condition on `road-to-harness-promotion-bridge`.
5
+ *
6
+ * WHY THIS GATE EXISTS
7
+ * --------------------
8
+ * The carried condition covers *"a verb, a state transition into `promoted`, or
9
+ * any write into `src/` derived from a candidate"*. Before this gate, TWO of
10
+ * those three limbs were enforced — `verbPromote` refuses unconditionally
11
+ * (`src/scripts/evolution_lab.ts`), and `assertTransition(_, 'promoted')`
12
+ * refuses without a named approver (`src/scripts/_lib/candidate_record.ts`) —
13
+ * and the third was enforced by nothing. The AI council of 2026-08-31 ruled the
14
+ * condition NOT DISCHARGED on exactly that gap, and named the discharging
15
+ * mechanism: an invariant that *inventories* every lifecycle-record write site
16
+ * and every `src/` write site and *fails* when a promotion-capable write does
17
+ * not pass through ONE guarded capability, where the capability is unobtainable
18
+ * while `blocker: merge-authority` is open.
19
+ *
20
+ * `src/scripts/_lib/promotion_capability.ts` is that capability. This is that
21
+ * invariant.
22
+ *
23
+ * THE FOUR RULES
24
+ * --------------
25
+ * R0 the capability is UNOBTAINABLE while the blocker is open. Probed by
26
+ * calling `acquirePromotionCapability` and requiring it to throw. This is
27
+ * executed, not read: a text match would pass over a module whose refusal
28
+ * had been edited into a no-op.
29
+ * R1 no approval synthesis outside {@link R1_ALLOWLIST}. Any construction of
30
+ * a `HumanApproval`, any reference to the type, and any three-argument
31
+ * `assertTransition(...)` call — the argument that supplies the approval.
32
+ * R2 no promoted-state record write outside {@link R2_ALLOWLIST}. A record
33
+ * literal carrying `lifecycle: 'promoted'` (or `ACCEPTED_STATE`) bypasses
34
+ * the transition gate entirely, which is the second bypass vector both
35
+ * council seats named.
36
+ * R3 no candidate-derived write into the repository source tree. A module
37
+ * that reads candidate data may not target `src/` with a filesystem
38
+ * write. This is the THIRD LIMB, the one nothing gated before.
39
+ *
40
+ * ANTI-VACUITY — this gate fails when it scans nothing
41
+ * ----------------------------------------------------
42
+ * Risk 2 of the roadmap's register is that a check over a population of zero
43
+ * discharges the condition while scanning nothing, and the condition's own text
44
+ * forbids exactly that. Three floors, all fail-closed:
45
+ * {@link MIN_FILES} source files, {@link MIN_CANDIDATE_MODULES} modules that
46
+ * actually read candidate data, and {@link MIN_WRITE_SITES} filesystem-write
47
+ * sites inside them. Below any of them the gate exits 2 — the population moved
48
+ * or the scanner broke, and either way its green would be false.
49
+ *
50
+ * **Gaming risk.** The cheapest degenerate pass is prose relocation: this gate
51
+ * strips block comments and whole-line `//` comments before matching, so a
52
+ * bypass written inside a block comment is invisible to it. That is deliberate
53
+ * and the direction is safe — this module and its neighbours DOCUMENT the
54
+ * constructs they refuse, and a gate that failed on its own documentation would
55
+ * be a reason to stop documenting it. A trailing `//` comment is NOT stripped,
56
+ * so prose there trips the gate rather than hiding a construct. Second
57
+ * degenerate pass: adding a file to {@link R1_ALLOWLIST} / {@link R2_ALLOWLIST}.
58
+ * Mitigation — the allowlists are literals in this file, so growing one is a
59
+ * visible diff, and `tests/scripts/lint_promotion_paths.test.ts` pins their exact
60
+ * membership so a silent addition fails a test rather than passing review.
61
+ * **Residual, stated rather than implied:** R3 resolves `const` bindings up to
62
+ * {@link CONST_HOPS} hops, so a destination assembled at runtime from a value
63
+ * carrying no `src` literal anywhere in its chain is not detectable textually.
64
+ * That residual is bounded rather than closed — such a write is a source-tree
65
+ * write, but it is only a PROMOTION when it also carries an approval or a
66
+ * promoted record, which R1 and R2 catch independently.
67
+ *
68
+ * ADVISORY STAGE — satisfied by measurement, not by absence. Run against the
69
+ * real corpus at authoring time this gate produced ZERO findings over 2812
70
+ * source and test files, 16 candidate-derived modules and 66 write sites, and
71
+ * it found and fixed two of its own defects on the way (a substitution that
72
+ * rewrote identifiers inside string literals, and an expansion that destroyed
73
+ * the `REPO_ROOT` token it keys on). It therefore lands at
74
+ * error with an empty baseline rather than as a wall over a frozen corpus.
75
+ * There is no `gate-violation-baselines.json` entry to promote FROM: the
76
+ * promotion condition would be "promote to error when the baseline reaches 0",
77
+ * and it is 0 on arrival.
78
+ *
79
+ * Exit codes: 0 clean · 1 a promotion-capable bypass · 2 misuse or dead scope.
80
+ *
81
+ * Usage:
82
+ * ./scripts-run src/scripts/lint_promotion_paths [--root <dir>] [--quiet]
83
+ * ./scripts-run src/scripts/lint_promotion_paths --self-test
84
+ */
85
+ import * as fs from 'node:fs';
86
+ import * as os from 'node:os';
87
+ import * as path from 'node:path';
88
+ import process from 'node:process';
89
+
90
+ import { GateLedger } from './_lib/gate_ledger.js';
91
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
92
+ import { assertScanned, DeadScopeError, reportScanned } from './_lib/scan_scope.js';
93
+ import {
94
+ PromotionCapabilityUnobtainableError,
95
+ REPO_SOURCE_ROOT,
96
+ acquirePromotionCapability,
97
+ isRefusingStatus,
98
+ readMergeAuthorityStatus,
99
+ } from './_lib/promotion_capability.js';
100
+
101
+ const HERE = path.dirname(new URL(import.meta.url).pathname);
102
+ const REPO_ROOT = path.resolve(HERE, '..', '..');
103
+
104
+ export const GATE = 'lint_promotion_paths';
105
+
106
+ /** Roots walked for R1 and R2 — the authored source tree. */
107
+ export const SOURCE_ROOTS: readonly string[] = ['src'];
108
+
109
+ /**
110
+ * Roots walked additionally for R3.
111
+ *
112
+ * Tests are OUT of R1/R2 scope and IN R3 scope, and the asymmetry is deliberate:
113
+ * a test must be able to construct the refused shape in order to test the
114
+ * refusal, and no test has any business writing into the source tree.
115
+ */
116
+ export const R3_EXTRA_ROOTS: readonly string[] = ['tests'];
117
+
118
+ /**
119
+ * This gate's own file.
120
+ *
121
+ * A detector has to NAME what it detects — the patterns below and the self-test
122
+ * fixtures beside them contain every construct this gate refuses. A gate that
123
+ * failed on its own detector is the false-positive shape
124
+ * `check_no_currency_in_cost_surfaces` already solved the same way, and its
125
+ * discrimination is proven by `--self-test` against planted fixtures rather
126
+ * than by scanning itself.
127
+ */
128
+ export const GATE_SELF = 'src/scripts/lint_promotion_paths.ts';
129
+
130
+ /**
131
+ * This gate's paired fixture file, for the same reason and with the same bound.
132
+ *
133
+ * `tests/scripts/lint_promotion_paths.test.ts` carries every construct this gate
134
+ * refuses, as string fixtures fed to the detectors — including a literal
135
+ * `import ... candidate_record.js`, which makes `isCandidateDerived` read the
136
+ * test itself as a candidate module. It ships nothing and can promote nothing;
137
+ * its membership here is pinned by an assertion inside that very file, so
138
+ * widening the allowlist is a visible diff in two places rather than one.
139
+ */
140
+ export const GATE_TEST = 'tests/scripts/lint_promotion_paths.test.ts';
141
+
142
+ /**
143
+ * R1 allowlist — the files that may name a human approval.
144
+ *
145
+ * `candidate_record.ts` DEFINES `HumanApproval` and the transition gate;
146
+ * `promotion_capability.ts` is the one guarded mint. Anywhere else, naming the
147
+ * type is doing approval work outside the capability.
148
+ */
149
+ export const R1_ALLOWLIST: readonly string[] = [
150
+ 'src/scripts/_lib/candidate_record.ts',
151
+ 'src/scripts/_lib/promotion_capability.ts',
152
+ GATE_SELF,
153
+ GATE_TEST,
154
+ ];
155
+
156
+ /** R2 allowlist — narrower on purpose. Only the capability may express a promotion. */
157
+ export const R2_ALLOWLIST: readonly string[] = ['src/scripts/_lib/promotion_capability.ts', GATE_SELF, GATE_TEST];
158
+
159
+ /** R3 allowlist — the capability performs no write at all, and is listed for symmetry. */
160
+ export const R3_ALLOWLIST: readonly string[] = ['src/scripts/_lib/promotion_capability.ts', GATE_SELF, GATE_TEST];
161
+
162
+ /** Modules whose import marks a file as reading candidate data. */
163
+ export const CANDIDATE_DATA_MODULES: readonly string[] = [
164
+ 'candidate_record',
165
+ 'candidate_proposer',
166
+ 'curator_ops',
167
+ 'bench_ab_clone',
168
+ 'promotion_evidence',
169
+ 'promotion_review',
170
+ ];
171
+
172
+ /** Anti-vacuity floors. Below any of these the population moved or the scanner broke. */
173
+ export const MIN_FILES = 400;
174
+ export const MIN_CANDIDATE_MODULES = 4;
175
+ export const MIN_WRITE_SITES = 10;
176
+
177
+ /** How far R3 follows a `const` binding when resolving a write destination. */
178
+ export const CONST_HOPS = 3;
179
+
180
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'clones', '__pycache__']);
181
+
182
+ export interface Finding {
183
+ rule: 'R0' | 'R1' | 'R2' | 'R3';
184
+ file: string;
185
+ line: number;
186
+ what: string;
187
+ text: string;
188
+ }
189
+
190
+ // ------------------------------------------------------------------ scanning
191
+
192
+ /**
193
+ * Remove block comments and WHOLE-LINE `//` comments, preserving line count.
194
+ *
195
+ * A trailing `//` comment is left in place on purpose — stripping it would let a
196
+ * construct hide behind a preceding string containing `//`, and that is a
197
+ * cheaper bypass than the false positive it would prevent.
198
+ */
199
+ export function stripComments(source: string): string {
200
+ const noBlocks = source.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
201
+ return noBlocks
202
+ .split('\n')
203
+ .map((l) => (/^\s*\/\//.test(l) ? '' : l))
204
+ .join('\n');
205
+ }
206
+
207
+ /** Does this file import anything that reads candidate data? */
208
+ export function isCandidateDerived(source: string): boolean {
209
+ const body = stripComments(source);
210
+ return CANDIDATE_DATA_MODULES.some((m) =>
211
+ new RegExp(`from\\s+['"][^'"]*${m}(\\.js)?['"]`).test(body),
212
+ );
213
+ }
214
+
215
+ function lineOf(body: string, index: number): number {
216
+ let n = 1;
217
+ for (let i = 0; i < index && i < body.length; i += 1) {
218
+ if (body[i] === '\n') n += 1;
219
+ }
220
+ return n;
221
+ }
222
+
223
+ /**
224
+ * Split a call's argument list into top-level arguments.
225
+ *
226
+ * `open` is the index of the `(`. Returns `null` when the parentheses do not
227
+ * balance within the file, which is a parse failure rather than a pass.
228
+ */
229
+ export function callArguments(body: string, open: number): string[] | null {
230
+ let depth = 0;
231
+ let quote: string | null = null;
232
+ const args: string[] = [];
233
+ let current = '';
234
+ for (let i = open; i < body.length; i += 1) {
235
+ const c = body[i] as string;
236
+ if (quote !== null) {
237
+ current += c;
238
+ if (c === '\\') {
239
+ current += body[i + 1] ?? '';
240
+ i += 1;
241
+ } else if (c === quote) {
242
+ quote = null;
243
+ }
244
+ continue;
245
+ }
246
+ if (c === "'" || c === '"' || c === '`') {
247
+ quote = c;
248
+ current += c;
249
+ continue;
250
+ }
251
+ if (c === '(' || c === '[' || c === '{') {
252
+ depth += 1;
253
+ if (depth === 1 && c === '(') continue;
254
+ current += c;
255
+ continue;
256
+ }
257
+ if (c === ')' || c === ']' || c === '}') {
258
+ depth -= 1;
259
+ if (depth === 0 && c === ')') {
260
+ args.push(current);
261
+ return args;
262
+ }
263
+ current += c;
264
+ continue;
265
+ }
266
+ if (c === ',' && depth === 1) {
267
+ args.push(current);
268
+ current = '';
269
+ continue;
270
+ }
271
+ current += c;
272
+ }
273
+ return null;
274
+ }
275
+
276
+ // --------------------------------------------------------------------- R1
277
+
278
+ /**
279
+ * Approval synthesis: constructing, typing, or supplying a human approval.
280
+ *
281
+ * The `approver:` / `approvedAt:` detectors are CONSTRUCTION-specific — an
282
+ * interface field declaration (`readonly approver: string;`) is a type position
283
+ * and is excluded by the negative lookahead, so the definition site does not
284
+ * have to be allowlisted for the wrong reason.
285
+ */
286
+ export function findApprovalSites(source: string): Finding[] {
287
+ const body = stripComments(source);
288
+ const out: Finding[] = [];
289
+ const patterns: Array<[string, RegExp]> = [
290
+ ['approver-construction', /\bapprover\s*:\s*(?!(?:string|number|boolean|unknown|any)\b)\S/g],
291
+ ['approvedAt-construction', /\bapprovedAt\s*:\s*(?!(?:string|number|boolean|unknown|any)\b)\S/g],
292
+ ['HumanApproval-reference', /\bHumanApproval\b/g],
293
+ ['assertHumanApproval-reference', /\bassertHumanApproval\b/g],
294
+ ];
295
+ for (const [what, re] of patterns) {
296
+ re.lastIndex = 0;
297
+ let m: RegExpExecArray | null;
298
+ while ((m = re.exec(body)) !== null) {
299
+ out.push({
300
+ rule: 'R1',
301
+ file: '',
302
+ line: lineOf(body, m.index),
303
+ what,
304
+ text: (body.split('\n')[lineOf(body, m.index) - 1] ?? '').trim().slice(0, 120),
305
+ });
306
+ }
307
+ }
308
+ // A three-argument `assertTransition(from, to, approval)` supplies the
309
+ // approval the two-argument form cannot. The DECLARATION is excluded by the
310
+ // preceding-`function` test — its parameter list also carries two commas.
311
+ const callRe = /\bassertTransition\s*\(/g;
312
+ let c: RegExpExecArray | null;
313
+ while ((c = callRe.exec(body)) !== null) {
314
+ const before = body.slice(Math.max(0, c.index - 40), c.index);
315
+ if (/\bfunction\s*$/.test(before)) continue;
316
+ const args = callArguments(body, body.indexOf('(', c.index));
317
+ if (args !== null && args.length >= 3) {
318
+ out.push({
319
+ rule: 'R1',
320
+ file: '',
321
+ line: lineOf(body, c.index),
322
+ what: 'assertTransition-with-approval',
323
+ text: args.map((a) => a.trim()).join(', ').slice(0, 120),
324
+ });
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+
330
+ // --------------------------------------------------------------------- R2
331
+
332
+ /** A record literal that names the accepted state directly, bypassing the transition. */
333
+ export function findPromotedWriteSites(source: string): Finding[] {
334
+ const body = stripComments(source);
335
+ const out: Finding[] = [];
336
+ const patterns: Array<[string, RegExp]> = [
337
+ ['promoted-literal', /\blifecycle\s*:\s*['"`]promoted['"`]/g],
338
+ ['accepted-state-write', /\blifecycle\s*:\s*ACCEPTED_STATE\b/g],
339
+ ];
340
+ for (const [what, re] of patterns) {
341
+ re.lastIndex = 0;
342
+ let m: RegExpExecArray | null;
343
+ while ((m = re.exec(body)) !== null) {
344
+ out.push({
345
+ rule: 'R2',
346
+ file: '',
347
+ line: lineOf(body, m.index),
348
+ what,
349
+ text: (body.split('\n')[lineOf(body, m.index) - 1] ?? '').trim().slice(0, 120),
350
+ });
351
+ }
352
+ }
353
+ return out;
354
+ }
355
+
356
+ // --------------------------------------------------------------------- R3
357
+
358
+ /** Write calls, and which argument carries the DESTINATION. */
359
+ export const WRITE_CALLS: Readonly<Record<string, number>> = {
360
+ writeFileSync: 0,
361
+ writeFile: 0,
362
+ appendFileSync: 0,
363
+ appendFile: 0,
364
+ mkdirSync: 0,
365
+ rmSync: 0,
366
+ rmdirSync: 0,
367
+ unlinkSync: 0,
368
+ createWriteStream: 0,
369
+ outputFileSync: 0,
370
+ copyFileSync: 1,
371
+ cpSync: 1,
372
+ renameSync: 1,
373
+ linkSync: 1,
374
+ symlinkSync: 1,
375
+ };
376
+
377
+ /**
378
+ * Tokens that root a path at the REPOSITORY, as opposed to a clone or a tmpdir.
379
+ *
380
+ * The discriminator matters: `join(victim, 'src', 'smuggled.ts')` in
381
+ * `tests/scripts/bench_ab_candidate.test.ts:384` writes into a CLONE's `src/`,
382
+ * which is a candidate's own sandbox and is already gated — by
383
+ * `bench_ab_integrity`'s allowed-delta-path check, whose sensitivity that very
384
+ * test proves. Promotion is a candidate reaching CANONICAL `agent-config`, so
385
+ * this rule is anchored at the repository root and says so.
386
+ */
387
+ export const REPO_ROOT_TOKENS = /\b(?:REPO_ROOT|CAPABILITY_REPO_ROOT|repoRoot|repo_root|ROOT|root|cwd)\b/;
388
+
389
+ /** A string literal naming `src` as a path segment. */
390
+ const SRC_SEGMENT = new RegExp(`['"\`]${REPO_SOURCE_ROOT}['"\`]`);
391
+
392
+ /** A destination given as a bare repo-relative literal — `'src/...'`, `"src"`. */
393
+ const SRC_FIRST_LITERAL = new RegExp(`^\\s*['"\`]${REPO_SOURCE_ROOT}(?:/|['"\`])`);
394
+
395
+ /**
396
+ * Does this write destination resolve to the canonical source tree?
397
+ *
398
+ * Two accepted shapes, and nothing else: a repo-root token joined with a `src`
399
+ * segment, or a destination whose FIRST component is a `src`-rooted literal.
400
+ * `original` is the unexpanded expression, because the first-component test is
401
+ * about how the destination was written, not about what it expands to; the
402
+ * two-shape test below reads the UNION of both, for the reason stated in the
403
+ * body.
404
+ */
405
+ export function targetsCanonicalSource(original: string, expanded: string): boolean {
406
+ if (SRC_FIRST_LITERAL.test(original)) {
407
+ return true;
408
+ }
409
+ // Both texts, unioned. Expansion can DESTROY the signal as easily as reveal
410
+ // it: `REPO_ROOT` is itself a `const`, so substituting it replaces the very
411
+ // token this rule keys on — measured, on the first sensitivity probe, which
412
+ // fired R1 and R2 on a planted bypass and missed its R3 half entirely.
413
+ const combined = `${original} ${expanded}`;
414
+ return REPO_ROOT_TOKENS.test(combined) && SRC_SEGMENT.test(combined);
415
+ }
416
+
417
+ /** Module- and function-level `const NAME = <expr>;` bindings, for one-name resolution. */
418
+ export function constBindings(body: string): Map<string, string> {
419
+ const out = new Map<string, string>();
420
+ const re = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=\s*([^;\n]*(?:\n[^;]*)?);/g;
421
+ let m: RegExpExecArray | null;
422
+ while ((m = re.exec(body)) !== null) {
423
+ const name = m[1] as string;
424
+ if (!out.has(name)) out.set(name, (m[2] as string).replace(/\s+/g, ' '));
425
+ }
426
+ return out;
427
+ }
428
+
429
+ /**
430
+ * Substitute bound identifiers into `expr`, up to {@link CONST_HOPS} hops.
431
+ *
432
+ * Substitution IN PLACE rather than accumulation: an earlier draft appended each
433
+ * binding's value to a growing string and then matched over the whole thing, so
434
+ * one unrelated binding anywhere in the chain contaminated the verdict — it
435
+ * reported `rmSync(scratch)` in a test as a write into `src/`. Callees are not
436
+ * expanded (`join(...)` is a function, not a path), and a binding longer than
437
+ * {@link MAX_BINDING_CHARS} is skipped because an object or an array literal is
438
+ * not a path expression.
439
+ */
440
+ export const MAX_BINDING_CHARS = 200;
441
+
442
+ export function expandExpression(expr: string, bindings: Map<string, string>): string {
443
+ let current = expr;
444
+ for (let hop = 0; hop < CONST_HOPS; hop += 1) {
445
+ const next = substituteOutsideStrings(current, (name, followedByCall) => {
446
+ if (followedByCall) return name;
447
+ const value = bindings.get(name);
448
+ if (value === undefined || value === name || value.length > MAX_BINDING_CHARS) return name;
449
+ return `(${value})`;
450
+ });
451
+ if (next === current) break;
452
+ current = next;
453
+ }
454
+ return current;
455
+ }
456
+
457
+ /**
458
+ * Apply `replace` to identifiers in CODE positions only, never inside a string
459
+ * literal.
460
+ *
461
+ * The second defect this function exists for, and it was found by running the
462
+ * gate rather than by reading it: substituting over the raw text rewrote the
463
+ * identifier `cand` INSIDE the literal `'ac-cand-'`, because `-` is a word
464
+ * boundary. The injected value happened to carry `join(REPO_ROOT, 'src', ...)`,
465
+ * so a `rmSync(scratch)` on a temp directory was reported as a write into the
466
+ * source tree. A substitution that can edit string contents is not resolving a
467
+ * path expression, it is corrupting one.
468
+ */
469
+ export function substituteOutsideStrings(
470
+ source: string,
471
+ replace: (name: string, followedByCall: boolean) => string,
472
+ ): string {
473
+ let out = '';
474
+ let code = '';
475
+ let quote: string | null = null;
476
+ const flush = (): void => {
477
+ out += code.replace(/\b[A-Za-z_$][\w$]*\b/g, (name, offset: number, whole: string) =>
478
+ replace(name, whole[offset + name.length] === '('),
479
+ );
480
+ code = '';
481
+ };
482
+ for (let i = 0; i < source.length; i += 1) {
483
+ const c = source[i] as string;
484
+ if (quote !== null) {
485
+ out += c;
486
+ if (c === '\\') {
487
+ out += source[i + 1] ?? '';
488
+ i += 1;
489
+ } else if (c === quote) {
490
+ quote = null;
491
+ }
492
+ continue;
493
+ }
494
+ if (c === "'" || c === '"' || c === '`') {
495
+ flush();
496
+ quote = c;
497
+ out += c;
498
+ continue;
499
+ }
500
+ code += c;
501
+ }
502
+ flush();
503
+ return out;
504
+ }
505
+
506
+ export interface WriteSite {
507
+ line: number;
508
+ fn: string;
509
+ dest: string;
510
+ targetsSource: boolean;
511
+ }
512
+
513
+ /** Every filesystem-write site in a module, with its destination classified. */
514
+ export function findWriteSites(source: string): WriteSite[] {
515
+ const body = stripComments(source);
516
+ const bindings = constBindings(body);
517
+ const out: WriteSite[] = [];
518
+ const re = new RegExp(`\\b(${Object.keys(WRITE_CALLS).join('|')})\\s*\\(`, 'g');
519
+ let m: RegExpExecArray | null;
520
+ while ((m = re.exec(body)) !== null) {
521
+ const fn = m[1] as string;
522
+ const before = body.slice(Math.max(0, m.index - 40), m.index);
523
+ if (/\bfunction\s*$/.test(before)) continue;
524
+ const args = callArguments(body, body.indexOf('(', m.index));
525
+ if (args === null) continue;
526
+ const destIndex = WRITE_CALLS[fn] as number;
527
+ const dest = (args[destIndex] ?? '').trim();
528
+ if (dest === '') continue;
529
+ out.push({
530
+ line: lineOf(body, m.index),
531
+ fn,
532
+ dest: dest.slice(0, 120),
533
+ targetsSource: targetsCanonicalSource(dest, expandExpression(dest, bindings)),
534
+ });
535
+ }
536
+ return out;
537
+ }
538
+
539
+ // -------------------------------------------------------------------- walk
540
+
541
+ export function listTypeScript(root: string, rel = ''): string[] {
542
+ const abs = path.join(root, rel);
543
+ let entries: fs.Dirent[];
544
+ try {
545
+ entries = fs.readdirSync(abs, { withFileTypes: true });
546
+ } catch {
547
+ return [];
548
+ }
549
+ const out: string[] = [];
550
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
551
+ if (SKIP_DIRS.has(e.name)) continue;
552
+ const child = rel === '' ? e.name : `${rel}/${e.name}`;
553
+ if (e.isDirectory()) out.push(...listTypeScript(root, child));
554
+ else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) out.push(child);
555
+ }
556
+ return out;
557
+ }
558
+
559
+ export interface Evaluation {
560
+ scanned: number;
561
+ candidateModules: string[];
562
+ writeSites: number;
563
+ findings: Finding[];
564
+ capabilityStatus: string;
565
+ }
566
+
567
+ export function evaluate(root: string = REPO_ROOT, ledger?: GateLedger): Evaluation {
568
+ const r12Files = SOURCE_ROOTS.flatMap((r) => listTypeScript(path.join(root, r)).map((f) => `${r}/${f}`));
569
+ const r3Only = R3_EXTRA_ROOTS.flatMap((r) => listTypeScript(path.join(root, r)).map((f) => `${r}/${f}`));
570
+ const all = [...r12Files, ...r3Only];
571
+ ledger?.plan(all);
572
+
573
+ const findings: Finding[] = [];
574
+ const candidateModules: string[] = [];
575
+ let writeSites = 0;
576
+
577
+ for (const rel of all) {
578
+ const source = fs.readFileSync(path.join(root, rel), 'utf-8');
579
+ const hits: Finding[] = [];
580
+ const inR12 = r12Files.includes(rel);
581
+
582
+ if (inR12 && !R1_ALLOWLIST.includes(rel)) {
583
+ hits.push(...findApprovalSites(source).map((f) => ({ ...f, file: rel })));
584
+ }
585
+ if (inR12 && !R2_ALLOWLIST.includes(rel)) {
586
+ hits.push(...findPromotedWriteSites(source).map((f) => ({ ...f, file: rel })));
587
+ }
588
+ if (isCandidateDerived(source)) {
589
+ candidateModules.push(rel);
590
+ const sites = findWriteSites(source);
591
+ writeSites += sites.length;
592
+ if (!R3_ALLOWLIST.includes(rel)) {
593
+ for (const s of sites.filter((x) => x.targetsSource)) {
594
+ hits.push({
595
+ rule: 'R3',
596
+ file: rel,
597
+ line: s.line,
598
+ what: `candidate-derived ${s.fn} into ${REPO_SOURCE_ROOT}/`,
599
+ text: s.dest,
600
+ });
601
+ }
602
+ }
603
+ }
604
+
605
+ findings.push(...hits);
606
+ if (hits.length > 0) ledger?.fail(rel, `${String(hits.length)} promotion-path finding(s)`);
607
+ else ledger?.complete(rel);
608
+ }
609
+
610
+ // R0 — the capability must be unobtainable unless the blocker reads GRANTED.
611
+ // Called, not read: a text match would pass over a refusal edited into a
612
+ // no-op. `resolved` now means `Status: resolved` AND `Disposition: granted`;
613
+ // a blocker closed as `refused`, or closed without saying which, is a
614
+ // refusing status here exactly like an open one.
615
+ const status = readMergeAuthorityStatus(root);
616
+ if (isRefusingStatus(status)) {
617
+ let refused = false;
618
+ try {
619
+ acquirePromotionCapability({ approver: 'gate probe', approvedAt: '1970-01-01' }, root);
620
+ } catch (e) {
621
+ refused = e instanceof PromotionCapabilityUnobtainableError;
622
+ }
623
+ if (!refused) {
624
+ findings.push({
625
+ rule: 'R0',
626
+ file: 'src/scripts/_lib/promotion_capability.ts',
627
+ line: 1,
628
+ what: 'capability obtainable while the blocker does not read granted',
629
+ text: `blocker status ${status}, yet acquirePromotionCapability() returned a token`,
630
+ });
631
+ }
632
+ }
633
+
634
+ return { scanned: all.length, candidateModules, writeSites, findings, capabilityStatus: status };
635
+ }
636
+
637
+ // ---------------------------------------------------------------- self-test
638
+
639
+ function selfTestCases(): SelfTestCase[] {
640
+ const plant = (name: string, expect: 'reject' | 'accept', rel: string, body: string): SelfTestCase => ({
641
+ name,
642
+ expect,
643
+ run: () => {
644
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'promo-'));
645
+ try {
646
+ // A minimal but ABOVE-FLOOR tree: the anti-vacuity floors must not
647
+ // be what decides these cases, so the fixture carries enough files,
648
+ // candidate modules and write sites to clear them.
649
+ fs.mkdirSync(path.join(dir, 'src', 'scripts', '_lib'), { recursive: true });
650
+ fs.mkdirSync(path.join(dir, 'tests'), { recursive: true });
651
+ fs.mkdirSync(path.join(dir, 'agents', 'roadmaps'), { recursive: true });
652
+ fs.writeFileSync(
653
+ path.join(dir, 'agents', 'roadmaps', 'road-to-harness-promotion-bridge.md'),
654
+ '### blocker: merge-authority\n\n- **Status:** open\n',
655
+ );
656
+ for (let i = 0; i < MIN_FILES + 5; i += 1) {
657
+ fs.writeFileSync(path.join(dir, 'src', 'scripts', `filler_${String(i)}.ts`), 'export const x = 1;\n');
658
+ }
659
+ for (let i = 0; i < MIN_CANDIDATE_MODULES + 1; i += 1) {
660
+ fs.writeFileSync(
661
+ path.join(dir, 'src', 'scripts', `cand_${String(i)}.ts`),
662
+ "import { parseCandidateRecord } from './_lib/candidate_record.js';\n" +
663
+ 'const out = "clones/x";\n' +
664
+ 'fs.writeFileSync(out, parseCandidateRecord);\n' +
665
+ 'fs.mkdirSync(out);\nfs.rmSync(out);\n',
666
+ );
667
+ }
668
+ fs.writeFileSync(path.join(dir, rel), body);
669
+ return runGateCli(REPO_ROOT, 'src/scripts/lint_promotion_paths.ts', ['--root', dir, '--quiet'], REPO_ROOT);
670
+ } finally {
671
+ fs.rmSync(dir, { recursive: true, force: true });
672
+ }
673
+ },
674
+ });
675
+ const CAND = "import { parseCandidateRecord } from './_lib/candidate_record.js';\n";
676
+ return [
677
+ plant('a synthesised approver → reject', 'reject', 'src/scripts/rogue.ts',
678
+ "assertTransition(from, 'promoted', { approver: 'ci', approvedAt: '2026-01-01' });\n"),
679
+ plant('a HumanApproval reference outside the allowlist → reject', 'reject', 'src/scripts/rogue.ts',
680
+ 'const a: HumanApproval = load();\n'),
681
+ plant('a three-argument assertTransition → reject', 'reject', 'src/scripts/rogue.ts',
682
+ 'assertTransition(a, b, c);\n'),
683
+ plant('a record written straight into the promoted state → reject', 'reject', 'src/scripts/rogue.ts',
684
+ "const r = { kind: 'candidate', lifecycle: 'promoted' };\n"),
685
+ plant('a promoted state via the accepted-state constant → reject', 'reject', 'src/scripts/rogue.ts',
686
+ 'const r = { lifecycle: ACCEPTED_STATE };\n'),
687
+ plant('a candidate-derived write into src/ → reject', 'reject', 'src/scripts/rogue.ts',
688
+ `${CAND}fs.writeFileSync(path.join(root, 'src', 'rules', 'x.md'), parseCandidateRecord);\n`),
689
+ plant('a candidate-derived copy INTO src/, second argument → reject', 'reject', 'src/scripts/rogue.ts',
690
+ `${CAND}fs.copyFileSync(from, 'src/skills/x/SKILL.md');\n`),
691
+ plant('a candidate-derived write behind a const hop → reject', 'reject', 'src/scripts/rogue.ts',
692
+ `${CAND}const dest = path.join(root, 'src', 'rules');\nfs.writeFileSync(dest, body);\n`),
693
+ plant('a TEST writing into src/ from candidate data → reject', 'reject', 'tests/rogue.test.ts',
694
+ `${CAND}fs.writeFileSync('src/rules/x.md', body);\n`),
695
+ // Accepting poles — each pins a direction a widened pattern would break.
696
+ plant('the interface field declaration is not a construction → accept', 'accept', 'src/scripts/ok.ts',
697
+ 'interface A { readonly approver: string; readonly approvedAt: string; }\n'),
698
+ plant('a two-argument assertTransition → accept', 'accept', 'src/scripts/ok.ts',
699
+ "assertTransition(from, 'promoted');\n"),
700
+ plant('a candidate-derived write to a clone directory → accept', 'accept', 'src/scripts/ok.ts',
701
+ `${CAND}const dest = path.join(CLONES, 'candidate-1');\nfs.writeFileSync(dest, body);\n`),
702
+ plant('a candidate-derived READ of src/ → accept', 'accept', 'src/scripts/ok.ts',
703
+ `${CAND}const t = fs.readFileSync(path.join(root, 'src', 'x.ts'));\n`),
704
+ plant('prose in a block comment naming the constructs → accept', 'accept', 'src/scripts/ok.ts',
705
+ '/* never write approver: or lifecycle: "promoted" or HumanApproval here */\nexport const x = 1;\n'),
706
+ plant('a non-candidate module writing into src/ → accept', 'accept', 'src/scripts/ok.ts',
707
+ "fs.writeFileSync(path.join(root, 'src', 'generated.ts'), body);\n"),
708
+ ];
709
+ }
710
+
711
+ // --------------------------------------------------------------------- CLI
712
+
713
+ export function main(argv: string[] = process.argv.slice(2)): number {
714
+ if (argv.includes('--self-test')) {
715
+ return runSelfTest({ gate: GATE, cases: selfTestCases(), minCases: 14, minRejectCases: 9 });
716
+ }
717
+ const quiet = argv.includes('--quiet');
718
+ const ri = argv.indexOf('--root');
719
+ const rootArg = ri !== -1 ? argv[ri + 1] : undefined;
720
+ const root = rootArg !== undefined ? path.resolve(rootArg) : REPO_ROOT;
721
+
722
+ const ledger = new GateLedger(GATE);
723
+ let v: Evaluation;
724
+ try {
725
+ v = evaluate(root, ledger);
726
+ } catch (err) {
727
+ process.stderr.write(`❌ ${GATE}: ${(err as Error).message}\n`);
728
+ return 2;
729
+ }
730
+
731
+ try {
732
+ reportScanned({
733
+ gate: GATE,
734
+ scanned: v.scanned,
735
+ units: 'TypeScript file(s)',
736
+ roots: [...SOURCE_ROOTS, ...R3_EXTRA_ROOTS],
737
+ });
738
+ // The anti-vacuity floors. The condition this gate discharges says in as
739
+ // many words that a check over a population of zero discharges nothing,
740
+ // so a collapsed population is a dead scope rather than a clean run.
741
+ assertScanned({ gate: `${GATE}/files`, scanned: v.scanned >= MIN_FILES ? v.scanned : 0, units: `TypeScript file(s) (floor ${String(MIN_FILES)})`, roots: [...SOURCE_ROOTS, ...R3_EXTRA_ROOTS] });
742
+ assertScanned({ gate: `${GATE}/candidate-modules`, scanned: v.candidateModules.length >= MIN_CANDIDATE_MODULES ? v.candidateModules.length : 0, units: `candidate-derived module(s) (floor ${String(MIN_CANDIDATE_MODULES)})`, roots: [...SOURCE_ROOTS, ...R3_EXTRA_ROOTS] });
743
+ assertScanned({ gate: `${GATE}/write-sites`, scanned: v.writeSites >= MIN_WRITE_SITES ? v.writeSites : 0, units: `filesystem-write site(s) in candidate-derived modules (floor ${String(MIN_WRITE_SITES)})`, roots: v.candidateModules });
744
+ } catch (err) {
745
+ if (err instanceof DeadScopeError) {
746
+ process.stderr.write(`❌ ${GATE}: ${err.message}\n`);
747
+ return 2;
748
+ }
749
+ throw err;
750
+ }
751
+ ledger.report();
752
+
753
+ if (v.findings.length > 0) {
754
+ for (const f of v.findings) {
755
+ process.stderr.write(`❌ ${f.file}:${String(f.line)} ${f.rule} ${f.what}\n ${f.text}\n`);
756
+ }
757
+ process.stderr.write(
758
+ `\n ${String(v.findings.length)} promotion-capable write(s) bypass the guarded capability\n` +
759
+ ' (src/scripts/_lib/promotion_capability.ts). Promotion into canonical agent-config\n' +
760
+ ' is gated on blocker merge-authority (ADR-239 Decision 3), which is owner-reserved.\n' +
761
+ ' Route the write through acquirePromotionCapability, or do not perform it.\n',
762
+ );
763
+ return 1;
764
+ }
765
+ if (!quiet) {
766
+ process.stdout.write(
767
+ `✅ ${GATE}: no promotion path bypasses the guarded capability ` +
768
+ `(${String(v.scanned)} file(s), ${String(v.candidateModules.length)} candidate-derived ` +
769
+ `module(s), ${String(v.writeSites)} write site(s); blocker status: ${v.capabilityStatus}).\n`,
770
+ );
771
+ }
772
+ return 0;
773
+ }
774
+
775
+ if (process.env['GATE_SELF_TEST_CHILD'] !== '1' || process.argv.includes('--root')) {
776
+ if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) {
777
+ process.exit(main());
778
+ }
779
+ }