@event4u/agent-config 9.22.0 → 9.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 (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +69 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/optimize/deep.md +13 -0
  5. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  6. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  7. package/dist/agent-src/rules/context-hygiene.md +17 -0
  8. package/dist/agent-src/rules/downstream-changes.md +26 -0
  9. package/dist/agent-src/rules/think-before-action.md +25 -0
  10. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  11. package/dist/agent-src/rules/token-efficiency.md +13 -0
  12. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  13. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  14. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  15. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  16. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  18. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  19. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  20. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/cli/registry.js +1 -0
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  25. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  26. package/dist/cli-delegate/cmd_export.js +2 -1
  27. package/dist/cli-delegate/cmd_prune.js +2 -1
  28. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  29. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  30. package/dist/cli-delegate/cmd_sync.js +2 -1
  31. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  32. package/dist/cli-delegate/cmd_validate.js +2 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +17 -17
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +1 -1
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +2 -2
  39. package/dist/discovery/trust-report.md +1 -1
  40. package/dist/discovery/workspaces.json +2 -2
  41. package/dist/hooks/dispatch.js +79 -1
  42. package/dist/mcp/registry-manifest.json +2 -2
  43. package/dist/mcp/server.json +2 -2
  44. package/dist/scripts/condense.js +47 -2
  45. package/dist/scripts/condense.js.map +1 -1
  46. package/dist/server/routes/settings.js +82 -0
  47. package/dist/server/routes/settings.js.map +1 -1
  48. package/dist/shared/settingsClasses.js +191 -0
  49. package/dist/shared/settingsClasses.js.map +1 -0
  50. package/docs/contracts/settings-classes.md +342 -0
  51. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  52. package/docs/guidelines/agent-infra/false-green.md +187 -0
  53. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  54. package/package.json +3 -1
  55. package/src/config/ci-local-parity.yml +12 -0
  56. package/src/config/evaluator-budgets.json +4 -4
  57. package/src/config/gate-coverage.yml +54 -12
  58. package/src/config/gate-violation-baselines.json +6 -0
  59. package/src/config/pack-size-budget.json +4 -3
  60. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  61. package/src/scripts/_dispatch.bash +16 -0
  62. package/src/scripts/_lib/gate_ledger.ts +295 -0
  63. package/src/scripts/_lib/gate_result.ts +101 -0
  64. package/src/scripts/_lib/gate_self_test.ts +129 -0
  65. package/src/scripts/_lib/measured_render.ts +97 -0
  66. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  67. package/src/scripts/check_gate_completeness.ts +244 -0
  68. package/src/scripts/check_gate_coverage.ts +35 -6
  69. package/src/scripts/check_references.ts +125 -15
  70. package/src/scripts/check_review_dispositions.ts +14 -1
  71. package/src/scripts/check_suppression_hygiene.ts +503 -0
  72. package/src/scripts/condense.ts +47 -2
  73. package/src/scripts/hook_manifest.yaml +23 -0
  74. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  75. package/src/scripts/hooks/host_semantics.ts +140 -0
  76. package/src/scripts/lint_framework_leakage.ts +147 -15
  77. package/src/scripts/lint_mandated_lines.ts +339 -0
  78. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  79. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  80. package/src/scripts/lint_settings_classes.ts +397 -0
  81. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  82. package/src/scripts/report_imperative_density.ts +229 -0
  83. package/src/scripts/verify_physical_move.ts +10 -1
  84. package/src/server/io/yamlIO.ts +173 -0
  85. package/src/shared/settingsClasses.ts +235 -0
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * lint_mandated_lines.ts — the deterministic half of the mandated-line contract.
4
+ *
5
+ * Contract: `src/agent-src/contexts/execution/mandated-lines.md`. Given a run
6
+ * report on stdin (or `--file`), assert the two checkable obligations:
7
+ *
8
+ * 1. A report claiming a **behaviour change** carries an **Intent** line.
9
+ * 2. A report describing an **outward action** carries an **Authorization** line.
10
+ *
11
+ * Documented invocation, mirroring the sibling reply checker:
12
+ *
13
+ * ./scripts-run src/scripts/lint_mandated_lines --stdin < report.md
14
+ *
15
+ * **Not wired into the `ci` corpus sweep, deliberately.** There is no standing
16
+ * tree of run reports to scan, and a gate pointed at a directory that does not
17
+ * accumulate is the dead-scan-root shape this repository has already paid for
18
+ * four times. It is a callable checker, exactly like
19
+ * `check_reply_consistency --stdin`, and its discrimination is proven by a test
20
+ * rather than by a corpus.
21
+ *
22
+ * **What it cannot check, stated rather than discovered later.** It reads a
23
+ * report, so it can see whether a line is present and well-formed. It cannot
24
+ * see whether the line was emitted *before* the action or reconstructed after,
25
+ * and it cannot see whether the three intent slots were filled honestly. Those
26
+ * are model-carried — the contract says so in the same words.
27
+ *
28
+ * **Gaming risk.** The cheap degenerate pass is a report that never uses the
29
+ * trigger vocabulary: describe the edit without the word "fix", the push
30
+ * without the word "push", and both obligations evaporate because nothing was
31
+ * detected to owe them. Mitigation: the trigger sets below are deliberately
32
+ * broad and include the past-tense forms a completion claim actually uses, and
33
+ * a report that trips NO trigger is reported as `no obligations detected`
34
+ * rather than as a pass — the two read differently on purpose. Residual: a
35
+ * sufficiently vague report still owes nothing, and no lexical check can fix
36
+ * that. The line's real enforcement is that the model emits it at the decision
37
+ * point; this is the backstop for the case where it did not.
38
+ *
39
+ * Exit: 0 clean · 1 usage/IO error · 2 findings.
40
+ */
41
+
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
45
+
46
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
47
+
48
+ const _HERE = fileURLToPath(import.meta.url);
49
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
50
+
51
+ /**
52
+ * A report claims a behaviour change. Past tense included on purpose: a
53
+ * completion claim says "fixed", not "will fix".
54
+ */
55
+ export const BEHAVIOUR_CHANGE_TRIGGERS: readonly RegExp[] = [
56
+ /\b(fixed|fixes|fixing|a fix)\b/i,
57
+ /\b(changed|changes|changing) the behaviou?r\b/i,
58
+ /\b(implemented|implements|implementing)\b/i,
59
+ /\b(patched|patches|patching)\b/i,
60
+ /\b(refactored|refactors|refactoring)\b/i,
61
+ /\b(corrected|corrects|correcting)\b/i,
62
+ /\bbehaviou?r[- ]changing\b/i,
63
+ ];
64
+
65
+ /**
66
+ * A report describes an irreversible outward action — the
67
+ * `non-destructive-by-default` Hard-Floor set.
68
+ */
69
+ export const OUTWARD_ACTION_TRIGGERS: readonly RegExp[] = [
70
+ /\b(pushed|pushing|push(?:ed)? to (?:remote|origin|main))\b/i,
71
+ /\b(deployed|deploying|deploy(?:ed)? to prod)\b/i,
72
+ /\b(published|publishing)\b/i,
73
+ /\b(merged|merging) (?:into |to )?(?:main|master|prod)/i,
74
+ /\b(opened|created) (?:the |a )?(?:pull request|PR)\b/i,
75
+ /\b(sent|sending) (?:the |an )?(?:email|message)\b/i,
76
+ /\b(posted|posting) (?:the |a )?comment\b/i,
77
+ /\b(purchased|submitted|ordered)\b/i,
78
+ ];
79
+
80
+ /** `Intent: … · … · …` — three slots separated by the middle dot. Global: EVERY line is validated. */
81
+ export const INTENT_RE = /^[ \t]*(?:>[ \t]*)?Intent(?:[ \t]*\([^)]*\))?:[ \t]*(.+)$/gim;
82
+
83
+ /** `Authorization: "…"` — carrying the user's own words. */
84
+ export const AUTHORIZATION_RE = /^[ \t]*(?:>[ \t]*)?Authorization:[ \t]*(.+)$/gim;
85
+
86
+ /**
87
+ * A PAIRED quotation, not a stray quote character.
88
+ *
89
+ * The first version tested for any character in `["“”'']` and got both
90
+ * directions wrong: `the user's roadmap step says to push` passed on the
91
+ * possessive apostrophe — a paraphrase, and literally the
92
+ * documentation-is-not-authorization case the contract denies — while
93
+ * `‘push it and open the PR’` failed, because the typographic single quotes
94
+ * were not in the class at all. What the line must carry is a span somebody
95
+ * actually said, so the test is for an opening mark with a closing partner.
96
+ */
97
+ const QUOTED_SPAN_RE = /"[^"]+"|“[^”]*”|‘[^’]*’|'[^']{2,}'|«[^»]+»|„[^“”]+[“”]/;
98
+
99
+ /**
100
+ * Drop fenced blocks before matching.
101
+ *
102
+ * Without this, a report that QUOTES the contract's own merged-block example —
103
+ * which contains a well-formed Intent and Commit line — satisfies both
104
+ * obligations while having emitted neither. The contract's § Brevity
105
+ * illustration was, verbatim, a working bypass.
106
+ */
107
+ export function stripFences(text: string): string {
108
+ const out: string[] = [];
109
+ let fence: string | null = null;
110
+ for (const line of text.split('\n')) {
111
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(line);
112
+ if (m !== null) {
113
+ const marker = (m[1] as string)[0] as string;
114
+ if (fence === null) fence = marker;
115
+ else if (fence === marker) fence = null;
116
+ out.push('');
117
+ continue;
118
+ }
119
+ out.push(fence === null ? line : '');
120
+ }
121
+ return out.join('\n');
122
+ }
123
+
124
+ /**
125
+ * Join a wrapped continuation onto its label line.
126
+ *
127
+ * The contract's own canonical Intent example wraps across two blockquote
128
+ * lines, and a one-physical-line capture read it as two slots — the document's
129
+ * model-correct artifact failed its own checker. A following line that does not
130
+ * start a new label and is not blank belongs to the line above it.
131
+ */
132
+ export function unwrapLines(text: string): string {
133
+ const lines = text.split('\n');
134
+ const out: string[] = [];
135
+ for (const raw of lines) {
136
+ const isLabel = /^[ \t]*(?:>[ \t]*)?(?:Intent|Authorization|Commit|Pending|Sibling search)\b/i.test(raw);
137
+ const isBlank = raw.trim() === '';
138
+ const prev = out[out.length - 1];
139
+ if (!isLabel && !isBlank && prev !== undefined && prev.trim() !== '' &&
140
+ /^[ \t]*(?:>[ \t]*)?(?:Intent|Authorization|Commit|Pending|Sibling search)\b/i.test(prev)) {
141
+ out[out.length - 1] = `${prev.trimEnd()} ${raw.trim()}`;
142
+ continue;
143
+ }
144
+ out.push(raw);
145
+ }
146
+ return out.join('\n');
147
+ }
148
+
149
+ /**
150
+ * Sentences that DENY the action rather than claiming it.
151
+ *
152
+ * Narrow on purpose. A trigger word inside "no code changed" or "nothing was
153
+ * implemented" is a report saying the opposite of what the trigger detects, and
154
+ * charging it an obligation trains the reader to ignore the checker. This
155
+ * cannot be complete — negation is not a lexical property — and the residual is
156
+ * stated in the header rather than hidden.
157
+ */
158
+ const DENIAL_RE = /\b(no|nothing|not|never|without)\b[^.]{0,40}$/i;
159
+
160
+ /** The slot separator the contract specifies. */
161
+ const SLOT_SEPARATOR = '·';
162
+
163
+ export interface Finding {
164
+ code: 'missing-intent' | 'missing-authorization' | 'intent-slots' | 'authorization-quote';
165
+ message: string;
166
+ }
167
+
168
+ export interface Verdict {
169
+ findings: Finding[];
170
+ /** True when no trigger fired at all — reported distinctly from a pass. */
171
+ noObligations: boolean;
172
+ owed: string[];
173
+ }
174
+
175
+ /**
176
+ * Does any trigger fire on a sentence that is not denying it?
177
+ *
178
+ * Sentence-scoped so a denial in one sentence does not excuse a claim in the
179
+ * next: "No code changed. Pushed the doc fix." still owes the authorization
180
+ * line.
181
+ */
182
+ function _fires(text: string, triggers: readonly RegExp[]): boolean {
183
+ for (const sentence of text.split(/(?<=[.!?\n])/)) {
184
+ if (!triggers.some((re) => re.test(sentence))) continue;
185
+ const before = sentence.slice(0, sentence.search(triggers.find((re) => re.test(sentence)) as RegExp));
186
+ if (DENIAL_RE.test(before)) continue;
187
+ return true;
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Check one report.
194
+ *
195
+ * Separated from the CLI so the discrimination cases are unit-testable without
196
+ * spawning a process — the boundary cases here are textual, and a fixture file
197
+ * per case would make them harder to read, not easier.
198
+ */
199
+ export function checkReport(text: string): Verdict {
200
+ const findings: Finding[] = [];
201
+ const owed: string[] = [];
202
+
203
+ // Fenced blocks are illustrations, not claims — and unwrapping puts a
204
+ // soft-wrapped continuation back on its label line before anything counts
205
+ // slots.
206
+ const prepared = unwrapLines(stripFences(text));
207
+
208
+ const claimsChange = _fires(prepared, BEHAVIOUR_CHANGE_TRIGGERS);
209
+ const claimsOutward = _fires(prepared, OUTWARD_ACTION_TRIGGERS);
210
+
211
+ if (claimsChange) {
212
+ owed.push('intent');
213
+ INTENT_RE.lastIndex = 0;
214
+ const matches = [...prepared.matchAll(INTENT_RE)];
215
+ if (matches.length === 0) {
216
+ findings.push({
217
+ code: 'missing-intent',
218
+ message:
219
+ 'the report claims a behaviour change and carries no Intent line. ' +
220
+ 'Three slots: what the code does · what the failing check expects · what the spec says.',
221
+ });
222
+ } else {
223
+ // EVERY intent line is validated. Checking only the first made the
224
+ // verdict depend on line order, so one well-formed line satisfied a
225
+ // report claiming any number of behaviour changes.
226
+ for (const [i, m] of matches.entries()) {
227
+ const slots = (m[1] ?? '').split(SLOT_SEPARATOR).map((s) => s.trim()).filter((s) => s !== '');
228
+ if (slots.length < 3) {
229
+ findings.push({
230
+ code: 'intent-slots',
231
+ message:
232
+ `Intent line ${String(i + 1)} of ${String(matches.length)} has ${String(slots.length)} slot(s), not 3. ` +
233
+ 'The three slots are the mechanism: when they disagree, the disagreement is the finding ' +
234
+ `and the edit does not proceed. Separate them with '${SLOT_SEPARATOR}'.`,
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ if (claimsOutward) {
242
+ owed.push('authorization');
243
+ AUTHORIZATION_RE.lastIndex = 0;
244
+ const m = AUTHORIZATION_RE.exec(prepared);
245
+ if (m === null) {
246
+ findings.push({
247
+ code: 'missing-authorization',
248
+ message:
249
+ 'the report describes an irreversible outward action and carries no Authorization line. ' +
250
+ 'Documentation is not authorization; completing the task is not authorization.',
251
+ });
252
+ } else if (!QUOTED_SPAN_RE.test(m[1] ?? '')) {
253
+ findings.push({
254
+ code: 'authorization-quote',
255
+ message:
256
+ 'the Authorization line quotes nothing. It carries the USER\'S OWN WORDS — a paraphrase ' +
257
+ 'is the agent restating its own conclusion, which is what the line exists to prevent.',
258
+ });
259
+ }
260
+ }
261
+
262
+ return { findings, noObligations: !claimsChange && !claimsOutward, owed };
263
+ }
264
+
265
+ function _readInput(argv: readonly string[]): { text: string } | { error: string } {
266
+ const fileIdx = argv.indexOf('--file');
267
+ if (fileIdx !== -1) {
268
+ const p = argv[fileIdx + 1];
269
+ if (p === undefined) {
270
+ return { error: '--file needs a path' };
271
+ }
272
+ try {
273
+ return { text: fs.readFileSync(path.resolve(p), 'utf-8') };
274
+ } catch (e) {
275
+ return { error: `cannot read ${p}: ${String(e)}` };
276
+ }
277
+ }
278
+ if (argv.includes('--stdin')) {
279
+ try {
280
+ return { text: fs.readFileSync(0, 'utf-8') };
281
+ } catch (e) {
282
+ return { error: `cannot read stdin: ${String(e)}` };
283
+ }
284
+ }
285
+ return { error: 'usage: lint_mandated_lines --stdin | --file <report.md> [--quiet]' };
286
+ }
287
+
288
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
289
+ const quiet = argv.includes('--quiet');
290
+ const input = _readInput(argv);
291
+ if ('error' in input) {
292
+ process.stderr.write(`${input.error}\n`);
293
+ return 1;
294
+ }
295
+
296
+ // Scope assertion. An EMPTY report used to print "no obligations detected"
297
+ // and exit 0 — a piped-nothing reading as compliance, which is the
298
+ // false-green shape this repository has paid for four times. The unit is
299
+ // content lines, and zero of them is a dead scope rather than a clean run.
300
+ const contentLines = input.text.split('\n').filter((l) => l.trim() !== '').length;
301
+ try {
302
+ assertScanned({ gate: 'lint_mandated_lines', scanned: contentLines, units: 'report line(s)', roots: ['<stdin>'] });
303
+ } catch (e) {
304
+ if (e instanceof DeadScopeError) {
305
+ process.stderr.write(`❌ ${e.message}\n`);
306
+ return 1;
307
+ }
308
+ throw e;
309
+ }
310
+
311
+ const verdict = checkReport(input.text);
312
+ for (const f of verdict.findings) {
313
+ process.stderr.write(`❌ ${f.code} ${f.message}\n`);
314
+ }
315
+ if (verdict.findings.length > 0) {
316
+ process.stderr.write(`\n❌ lint_mandated_lines: ${String(verdict.findings.length)} finding(s)\n`);
317
+ return 2;
318
+ }
319
+ if (!quiet) {
320
+ if (verdict.noObligations) {
321
+ // Deliberately not "clean". A report that triggered nothing has not
322
+ // been shown to comply; it has been shown to owe nothing, and the
323
+ // two are different facts.
324
+ process.stdout.write('✅ lint_mandated_lines: no obligations detected in this report\n');
325
+ } else {
326
+ process.stdout.write(
327
+ `✅ lint_mandated_lines: ${String(verdict.owed.length)} obligation(s) owed and present — ` +
328
+ `${verdict.owed.join(', ')}\n`,
329
+ );
330
+ }
331
+ }
332
+ return 0;
333
+ }
334
+
335
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
336
+ process.exit(main());
337
+ }
338
+
339
+ export { REPO_ROOT };
@@ -16,16 +16,33 @@
16
16
  */
17
17
 
18
18
  import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
19
20
  import * as path from 'node:path';
20
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
22
 
22
23
  import { project_settings_path } from './_lib/agent_settings.js';
24
+ import { GateLedger } from './_lib/gate_ledger.js';
25
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
23
26
  import { granularity_problems } from './_lib/roadmap_granularity.js';
24
27
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
25
28
 
26
29
  /** Mirror `QUIET = "--quiet" in sys.argv` (computed at import). */
27
30
  const QUIET = process.argv.slice(2).includes('--quiet');
28
31
 
32
+ /**
33
+ * Optional roadmap-tree root, additive to the pinned CLI contract.
34
+ *
35
+ * Needed because this gate resolves its root from its own file location, not
36
+ * from the working directory — so nothing could exercise the real binary
37
+ * against a fixture tree, and the two rejection paths (untagged roadmap, dead
38
+ * scope) had no end-to-end proof at all. Absent, behaviour is unchanged.
39
+ */
40
+ function _rootOverride(argv: readonly string[]): string | null {
41
+ const i = argv.indexOf('--root');
42
+ const value = i === -1 ? undefined : argv[i + 1];
43
+ return value === undefined ? null : path.resolve(value);
44
+ }
45
+
29
46
  const _HERE = path.resolve(fileURLToPath(import.meta.url));
30
47
  // REPO_ROOT = Path(__file__).resolve().parent.parent.parent
31
48
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -388,11 +405,61 @@ function _check_execution_mode(fm: string, problems: string[]): void {
388
405
  }
389
406
  }
390
407
 
408
+ /**
409
+ * Prove, against the real CLI, that this gate still rejects what it must.
410
+ *
411
+ * The empty-tree case is the one that would otherwise rot silently: this gate
412
+ * legitimately reaches zero ACTIVE roadmaps, so only the whole-tree assertion
413
+ * separates "everything is archived" from "the root moved".
414
+ */
415
+ export function selfTest(): number {
416
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rmc-selftest-'));
417
+ const seed = (name: string, body: string | null): string => {
418
+ const root = path.join(tmp, name);
419
+ fs.mkdirSync(path.join(root, 'agents', 'roadmaps'), { recursive: true });
420
+ if (body !== null) {
421
+ fs.writeFileSync(path.join(root, 'agents', 'roadmaps', 'demo.md'), body, 'utf-8');
422
+ }
423
+ return root;
424
+ };
425
+ const run = (cwd: string): number =>
426
+ runGateCli(REPO_ROOT, 'src/scripts/lint_roadmap_complexity.ts', ['--quiet', '--root', cwd], REPO_ROOT);
427
+
428
+ const clean = '---\ncomplexity: lightweight\n---\n\n# Demo\n\n## Phase 1\n\n- [ ] **Step 1:** do the thing.\n';
429
+ try {
430
+ return runSelfTest({
431
+ gate: 'lint_roadmap_complexity',
432
+ minCases: 3,
433
+ minRejectCases: 2,
434
+ cases: [
435
+ {
436
+ name: 'a roadmap without a `complexity:` tag is rejected',
437
+ expect: 'reject',
438
+ run: () => run(seed('untagged', '# Demo\n\n## Phase 1\n\n- [ ] **Step 1:** do the thing.\n')),
439
+ },
440
+ { name: 'a tagged roadmap passes', expect: 'accept', run: () => run(seed('clean', clean)) },
441
+ {
442
+ name: 'an EMPTY roadmap tree is rejected — dead scope, not "all archived"',
443
+ expect: 'reject',
444
+ run: () => run(seed('empty', null)),
445
+ },
446
+ ],
447
+ });
448
+ } finally {
449
+ fs.rmSync(tmp, { recursive: true, force: true });
450
+ }
451
+ }
452
+
391
453
  function main(): number {
454
+ if (process.argv.slice(2).includes('--self-test')) {
455
+ return selfTest();
456
+ }
457
+ const root = _rootOverride(process.argv.slice(2)) ?? REPO_ROOT;
458
+ const tree = _listRoadmapTree(path.join(root, 'agents', 'roadmaps'));
392
459
  try {
393
460
  assertScanned({
394
461
  gate: 'lint_roadmap_complexity',
395
- scanned: _countRoadmapTree(path.join(REPO_ROOT, 'agents', 'roadmaps')),
462
+ scanned: tree.length,
396
463
  units: 'roadmap file(s)',
397
464
  roots: ['agents/roadmaps'],
398
465
  });
@@ -404,18 +471,32 @@ function main(): number {
404
471
  }
405
472
  throw e;
406
473
  }
407
- const roadmaps = _globRoadmaps();
474
+ const roadmaps = _globRoadmaps(root);
408
475
  const horizon_weeks = _read_horizon_weeks();
476
+ // The ledger plans the WHOLE tree, not just the active glob. The gap
477
+ // between the asserted population and the judged one used to be invisible:
478
+ // the assertion counted `archive/` + `later/` + `skipped/`, the loop judged
479
+ // only the top level, and nothing said so. Now every disposed roadmap is an
480
+ // explicit `out_of_scope`, so the denominator on the green line is honest.
481
+ const ledger = new GateLedger('lint_roadmap_complexity');
482
+ ledger.plan(tree.map((p) => _relPosix(p, root)));
483
+ const active = new Set(roadmaps.map((p) => _relPosix(p, root)));
484
+ for (const rel of tree.map((p) => _relPosix(p, root))) {
485
+ if (!active.has(rel)) {
486
+ ledger.outOfScope(rel, 'excluded_directory');
487
+ }
488
+ }
409
489
  if (roadmaps.length === 0) {
410
490
  if (!QUIET) {
411
491
  process.stdout.write(`✅ no active roadmaps under ${ROADMAP_GLOB} — nothing to lint\n`);
412
492
  }
493
+ ledger.report();
413
494
  return 0;
414
495
  }
415
496
  let failed = 0;
416
497
  const summary: Array<[string, string]> = [];
417
498
  for (const roadmap of roadmaps) {
418
- const rel = _relPosix(roadmap, REPO_ROOT);
499
+ const rel = _relPosix(roadmap, root);
419
500
  const warnings: string[] = [];
420
501
  const problems = lint_roadmap(roadmap, horizon_weeks, warnings);
421
502
  for (const w of warnings) {
@@ -426,11 +507,13 @@ function main(): number {
426
507
  summary.push([rel, complexity]);
427
508
  if (problems.length > 0) {
428
509
  failed++;
510
+ ledger.fail(rel, problems[0] ?? 'complexity lint failed');
429
511
  process.stderr.write(`❌ ${rel} [${complexity}]\n`);
430
512
  for (const pr of problems) {
431
513
  process.stderr.write(` - ${pr}\n`);
432
514
  }
433
515
  } else {
516
+ ledger.complete(rel);
434
517
  if (!QUIET) {
435
518
  process.stdout.write(`✅ ${rel} [${complexity}]\n`);
436
519
  }
@@ -444,6 +527,9 @@ function main(): number {
444
527
  `summary: ${light} lightweight · ${structural} structural · ` +
445
528
  `${untagged} untagged · ${summary.length} total\n`,
446
529
  );
530
+ // Unconditional: the completeness line is printed on the red path too, so a
531
+ // failing run still says how much of the corpus it actually judged.
532
+ ledger.report();
447
533
  if (failed) {
448
534
  process.stderr.write(`\n❌ ${failed} roadmap(s) failed complexity lint\n`);
449
535
  return 1;
@@ -477,8 +563,8 @@ function _countChar(text: string, ch: string, start = 0, end?: number): number {
477
563
  }
478
564
 
479
565
  /** Sorted `agents/roadmaps/*.md` (non-recursive — mirrors glob, not rglob). */
480
- function _globRoadmaps(): string[] {
481
- const dir = path.join(REPO_ROOT, 'agents', 'roadmaps');
566
+ function _globRoadmaps(root: string = REPO_ROOT): string[] {
567
+ const dir = path.join(root, 'agents', 'roadmaps');
482
568
  let entries: fs.Dirent[];
483
569
  try {
484
570
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -505,23 +591,28 @@ function _relPosix(target: string, root: string): string {
505
591
  * The active glob is the subset this gate judges, and it reaches zero
506
592
  * legitimately once everything is archived, so it cannot double as the scope
507
593
  * assertion. The whole tree reaching zero has only one cause: the root moved.
594
+ *
595
+ * Returns the paths rather than a bare count so the ledger can account for the
596
+ * difference between the two populations explicitly: a file under `archive/`
597
+ * is not unchecked-by-accident, it is out of scope for a named reason, and the
598
+ * ledger says which.
508
599
  */
509
- function _countRoadmapTree(dir: string): number {
600
+ function _listRoadmapTree(dir: string): string[] {
510
601
  let entries: fs.Dirent[];
511
602
  try {
512
603
  entries = fs.readdirSync(dir, { withFileTypes: true });
513
604
  } catch {
514
- return 0;
605
+ return [];
515
606
  }
516
- let n = 0;
607
+ const out: string[] = [];
517
608
  for (const entry of entries) {
518
609
  if (entry.isDirectory()) {
519
- n += _countRoadmapTree(path.join(dir, entry.name));
610
+ out.push(..._listRoadmapTree(path.join(dir, entry.name)));
520
611
  } else if (entry.name.endsWith('.md')) {
521
- n += 1;
612
+ out.push(path.join(dir, entry.name));
522
613
  }
523
614
  }
524
- return n;
615
+ return out.sort();
525
616
  }
526
617
 
527
618
  function _isCliEntry(): boolean {