@event4u/agent-config 9.24.0 → 9.26.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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +136 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +33 -2
  6. package/dist/agent-src/commands/review/changes.md +6 -5
  7. package/dist/agent-src/commands/roadmap/next.md +194 -0
  8. package/dist/agent-src/commands/roadmap.md +12 -4
  9. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/delegation-policy.md +3 -2
  12. package/dist/agent-src/rules/design-fidelity.md +17 -1
  13. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  14. package/dist/agent-src/rules/evaluator-independence.md +111 -0
  15. package/dist/agent-src/rules/session-canary.md +15 -1
  16. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  17. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  18. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  19. package/dist/agent-src/rules/user-interaction.md +23 -0
  20. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  21. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli/registry.js +1 -0
  26. package/dist/cli/registry.js.map +1 -1
  27. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  28. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +146 -28
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +5 -5
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +16 -11
  35. package/dist/discovery/trust-report.md +3 -3
  36. package/dist/discovery/workspaces.json +12 -7
  37. package/dist/hooks/dispatch.js +1501 -511
  38. package/dist/mcp/registry-manifest.json +3 -3
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/router.json +1 -1
  41. package/dist/server/nicknameResolver.js +116 -0
  42. package/dist/server/nicknameResolver.js.map +1 -0
  43. package/dist/server/routes/ping.js +29 -11
  44. package/dist/server/routes/ping.js.map +1 -1
  45. package/dist/shared/interactiveContext.js +73 -0
  46. package/dist/shared/interactiveContext.js.map +1 -0
  47. package/dist/shared/nicknamePrefill.js +59 -0
  48. package/dist/shared/nicknamePrefill.js.map +1 -0
  49. package/dist/shared/settingsAsks.js +128 -0
  50. package/dist/shared/settingsAsks.js.map +1 -0
  51. package/dist/shared/settingsConsent.js +93 -0
  52. package/dist/shared/settingsConsent.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/config/agents-paths.yml +15 -0
  55. package/src/config/evaluator-budgets.json +68 -68
  56. package/src/config/gate-coverage.yml +131 -5
  57. package/src/config/gate-violation-baselines.json +11 -0
  58. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  59. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  60. package/src/scripts/_dispatch.bash +12 -0
  61. package/src/scripts/ai-video/stitch.sh +27 -9
  62. package/src/scripts/before_complete_hook.ts +146 -5
  63. package/src/scripts/bench_ab_v2_run.ts +272 -16
  64. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  65. package/src/scripts/check_completion_review.ts +80 -3
  66. package/src/scripts/check_corpus_staleness.ts +692 -0
  67. package/src/scripts/check_depth_budget.ts +295 -0
  68. package/src/scripts/check_gate_coverage.ts +67 -0
  69. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  70. package/src/scripts/conformance_scan.ts +422 -0
  71. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  72. package/src/scripts/git_authorization_hook.ts +328 -0
  73. package/src/scripts/hook_manifest.yaml +69 -9
  74. package/src/scripts/hooks/block_no_verify.ts +3 -1
  75. package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
  76. package/src/scripts/hooks/concern_registry.ts +8 -0
  77. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  78. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  79. package/src/scripts/hooks/evidence_independence.ts +368 -0
  80. package/src/scripts/language_mirror_hook.ts +456 -0
  81. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  82. package/src/scripts/lint_agents_layout.ts +2 -0
  83. package/src/scripts/lint_decision_impact.ts +246 -0
  84. package/src/scripts/lint_example_fences.ts +609 -0
  85. package/src/scripts/lint_hook_manifest.ts +29 -0
  86. package/src/scripts/lint_rule_interactions.ts +90 -0
  87. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  88. package/src/scripts/lint_skill_descriptions.ts +86 -2
  89. package/src/scripts/memory_learn_hook.ts +109 -6
  90. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  91. package/src/scripts/release.ts +38 -1
  92. package/src/scripts/schemas/review-findings.schema.json +99 -0
  93. package/src/scripts/schemas/rule.schema.json +10 -0
  94. package/src/shared/interactiveContext.ts +98 -0
  95. package/src/shared/nicknamePrefill.ts +96 -0
  96. package/src/shared/settingsAsks.ts +176 -0
  97. package/src/shared/settingsConsent.ts +136 -0
@@ -252,6 +252,22 @@ function _run(): number {
252
252
  );
253
253
  }
254
254
 
255
+ const declaredPairKeys = new Set<string>();
256
+ for (const pair of pairs) {
257
+ if (_isPlainDict(pair) && Array.isArray(pair['rules']) && pair['rules'].length === 2) {
258
+ declaredPairKeys.add(_sorted(pair['rules'] as string[]).join('|'));
259
+ }
260
+ }
261
+ for (const gap of closureGaps(declared_rules as string[], declaredPairKeys)) {
262
+ errors.push(
263
+ `\`${gap.a}\` states precedence over \`${gap.b}\` at ${gap.cite} — both rules are ` +
264
+ 'declared in this matrix, so the pair needs a row. The register is authoritative ' +
265
+ 'for the set it covers; an internally incomplete register is worse than a small ' +
266
+ 'one, because a reader who finds two covered rules and no row between them ' +
267
+ 'concludes there is no interaction.',
268
+ );
269
+ }
270
+
255
271
  if (errors.length > 0) {
256
272
  fail(errors);
257
273
  }
@@ -264,6 +280,80 @@ function _run(): number {
264
280
  return 0;
265
281
  }
266
282
 
283
+ /**
284
+ * Precedence verbs — the vocabulary a rule uses to say another rule governs.
285
+ *
286
+ * Scoped deliberately to declarations of ORDER between two named rules, not to
287
+ * conflict detection. A static detector of *undeclared* conflicts was measured
288
+ * on this corpus and killed: 67 % false positives at the only full-recall
289
+ * operating point, with the co-fire signal anti-correlated with truth. This is
290
+ * the other half of that verdict — it reads only what a rule has already
291
+ * WRITTEN about a named sibling, so there is no object-resolution problem and
292
+ * no false-positive budget to defend.
293
+ */
294
+ const PRECEDENCE_VERB =
295
+ /\bwins on conflict\b|\bwins\b|\boutranks?\b|\boverrid\w+\b|\btakes precedence\b|\bsupersed\w+\b|\bis (?:junior|senior|subordinate) to\b|\bsubordinate to\b|\bdefers? to\b|\bnever lift\w*\b|\bnever overrid\w+\b|\bholds regardless\b|\bprecedes\b/i;
296
+
297
+ export interface ClosureGap {
298
+ readonly a: string;
299
+ readonly b: string;
300
+ readonly cite: string;
301
+ }
302
+
303
+ /**
304
+ * Pairs where one declared rule states precedence over another declared rule
305
+ * and the matrix has no row for them.
306
+ *
307
+ * The scope is the matrix's OWN `rules:` list, and that bound is the design.
308
+ * Asserting the same over all 111 rules would demand roughly 64 rows, and a
309
+ * corpus sweep classified about two thirds of the cross-artifact reference
310
+ * surface as see-also glosses, canonical-home pointers and deliberate
311
+ * safety-floor restatements — prose carrying no decision. A gate that ingested
312
+ * those would land as an unfixable blocker, which this repository has already
313
+ * recorded as a failure mode. Bounding to the declared set makes the register
314
+ * answerable for what it claims and silent about what it does not, which is
315
+ * the honest shape.
316
+ */
317
+ export function closureGaps(
318
+ declaredRules: readonly string[],
319
+ declaredPairKeys: ReadonlySet<string>,
320
+ rootDir: string = ROOT,
321
+ ): ClosureGap[] {
322
+ const covered = declaredRules.filter((r) => typeof r === 'string');
323
+ const gaps: ClosureGap[] = [];
324
+ const emitted = new Set<string>();
325
+ for (const a of [...covered].sort()) {
326
+ const file = path.join(rootDir, 'src', 'rules', `${a}.md`);
327
+ let lines: string[];
328
+ try {
329
+ lines = fs.readFileSync(file, 'utf-8').split(/\r\n|\r|\n/);
330
+ } catch {
331
+ continue;
332
+ }
333
+ for (let i = 0; i < lines.length; i++) {
334
+ const line = lines[i] as string;
335
+ if (!PRECEDENCE_VERB.test(line)) {
336
+ continue;
337
+ }
338
+ for (const b of covered) {
339
+ if (b === a) {
340
+ continue;
341
+ }
342
+ if (!new RegExp(`(?<![\\w-])${b}(?![\\w-])`).test(line)) {
343
+ continue;
344
+ }
345
+ const key = [a, b].sort().join('|');
346
+ if (declaredPairKeys.has(key) || emitted.has(key)) {
347
+ continue;
348
+ }
349
+ emitted.add(key);
350
+ gaps.push({ a, b, cite: `src/rules/${a}.md:${String(i + 1)}` });
351
+ }
352
+ }
353
+ }
354
+ return gaps;
355
+ }
356
+
267
357
  function _setDiff(a: Set<string>, b: Set<string>): Set<string> {
268
358
  const out = new Set<string>();
269
359
  for (const x of a) {
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * lint_rule_migration_ledger — hold the rule-body migration ledger honest.
4
+ *
5
+ * ## What the ledger is
6
+ *
7
+ * 44 of this package's 111 rules carry a line saying their body was migrated to
8
+ * a skill, guideline, context or contract. That transform is **lossy** and was
9
+ * never recorded: `check_condensation` guarantees byte-exactness for
10
+ * source→projection, and nothing at all covered rule→target. A reader who wants
11
+ * to know what a rule used to say, and where each part of it went, had only
12
+ * `git log` — and for 24 of the 44 even that fails, because `git log --follow`
13
+ * terminates their lineage at a 2026-05-18 merge where the file first appears
14
+ * already carrying the migration line.
15
+ *
16
+ * One ledger per migrated rule, at `agents/decisions/rule-migrations/<rule>.yml`.
17
+ *
18
+ * ## Why the ledger carries its own `source_headings`
19
+ *
20
+ * The obvious design has the gate re-derive the pre-migration headings from git
21
+ * at lint time. It cannot: the two commits holding the pre-migration bodies for
22
+ * 20 of these rules — `d4fe80e1c` and `2a11c70b2` — are **not ancestors of
23
+ * HEAD**. They survive only because 31 and 2 `origin/*` branches respectively
24
+ * still contain them, CI clones do not fetch those refs, and a routine remote
25
+ * branch prune destroys them permanently.
26
+ *
27
+ * So the ledger records `source_headings` verbatim, captured once from git while
28
+ * the objects still existed. That is not redundancy — it IS the harvest. After
29
+ * this lands, the ledger's basis survives a branch prune, and the gate needs no
30
+ * git at all, which is also why it runs in a shallow CI clone.
31
+ *
32
+ * ## What the gate asserts
33
+ *
34
+ * 1. Every rule carrying a migration line has a ledger, and every ledger names a
35
+ * rule that exists.
36
+ * 2. Every recorded `source_heading` has **exactly one** row — the completeness
37
+ * property, keyed on heading TEXT rather than level, because `##` → `###`
38
+ * demotion is the norm in these targets and a level-keyed check misses every
39
+ * demoted section.
40
+ * 3. Every `target` anchor resolves: the file exists AND carries a heading whose
41
+ * text matches.
42
+ * 4. `carried` and `merged` name a target; `dropped` must not.
43
+ * 5. Every reason is present and is not one of the known-empty phrasings.
44
+ *
45
+ * ## What the gate deliberately does NOT assert
46
+ *
47
+ * Naming this is more important here than usual, because the ledger records
48
+ * history and a gate that demanded history be *reproduced* would push an author
49
+ * to invent rows:
50
+ *
51
+ * - **Not the semantic quality of a reason.** The check is a denylist of
52
+ * known-empty phrasings plus a length floor — mechanical, not editorial. A
53
+ * reason can be wrong; that is a review problem, not a lint problem.
54
+ * - **Not that `unrecoverable` is truly unrecoverable.** The label claims "we
55
+ * do not have it", never "it could not be found by anyone".
56
+ * - **Not that a heading SHOULD have been carried.** A `dropped` row with a
57
+ * real reason is a complete answer. The ledger's job is that the loss is
58
+ * recorded, not that it is undone.
59
+ * - **Not headings added after the migration.** `## See also` was appended to
60
+ * nearly every rule by a later batch; requiring pre-migration provenance for
61
+ * post-migration headings would false-positive across the whole set.
62
+ *
63
+ * Exit codes: 0 = clean, 1 = violations, 2 = usage / unreadable ledger.
64
+ */
65
+ import * as fs from 'node:fs';
66
+ import * as os from 'node:os';
67
+ import * as path from 'node:path';
68
+ import { fileURLToPath, pathToFileURL } from 'node:url';
69
+ import { parse as parseYaml } from 'yaml';
70
+
71
+ import { GateLedger } from './_lib/gate_ledger.js';
72
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
73
+ import { reportScanned } from './_lib/scan_scope.js';
74
+
75
+ const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
76
+ const REAL_REPO_ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
77
+
78
+ export const LEDGER_DIR = 'agents/decisions/rule-migrations';
79
+
80
+ /** Rule-level provenance of the pre-migration body. */
81
+ export const SOURCE_KINDS = ['recoverable', 'born_thin', 'unrecoverable'] as const;
82
+ /** Row-level outcome for one pre-migration heading. */
83
+ export const DISPOSITIONS = ['carried', 'merged', 'dropped'] as const;
84
+
85
+ export type SourceKind = (typeof SOURCE_KINDS)[number];
86
+ export type Disposition = (typeof DISPOSITIONS)[number];
87
+
88
+ /**
89
+ * Phrasings that look like a reason and record nothing.
90
+ *
91
+ * This list is the whole of the "forbid a vague reason" requirement, and it is
92
+ * deliberately a closed denylist rather than a judgement: a gate that scored
93
+ * reason quality would be unfalsifiable and would invite writing to the scorer.
94
+ */
95
+ export const VAGUE_REASONS = [
96
+ 'secondary', 'lower-yield', 'lower yield', 'redundant', 'not needed',
97
+ 'unnecessary', 'consolidated', 'cleanup', 'obsolete', 'n/a', 'none',
98
+ 'tbd', 'todo', 'see above', 'as above', 'misc', 'other',
99
+ ];
100
+
101
+ /** A reason must carry content a reviewer can check: five words, and not a denylisted phrase. */
102
+ export function reasonIsAcceptable(reason: unknown): { ok: boolean; why?: string } {
103
+ if (typeof reason !== 'string' || reason.trim() === '') {
104
+ return { ok: false, why: 'reason is missing or empty' };
105
+ }
106
+ const norm = reason.trim().toLowerCase().replace(/[.!]$/, '');
107
+ if (VAGUE_REASONS.includes(norm)) {
108
+ return { ok: false, why: `reason "${reason.trim()}" is a known-empty phrasing` };
109
+ }
110
+ const words = norm.split(/\s+/).filter((w) => w.length > 1);
111
+ if (words.length < 5) {
112
+ return { ok: false, why: `reason "${reason.trim()}" is too short to review (needs ≥ 5 words)` };
113
+ }
114
+ return { ok: true };
115
+ }
116
+
117
+ /** Heading text, normalized for comparison — level, emphasis and spacing are not identity. */
118
+ export function normHeading(h: string): string {
119
+ return h.replace(/[`*_]/g, '').toLowerCase().trim().replace(/\s+/g, ' ');
120
+ }
121
+
122
+ /**
123
+ * Every heading in a markdown body, ignoring fenced blocks.
124
+ *
125
+ * `#` is included on purpose. Three of the migrated rules are pointer stubs
126
+ * whose entire surviving body sits under the H1 with no `##` beneath it, and a
127
+ * row saying "this stayed in the rule" has nowhere else to point. Excluding H1
128
+ * would force those rows to invent a subsection that does not exist — the
129
+ * anchor-fabrication this gate exists to prevent.
130
+ */
131
+ export function headingsOf(text: string): string[] {
132
+ const out: string[] = [];
133
+ let inFence = false;
134
+ for (const line of text.split(/\r\n|\r|\n/)) {
135
+ if (/^\s*(```|~~~)/.test(line)) {
136
+ inFence = !inFence;
137
+ continue;
138
+ }
139
+ if (inFence) {
140
+ continue;
141
+ }
142
+ const m = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
143
+ if (m?.[2] !== undefined) {
144
+ out.push(m[2].trim());
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ export interface LedgerRow {
151
+ heading?: unknown;
152
+ disposition?: unknown;
153
+ target?: unknown;
154
+ reason?: unknown;
155
+ }
156
+ export interface LedgerFile {
157
+ rule?: unknown;
158
+ source?: unknown;
159
+ source_commit?: unknown;
160
+ source_path?: unknown;
161
+ migrated_to?: unknown;
162
+ source_headings?: unknown;
163
+ rows?: unknown;
164
+ }
165
+
166
+ /** Rules whose body carries a migration pointer, in any of its five phrasings. */
167
+ export function migratedRules(root: string): string[] {
168
+ const dir = path.join(root, 'src', 'rules');
169
+ let names: string[];
170
+ try {
171
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.md'));
172
+ } catch {
173
+ return [];
174
+ }
175
+ const out: string[] = [];
176
+ for (const n of names.sort()) {
177
+ const text = fs.readFileSync(path.join(dir, n), 'utf-8');
178
+ // Five phrasings, one of them mid-paragraph and one soft-wrapped across
179
+ // two lines — so this matches on the joined body, not per line, and
180
+ // anchors on the verb rather than on a line start.
181
+ const joined = text.replace(/\s*\n\s*/g, ' ');
182
+ if (/\b(?:migrated to|merged into)\b/i.test(joined)) {
183
+ out.push(n.slice(0, -3));
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export function checkLedger(
190
+ root: string,
191
+ file: string,
192
+ doc: LedgerFile,
193
+ ): string[] {
194
+ const errs: string[] = [];
195
+ const id = path.basename(file);
196
+ const rule = typeof doc.rule === 'string' ? doc.rule : null;
197
+ if (rule === null) {
198
+ return [`${id}: missing \`rule:\``];
199
+ }
200
+ if (!fs.existsSync(path.join(root, 'src', 'rules', `${rule}.md`))) {
201
+ errs.push(`${id}: \`rule: ${rule}\` has no file at src/rules/${rule}.md`);
202
+ }
203
+ if (path.basename(file, '.yml') !== rule) {
204
+ errs.push(`${id}: filename must match \`rule: ${rule}\``);
205
+ }
206
+
207
+ const source = doc.source;
208
+ if (typeof source !== 'string' || !(SOURCE_KINDS as readonly string[]).includes(source)) {
209
+ errs.push(`${id}: \`source:\` must be one of ${SOURCE_KINDS.join(' | ')}`);
210
+ return errs;
211
+ }
212
+
213
+ const headings = Array.isArray(doc.source_headings) ? (doc.source_headings as unknown[]) : [];
214
+ const rows = Array.isArray(doc.rows) ? (doc.rows as LedgerRow[]) : [];
215
+
216
+ if (source === 'born_thin') {
217
+ // No source content ever existed. This is a different fact from
218
+ // "existed and is lost", and a ledger that spelled them the same way
219
+ // could not be audited — so the shapes are enforced apart.
220
+ if (headings.length > 0 || rows.length > 0) {
221
+ errs.push(
222
+ `${id}: \`source: born_thin\` means no pre-migration body ever existed, so it ` +
223
+ 'carries no headings and no rows — use `unrecoverable` if a body existed and is lost',
224
+ );
225
+ }
226
+ return errs;
227
+ }
228
+
229
+ if (source === 'unrecoverable') {
230
+ if (rows.length > 0) {
231
+ errs.push(`${id}: \`source: unrecoverable\` records that the body is gone; it carries no rows`);
232
+ }
233
+ return errs;
234
+ }
235
+
236
+ if (headings.length === 0) {
237
+ errs.push(`${id}: \`source: recoverable\` requires the harvested \`source_headings:\``);
238
+ }
239
+
240
+ const rowByHeading = new Map<string, number>();
241
+ for (const row of rows) {
242
+ const h = typeof row.heading === 'string' ? normHeading(row.heading) : null;
243
+ if (h === null) {
244
+ errs.push(`${id}: a row has no \`heading:\``);
245
+ continue;
246
+ }
247
+ rowByHeading.set(h, (rowByHeading.get(h) ?? 0) + 1);
248
+
249
+ const disp = row.disposition;
250
+ if (typeof disp !== 'string' || !(DISPOSITIONS as readonly string[]).includes(disp)) {
251
+ errs.push(`${id}: row "${String(row.heading)}" disposition must be one of ${DISPOSITIONS.join(' | ')}`);
252
+ continue;
253
+ }
254
+ const verdict = reasonIsAcceptable(row.reason);
255
+ if (!verdict.ok) {
256
+ errs.push(`${id}: row "${String(row.heading)}" — ${verdict.why ?? 'bad reason'}`);
257
+ }
258
+ const target = typeof row.target === 'string' ? row.target : null;
259
+ if (disp === 'dropped') {
260
+ if (target !== null) {
261
+ errs.push(`${id}: row "${String(row.heading)}" is \`dropped\` but names a target`);
262
+ }
263
+ continue;
264
+ }
265
+ if (target === null) {
266
+ errs.push(`${id}: row "${String(row.heading)}" is \`${disp}\` and must name a target`);
267
+ continue;
268
+ }
269
+ const [fileP, anchor] = target.split('#');
270
+ const abs = path.join(root, fileP ?? '');
271
+ if (!fs.existsSync(abs)) {
272
+ errs.push(`${id}: row "${String(row.heading)}" target file does not exist: ${String(fileP)}`);
273
+ continue;
274
+ }
275
+ if (anchor === undefined || anchor.trim() === '') {
276
+ errs.push(`${id}: row "${String(row.heading)}" target needs a \`#<heading>\` anchor`);
277
+ continue;
278
+ }
279
+ const want = normHeading(anchor);
280
+ const have = new Set(headingsOf(fs.readFileSync(abs, 'utf-8')).map(normHeading));
281
+ if (!have.has(want)) {
282
+ errs.push(
283
+ `${id}: row "${String(row.heading)}" target anchor does not resolve — ` +
284
+ `${String(fileP)} has no heading "${anchor}"`,
285
+ );
286
+ }
287
+ }
288
+
289
+ for (const h of headings) {
290
+ if (typeof h !== 'string') {
291
+ errs.push(`${id}: \`source_headings\` entry is not a string`);
292
+ continue;
293
+ }
294
+ const count = rowByHeading.get(normHeading(h)) ?? 0;
295
+ if (count === 0) {
296
+ errs.push(`${id}: pre-migration heading "${h}" has no ledger row — every heading gets exactly one`);
297
+ } else if (count > 1) {
298
+ errs.push(`${id}: pre-migration heading "${h}" has ${String(count)} rows — exactly one disposition per heading`);
299
+ }
300
+ }
301
+ return errs;
302
+ }
303
+
304
+ interface Args {
305
+ readonly quiet: boolean;
306
+ readonly json: boolean;
307
+ }
308
+
309
+ function parseArgs(argv: readonly string[]): Args {
310
+ let quiet = false;
311
+ let json = false;
312
+ for (const a of argv) {
313
+ if (a === '--quiet') { quiet = true; }
314
+ else if (a === '--json') { json = true; }
315
+ else if (a === '-h' || a === '--help') {
316
+ process.stdout.write('usage: lint_rule_migration_ledger [--quiet] [--json]\n');
317
+ process.exit(0);
318
+ } else {
319
+ process.stderr.write(`lint_rule_migration_ledger: unrecognized argument: ${a}\n`);
320
+ process.exit(2);
321
+ }
322
+ }
323
+ return { quiet, json };
324
+ }
325
+
326
+ export function selfTest(): number {
327
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lrml-'));
328
+ const mk = (files: Record<string, string>): string => {
329
+ const root = fs.mkdtempSync(path.join(tmp, 'repo-'));
330
+ for (const [rel, body] of Object.entries(files)) {
331
+ const p = path.join(root, rel);
332
+ fs.mkdirSync(path.dirname(p), { recursive: true });
333
+ fs.writeFileSync(p, body, 'utf-8');
334
+ }
335
+ return root;
336
+ };
337
+ const RULE = '# R\n\nBody migrated to `skill:x`.\n';
338
+ const TARGET = '# X\n\n## Kept Section\n\ntext\n';
339
+ const ok = mk({
340
+ 'src/rules/r.md': RULE,
341
+ 'src/skills/x/SKILL.md': TARGET,
342
+ [`${LEDGER_DIR}/r.yml`]:
343
+ 'rule: r\nsource: recoverable\nsource_headings:\n - Kept Section\nrows:\n - heading: Kept Section\n disposition: carried\n target: src/skills/x/SKILL.md#Kept Section\n reason: the whole section moved verbatim into the skill body\n',
344
+ });
345
+ const missingRow = mk({
346
+ 'src/rules/r.md': RULE,
347
+ 'src/skills/x/SKILL.md': TARGET,
348
+ [`${LEDGER_DIR}/r.yml`]:
349
+ 'rule: r\nsource: recoverable\nsource_headings:\n - Kept Section\n - Lost Section\nrows:\n - heading: Kept Section\n disposition: carried\n target: src/skills/x/SKILL.md#Kept Section\n reason: the whole section moved verbatim into the skill body\n',
350
+ });
351
+ const badAnchor = mk({
352
+ 'src/rules/r.md': RULE,
353
+ 'src/skills/x/SKILL.md': TARGET,
354
+ [`${LEDGER_DIR}/r.yml`]:
355
+ 'rule: r\nsource: recoverable\nsource_headings:\n - Kept Section\nrows:\n - heading: Kept Section\n disposition: carried\n target: src/skills/x/SKILL.md#No Such Heading\n reason: the whole section moved verbatim into the skill body\n',
356
+ });
357
+ const vague = mk({
358
+ 'src/rules/r.md': RULE,
359
+ 'src/skills/x/SKILL.md': TARGET,
360
+ [`${LEDGER_DIR}/r.yml`]:
361
+ 'rule: r\nsource: recoverable\nsource_headings:\n - Kept Section\nrows:\n - heading: Kept Section\n disposition: dropped\n reason: secondary\n',
362
+ });
363
+ const noLedger = mk({ 'src/rules/r.md': RULE, 'src/skills/x/SKILL.md': TARGET });
364
+ const run = (root: string): number => {
365
+ process.env['LINT_MIGRATION_LEDGER_ROOT'] = root;
366
+ try {
367
+ return runGateCli(REAL_REPO_ROOT, 'src/scripts/lint_rule_migration_ledger.ts', ['--quiet'], root);
368
+ } finally {
369
+ delete process.env['LINT_MIGRATION_LEDGER_ROOT'];
370
+ }
371
+ };
372
+ try {
373
+ return runSelfTest({
374
+ gate: 'lint_rule_migration_ledger',
375
+ minCases: 3,
376
+ minRejectCases: 2,
377
+ cases: [
378
+ { name: 'a complete ledger passes', expect: 'accept', run: () => run(ok) },
379
+ { name: 'a pre-migration heading with no row is rejected', expect: 'reject', run: () => run(missingRow) },
380
+ { name: 'a target anchor that does not resolve is rejected', expect: 'reject', run: () => run(badAnchor) },
381
+ { name: 'a known-empty reason ("secondary") is rejected', expect: 'reject', run: () => run(vague) },
382
+ { name: 'a migrated rule with no ledger at all is rejected', expect: 'reject', run: () => run(noLedger) },
383
+ ],
384
+ });
385
+ } finally {
386
+ fs.rmSync(tmp, { recursive: true, force: true });
387
+ }
388
+ }
389
+
390
+ export function main(argv?: readonly string[]): number {
391
+ const raw = argv ?? process.argv.slice(2);
392
+ if (raw.includes('--self-test')) {
393
+ return selfTest();
394
+ }
395
+ const args = parseArgs(raw);
396
+ const root = process.env['LINT_MIGRATION_LEDGER_ROOT'] ?? REAL_REPO_ROOT;
397
+
398
+ const rules = migratedRules(root);
399
+ const dir = path.join(root, LEDGER_DIR);
400
+ const ledgerFiles = fs.existsSync(dir)
401
+ ? fs.readdirSync(dir).filter((n) => n.endsWith('.yml')).sort()
402
+ : [];
403
+
404
+ const ledger = new GateLedger('lint_rule_migration_ledger');
405
+ ledger.plan(rules.map((r) => `src/rules/${r}.md`));
406
+
407
+ const errors: string[] = [];
408
+ const haveLedger = new Set(ledgerFiles.map((n) => n.slice(0, -4)));
409
+ for (const r of rules) {
410
+ if (!haveLedger.has(r)) {
411
+ errors.push(
412
+ `src/rules/${r}.md carries a migration line but has no ledger at ${LEDGER_DIR}/${r}.yml — ` +
413
+ 'a lossy transform with no record is the gap this ledger exists to close',
414
+ );
415
+ ledger.fail(`src/rules/${r}.md`, 'no ledger');
416
+ } else {
417
+ ledger.complete(`src/rules/${r}.md`);
418
+ }
419
+ }
420
+ for (const name of ledgerFiles) {
421
+ const file = path.join(dir, name);
422
+ let doc: LedgerFile;
423
+ try {
424
+ doc = parseYaml(fs.readFileSync(file, 'utf-8')) as LedgerFile;
425
+ } catch (e) {
426
+ errors.push(`${name}: unreadable YAML — ${String(e)}`);
427
+ continue;
428
+ }
429
+ errors.push(...checkLedger(root, file, doc));
430
+ }
431
+
432
+ const tally = ledger.finalize();
433
+
434
+ if (args.json) {
435
+ process.stdout.write(`${JSON.stringify({ version: 1, errors, ledger: tally }, null, 2)}\n`);
436
+ return errors.length > 0 ? 1 : 0;
437
+ }
438
+
439
+ if (errors.length > 0) {
440
+ process.stderr.write(`❌ lint_rule_migration_ledger: ${String(errors.length)} issue(s):\n`);
441
+ for (const e of errors) {
442
+ process.stderr.write(` • ${e}\n`);
443
+ }
444
+ } else if (!args.quiet) {
445
+ process.stdout.write(
446
+ `✅ rule-migration ledger clean — ${String(rules.length)} migrated rule(s), ` +
447
+ `${String(ledgerFiles.length)} ledger(s).\n`,
448
+ );
449
+ }
450
+
451
+ reportScanned({
452
+ gate: 'lint_rule_migration_ledger',
453
+ scanned: rules.length,
454
+ units: 'migrated rule(s)',
455
+ roots: ['src/rules', LEDGER_DIR],
456
+ });
457
+ return errors.length > 0 ? 1 : 0;
458
+ }
459
+
460
+ const _HERE = fileURLToPath(import.meta.url);
461
+ function _isCliEntry(): boolean {
462
+ if (process.argv[1] === undefined) {
463
+ return false;
464
+ }
465
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
466
+ return true;
467
+ }
468
+ try {
469
+ return (
470
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
471
+ fs.realpathSync(path.resolve(process.argv[1]))
472
+ );
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+
478
+ if (_isCliEntry() || process.argv[1] === _HERE) {
479
+ process.exit(main());
480
+ }