@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +136 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +33 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +111 -0
- package/dist/agent-src/rules/session-canary.md +15 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +23 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +146 -28
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +16 -11
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +12 -7
- package/dist/hooks/dispatch.js +1501 -511
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +131 -5
- package/src/config/gate-violation-baselines.json +11 -0
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +146 -5
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +422 -0
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +328 -0
- package/src/scripts/hook_manifest.yaml +69 -9
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +368 -0
- package/src/scripts/language_mirror_hook.ts +456 -0
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/lint_rule_interactions.ts +90 -0
- package/src/scripts/lint_rule_migration_ledger.ts +480 -0
- package/src/scripts/lint_skill_descriptions.ts +86 -2
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- 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
|
+
}
|