@event4u/agent-config 10.2.0 → 10.4.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 (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +168 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/analyze.md +2 -1
  6. package/dist/agent-src/commands/design-system/capture.md +44 -0
  7. package/dist/agent-src/commands/design-system/generate.md +53 -0
  8. package/dist/agent-src/commands/design-system/import.md +63 -0
  9. package/dist/agent-src/commands/design-system.md +66 -0
  10. package/dist/agent-src/commands/roadmap/create.md +4 -1
  11. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  12. package/dist/agent-src/rules/active-remediation.md +53 -2
  13. package/dist/agent-src/rules/code-provenance.md +66 -4
  14. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  15. package/dist/agent-src/rules/design-fidelity.md +49 -3
  16. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  18. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  19. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  20. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  21. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  22. package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/agent-src/templates/persona.md +26 -0
  26. package/dist/agent-src/templates/roadmaps.md +27 -7
  27. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  28. package/dist/agent-src/templates/skill.md +35 -1
  29. package/dist/cli/commands/commands.candidates.test.js +257 -0
  30. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  31. package/dist/cli/commands/commands.js +199 -0
  32. package/dist/cli/commands/commands.js.map +1 -1
  33. package/dist/cli/main.js +1 -0
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/cli/registry.js +1 -1
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
  38. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  39. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  40. package/dist/cli-delegate/cmd_conformance.js +672 -214
  41. package/dist/cli-delegate/cmd_doctor.js +1 -1
  42. package/dist/cli-delegate/cmd_handoff.js +8 -6
  43. package/dist/cli-delegate/cmd_session_recycle.js +5 -3
  44. package/dist/discovery/deprecation-report.md +1 -1
  45. package/dist/discovery/discovery-manifest.json +127 -22
  46. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  47. package/dist/discovery/discovery-manifest.summary.md +3 -3
  48. package/dist/discovery/dormancy-report.md +1 -1
  49. package/dist/discovery/orphan-report.md +1 -1
  50. package/dist/discovery/packs.json +9 -5
  51. package/dist/discovery/trust-report.md +2 -2
  52. package/dist/discovery/workspaces.json +8 -4
  53. package/dist/hooks/dispatch.js +1056 -411
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1103 -593
  57. package/dist/router.json +1 -1
  58. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  59. package/docs/guidelines/design-antipatterns.md +79 -10
  60. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  61. package/package.json +1 -1
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  63. package/src/config/gate-coverage.yml +65 -10
  64. package/src/config/gate-violation-baselines.json +6 -0
  65. package/src/config/review-prompt-binding-baseline.json +19 -0
  66. package/src/config/rule-enforcement-baseline.json +0 -1
  67. package/src/scripts/_dispatch.bash +1 -0
  68. package/src/scripts/_lib/design_system_import.ts +987 -0
  69. package/src/scripts/ai_council/clients.ts +15 -1
  70. package/src/scripts/check_ci_local_parity.ts +60 -6
  71. package/src/scripts/check_review_prompt_binding.ts +553 -0
  72. package/src/scripts/check_suppression_hygiene.ts +19 -3
  73. package/src/scripts/design_slop_rules.test.ts +37 -6
  74. package/src/scripts/design_slop_rules.ts +150 -22
  75. package/src/scripts/design_system_import.ts +264 -0
  76. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  77. package/src/scripts/hook_manifest.yaml +71 -2
  78. package/src/scripts/hooks/concern_registry.ts +4 -0
  79. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  80. package/src/scripts/hooks/host_semantics.ts +2 -0
  81. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  82. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  83. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  84. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  85. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  86. package/src/scripts/lint_harvest_provenance.ts +483 -0
  87. package/src/scripts/lint_hook_manifest.ts +2 -0
  88. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  89. package/src/scripts/lint_skill_router_head.ts +254 -0
  90. package/src/scripts/report_consultation_rate.ts +126 -1
  91. package/src/scripts/run_skill_evals.ts +10 -5
  92. package/src/scripts/schemas/persona.schema.json +8 -0
  93. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  94. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  95. package/tests/fixtures/hooks/subagent_start.json +7 -0
  96. package/tests/fixtures/hooks/subagent_stop.json +8 -0
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Harvest-ledger linter — the knowledge-side sibling of lint_provenance.ts.
4
+ *
5
+ * `provenance/borrows.jsonl` records borrowed CODE. `provenance/harvests.jsonl`
6
+ * records borrowed IDEAS: an externally-sourced heuristic, number, or mechanism
7
+ * that some skill, rule, or roadmap in this tree now asserts as doctrine. The
8
+ * `code-provenance` rule binds the obligation — cite a `harvest_id` or label the
9
+ * statement as own analysis — and this gate audits the ledger that obligation
10
+ * points at.
11
+ *
12
+ * Like its sibling it is strict from day one, because it checks OUR OWN RECORDS
13
+ * rather than running a similarity detector: a malformed row is a defect in the
14
+ * record, not a probabilistic call. And like its sibling it CANNOT catch an
15
+ * unrecorded harvest — no gate can see a citation nobody wrote. Saying so here
16
+ * is deliberate; claiming otherwise would inflate the coverage this repo
17
+ * reports.
18
+ *
19
+ * Naming, stated because the collision is live: `check_claims.ts` and
20
+ * `docs/CLAIMS.md` also use the word "claim", for the opposite direction of
21
+ * travel — public claims this package makes ABOUT ITSELF. This gate is named
22
+ * after `lint_provenance`, its actual sibling, so a reader who greps either
23
+ * name lands in the right family.
24
+ *
25
+ * Checks:
26
+ * 1. Schema — the closed 6-field shape (harvest_id, stated_in, source_ref,
27
+ * evidence_locator, harvested_at, verdict), every field format-checked.
28
+ * 2. Uniqueness — a duplicate harvest_id makes a citation ambiguous.
29
+ * 3. Dead rows — every `stated_in` path exists and stays inside the repo.
30
+ * 4. Pinning — `source_ref` carries a revision or is explicitly opaque; a
31
+ * bare URL is rejected because it cannot be re-verified later.
32
+ * 5. Orphan citations — every `<!-- harvest:<id> -->` marker under the
33
+ * scanned roots resolves to a ledger row.
34
+ *
35
+ * An EMPTY ledger with zero markers is a legitimate pass and is reported as
36
+ * such on stdout. A gate that scans nothing and exits green silently is
37
+ * indistinguishable from a broken one, so the green path always prints what it
38
+ * actually examined.
39
+ *
40
+ * Usage:
41
+ * ./scripts-run src/scripts/lint_harvest_provenance
42
+ * ./scripts-run src/scripts/lint_harvest_provenance --quiet
43
+ *
44
+ * Exit codes: 0 = clean · 2 = any finding OR usage error (mirrors
45
+ * lint_provenance — both mean "this run produced nothing trustworthy").
46
+ */
47
+
48
+ import * as fs from 'node:fs';
49
+ import * as path from 'node:path';
50
+ import { fileURLToPath, pathToFileURL } from 'node:url';
51
+
52
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
53
+
54
+ const _FILE = fileURLToPath(import.meta.url);
55
+ const _HERE = path.dirname(_FILE);
56
+ // src/scripts/lint_harvest_provenance.ts → two levels up is the repo root.
57
+ const REPO = path.resolve(_HERE, '..', '..');
58
+ const LEDGER_REL = 'provenance/harvests.jsonl';
59
+
60
+ /** Roots scanned for citation markers. Markdown-bearing authored surfaces. */
61
+ export const CITATION_SCAN_ROOTS: readonly string[] = [
62
+ 'src/skills',
63
+ 'src/rules',
64
+ 'src/agent-src',
65
+ 'src/domains',
66
+ 'agents/roadmaps',
67
+ ];
68
+
69
+ // ─── field formats ───────────────────────────────────────────────────────────
70
+
71
+ const REQUIRED_FIELDS = [
72
+ 'harvest_id', 'stated_in', 'source_ref', 'evidence_locator',
73
+ 'harvested_at', 'verdict',
74
+ ] as const;
75
+ const VERDICT_VALUES: ReadonlySet<string> = new Set(['adopt', 'adapt']);
76
+ const HARVEST_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
77
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
78
+ const EVIDENCE_MIN_LENGTH = 3;
79
+
80
+ /**
81
+ * A pinned public source: any http(s) or git URL carrying an `@<revision>`
82
+ * suffix. The revision is 7-64 hex chars — the same shape `borrows.jsonl`
83
+ * accepts for `source_sha`, kept identical so the two ledgers do not disagree
84
+ * about what "pinned" means.
85
+ */
86
+ const PINNED_URL_RE = /^(https?:\/\/\S+|git@\S+)@[0-9a-fA-F]{7,64}$/;
87
+ /** An opaque reference, for a source `source-confidentiality` keeps untracked. */
88
+ const OPAQUE_REF_RE = /^(opaque:[\w.-]+|ENC1:\S+)$/;
89
+
90
+ /** Citation marker as it appears in an authored artefact. */
91
+ export const CITATION_MARKER_RE = /<!--\s*harvest:([a-z0-9-]+)\s*-->/g;
92
+
93
+ // ─── types ───────────────────────────────────────────────────────────────────
94
+
95
+ export interface HarvestRecord {
96
+ readonly harvest_id: string;
97
+ readonly stated_in: string;
98
+ readonly source_ref: string;
99
+ readonly evidence_locator: string;
100
+ readonly harvested_at: string;
101
+ readonly verdict: 'adopt' | 'adapt';
102
+ }
103
+
104
+ export interface Finding {
105
+ /** Ledger line number, or 0 for findings that belong to no single row. */
106
+ readonly line: number;
107
+ readonly rule: 'schema' | 'uniqueness' | 'dead-row' | 'pinning' | 'orphan-citation';
108
+ readonly message: string;
109
+ }
110
+
111
+ function isValidCalendarDate(s: string): boolean {
112
+ if (!ISO_DATE_RE.test(s)) return false;
113
+ const [y, mo, d] = s.split('-').map(Number) as [number, number, number];
114
+ const dt = new Date(Date.UTC(y, mo - 1, d));
115
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
116
+ }
117
+
118
+ /** Strip an optional `:<line>` suffix from a `stated_in` value. */
119
+ export function stripLineSuffix(statedIn: string): string {
120
+ const m = /^(.*):(\d+)$/.exec(statedIn);
121
+ return m ? (m[1] as string) : statedIn;
122
+ }
123
+
124
+ // ─── record validation ───────────────────────────────────────────────────────
125
+
126
+ /** Validate one parsed ledger line against the closed schema. */
127
+ export function validateRecord(raw: unknown, line: number, repoRoot: string): Finding[] {
128
+ const findings: Finding[] = [];
129
+
130
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
131
+ return [{ line, rule: 'schema', message: 'record is not a JSON object' }];
132
+ }
133
+ const rec = raw as Record<string, unknown>;
134
+
135
+ for (const k of Object.keys(rec)) {
136
+ if (!(REQUIRED_FIELDS as readonly string[]).includes(k)) {
137
+ findings.push({
138
+ line,
139
+ rule: 'schema',
140
+ message: `unexpected field '${k}' — the ledger schema is closed to exactly [${REQUIRED_FIELDS.join(', ')}]`,
141
+ });
142
+ }
143
+ }
144
+ for (const k of REQUIRED_FIELDS) {
145
+ if (!(k in rec)) {
146
+ findings.push({ line, rule: 'schema', message: `missing required field '${k}'` });
147
+ }
148
+ }
149
+
150
+ if ('harvest_id' in rec && (typeof rec.harvest_id !== 'string' || !HARVEST_ID_RE.test(rec.harvest_id))) {
151
+ findings.push({
152
+ line,
153
+ rule: 'schema',
154
+ message: `harvest_id must be a kebab-case slug, got ${JSON.stringify(rec.harvest_id)}`,
155
+ });
156
+ }
157
+
158
+ if ('stated_in' in rec) {
159
+ if (typeof rec.stated_in !== 'string' || rec.stated_in.trim().length === 0) {
160
+ findings.push({ line, rule: 'schema', message: 'stated_in must be a non-empty repo-relative path' });
161
+ } else {
162
+ const rel = stripLineSuffix(rec.stated_in);
163
+ const resolved = path.resolve(repoRoot, rel);
164
+ const withinRepo = resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);
165
+ if (path.isAbsolute(rel) || !withinRepo) {
166
+ findings.push({
167
+ line,
168
+ rule: 'schema',
169
+ message: `stated_in '${rel}' escapes the repo root — must be a repo-relative path`,
170
+ });
171
+ } else if (!fs.existsSync(resolved)) {
172
+ findings.push({
173
+ line,
174
+ rule: 'dead-row',
175
+ message: `stated_in '${rel}' does not exist — the artefact asserting this harvest is gone, so the row cites nothing`,
176
+ });
177
+ }
178
+ }
179
+ }
180
+
181
+ if ('source_ref' in rec) {
182
+ const ref = rec.source_ref;
183
+ if (typeof ref !== 'string' || ref.trim().length === 0) {
184
+ findings.push({ line, rule: 'schema', message: 'source_ref must be a non-empty string' });
185
+ } else if (!PINNED_URL_RE.test(ref) && !OPAQUE_REF_RE.test(ref)) {
186
+ findings.push({
187
+ line,
188
+ rule: 'pinning',
189
+ message:
190
+ `source_ref ${JSON.stringify(ref)} is neither a revision-pinned URL ('<url>@<sha>') ` +
191
+ "nor an opaque token ('opaque:<id>' / 'ENC1:<payload>'). An unpinned reference cannot be " +
192
+ 're-verified later, which is the whole point of recording it',
193
+ });
194
+ }
195
+ }
196
+
197
+ if ('evidence_locator' in rec) {
198
+ const loc = rec.evidence_locator;
199
+ if (typeof loc !== 'string' || loc.trim().length < EVIDENCE_MIN_LENGTH) {
200
+ findings.push({
201
+ line,
202
+ rule: 'schema',
203
+ message: `evidence_locator must be at least ${EVIDENCE_MIN_LENGTH} characters — a claim whose evidence cannot be located again is not a citation`,
204
+ });
205
+ }
206
+ }
207
+
208
+ if ('harvested_at' in rec && !(typeof rec.harvested_at === 'string' && isValidCalendarDate(rec.harvested_at))) {
209
+ findings.push({
210
+ line,
211
+ rule: 'schema',
212
+ message: `harvested_at must be an ISO-8601 date (YYYY-MM-DD), got ${JSON.stringify(rec.harvested_at)}`,
213
+ });
214
+ }
215
+
216
+ if ('verdict' in rec && (typeof rec.verdict !== 'string' || !VERDICT_VALUES.has(rec.verdict))) {
217
+ findings.push({
218
+ line,
219
+ rule: 'schema',
220
+ message:
221
+ `verdict must be 'adopt' or 'adapt', got ${JSON.stringify(rec.verdict)} — a reject/already/unclear ` +
222
+ 'finding has no artefact citing it and belongs in the analysis document, not the ledger',
223
+ });
224
+ }
225
+
226
+ return findings;
227
+ }
228
+
229
+ // ─── ledger parsing ──────────────────────────────────────────────────────────
230
+
231
+ export interface ParsedLine {
232
+ readonly line: number;
233
+ readonly value: unknown;
234
+ }
235
+
236
+ /** Split jsonl text into non-blank lines, parsing each as JSON. */
237
+ export function parseLedgerText(text: string): { readonly parsed: ParsedLine[]; readonly findings: Finding[] } {
238
+ const parsed: ParsedLine[] = [];
239
+ const findings: Finding[] = [];
240
+ const lines = text.split('\n');
241
+ for (let i = 0; i < lines.length; i++) {
242
+ const raw = (lines[i] ?? '').trim();
243
+ if (raw.length === 0) continue;
244
+ try {
245
+ parsed.push({ line: i + 1, value: JSON.parse(raw) });
246
+ } catch (err) {
247
+ findings.push({ line: i + 1, rule: 'schema', message: `invalid JSON: ${(err as Error).message}` });
248
+ }
249
+ }
250
+ return { parsed, findings };
251
+ }
252
+
253
+ /** Parse + validate every line, then check cross-row uniqueness. */
254
+ export function lintLedgerText(
255
+ text: string,
256
+ repoRoot: string,
257
+ ): { readonly records: HarvestRecord[]; readonly findings: Finding[] } {
258
+ const { parsed, findings } = parseLedgerText(text);
259
+ const records: HarvestRecord[] = [];
260
+ const seen = new Map<string, number>();
261
+
262
+ for (const { line, value } of parsed) {
263
+ const recordFindings = validateRecord(value, line, repoRoot);
264
+ findings.push(...recordFindings);
265
+ if (recordFindings.length > 0) continue;
266
+
267
+ const rec = value as HarvestRecord;
268
+ const prior = seen.get(rec.harvest_id);
269
+ if (prior !== undefined) {
270
+ findings.push({
271
+ line,
272
+ rule: 'uniqueness',
273
+ message: `duplicate harvest_id '${rec.harvest_id}' (first seen on line ${prior}) — a citation must resolve to exactly one row`,
274
+ });
275
+ continue;
276
+ }
277
+ seen.set(rec.harvest_id, line);
278
+ records.push(rec);
279
+ }
280
+ return { records, findings };
281
+ }
282
+
283
+ // ─── citation scan ───────────────────────────────────────────────────────────
284
+
285
+ export interface Citation {
286
+ readonly id: string;
287
+ readonly file: string;
288
+ }
289
+
290
+ /** Recursively collect markdown files under a root. */
291
+ function collectMarkdown(dir: string, out: string[]): void {
292
+ let entries: fs.Dirent[];
293
+ try {
294
+ entries = fs.readdirSync(dir, { withFileTypes: true });
295
+ } catch {
296
+ return;
297
+ }
298
+ for (const e of entries) {
299
+ const full = path.join(dir, e.name);
300
+ if (e.isDirectory()) {
301
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
302
+ collectMarkdown(full, out);
303
+ } else if (e.isFile() && e.name.endsWith('.md')) {
304
+ out.push(full);
305
+ }
306
+ }
307
+ }
308
+
309
+ /** Find every `<!-- harvest:<id> -->` marker under the scan roots. */
310
+ export function collectCitations(repoRoot: string, roots: readonly string[] = CITATION_SCAN_ROOTS): Citation[] {
311
+ const files: string[] = [];
312
+ for (const r of roots) collectMarkdown(path.join(repoRoot, r), files);
313
+
314
+ const citations: Citation[] = [];
315
+ for (const f of files) {
316
+ let text: string;
317
+ try {
318
+ text = fs.readFileSync(f, 'utf-8');
319
+ } catch {
320
+ continue;
321
+ }
322
+ // Fresh lastIndex per file — the regex is module-level and /g is stateful.
323
+ const re = new RegExp(CITATION_MARKER_RE.source, 'g');
324
+ let m: RegExpExecArray | null;
325
+ while ((m = re.exec(text)) !== null) {
326
+ citations.push({ id: m[1] as string, file: path.relative(repoRoot, f) });
327
+ }
328
+ }
329
+ return citations;
330
+ }
331
+
332
+ /**
333
+ * A persona's optional `sources:` frontmatter list is a citation in exactly the
334
+ * sense this gate already checks: an id that must resolve to a ledger row. It
335
+ * is collected here rather than in a second gate so that "a harvest id
336
+ * resolves" has one owner — a persona-side check living elsewhere would be free
337
+ * to disagree with this one.
338
+ *
339
+ * Three states, and only the third produces citations: absent = unscoped,
340
+ * `[]` = explicitly asserts nothing harvested, non-empty = scoped to those ids.
341
+ */
342
+ export function collectPersonaSources(repoRoot: string, personaRoot = 'src/agent-src/personas'): Citation[] {
343
+ const files: string[] = [];
344
+ collectMarkdown(path.join(repoRoot, personaRoot), files);
345
+
346
+ const citations: Citation[] = [];
347
+ for (const f of files) {
348
+ let text: string;
349
+ try {
350
+ text = fs.readFileSync(f, 'utf-8');
351
+ } catch {
352
+ continue;
353
+ }
354
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
355
+ if (!fm) continue;
356
+ // Deliberately a narrow line-scanner rather than a YAML parse: this gate
357
+ // must not fail on a persona whose frontmatter is malformed elsewhere —
358
+ // that is validate_frontmatter's finding to report, and duplicating it
359
+ // here would produce two different error messages for one defect.
360
+ const block = /(^|\n)sources:\s*(\[[^\]]*\]|\n(?:\s*-\s*\S+\n?)*)/.exec(fm[1] as string);
361
+ if (!block) continue;
362
+ const raw = block[2] as string;
363
+ const ids = raw.trim().startsWith('[')
364
+ ? raw.replace(/[[\]]/g, '').split(',')
365
+ : raw.split('\n').map((l) => l.replace(/^\s*-\s*/, ''));
366
+ for (const id of ids.map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)) {
367
+ citations.push({ id, file: path.relative(repoRoot, f) });
368
+ }
369
+ }
370
+ return citations;
371
+ }
372
+
373
+ /** Every citation must resolve to a ledger row. */
374
+ export function findOrphanCitations(
375
+ citations: readonly Citation[],
376
+ records: readonly HarvestRecord[],
377
+ ): Finding[] {
378
+ const known = new Set(records.map((r) => r.harvest_id));
379
+ return citations
380
+ .filter((c) => !known.has(c.id))
381
+ .map((c) => ({
382
+ line: 0,
383
+ rule: 'orphan-citation' as const,
384
+ message: `${c.file} cites harvest '${c.id}', which has no row in ${LEDGER_REL}`,
385
+ }));
386
+ }
387
+
388
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
389
+
390
+ class ExitCode extends Error {
391
+ readonly code: number;
392
+ constructor(code: number) {
393
+ super(`exit ${code}`);
394
+ this.code = code;
395
+ }
396
+ }
397
+
398
+ function parseArgs(argv: string[]): { readonly quiet: boolean } {
399
+ let quiet = false;
400
+ for (const a of argv) {
401
+ if (a === '--quiet') {
402
+ quiet = true;
403
+ } else if (a === '-h' || a === '--help') {
404
+ process.stdout.write('usage: lint_harvest_provenance [--quiet]\n');
405
+ throw new ExitCode(0);
406
+ } else {
407
+ process.stderr.write(`❌ lint_harvest_provenance: unrecognized argument: ${a}\n`);
408
+ throw new ExitCode(2);
409
+ }
410
+ }
411
+ return { quiet };
412
+ }
413
+
414
+ export function main(argv: string[] = process.argv.slice(2)): number {
415
+ const args = parseArgs(argv);
416
+ const ledgerPath = path.join(REPO, LEDGER_REL);
417
+
418
+ // An EMPTY ledger is a legitimate state — it is empty today. An ABSENT one
419
+ // is not: without this guard a moved ledger would read as zero harvests and
420
+ // every citation would surface as an orphan, or (with no citations either)
421
+ // the run would report a clean green over a file that no longer exists.
422
+ try {
423
+ assertWatchlistResolves({ gate: 'lint_harvest_provenance', candidates: [LEDGER_REL], repoRoot: REPO });
424
+ } catch (exc) {
425
+ if (exc instanceof DeadScopeError) {
426
+ process.stderr.write(`❌ ${exc.message}\n`);
427
+ return 2;
428
+ }
429
+ throw exc;
430
+ }
431
+
432
+ const text = fs.readFileSync(ledgerPath, 'utf-8');
433
+ const { records, findings } = lintLedgerText(text, REPO);
434
+ const citations = [...collectCitations(REPO), ...collectPersonaSources(REPO)];
435
+ const allFindings = [...findings, ...findOrphanCitations(citations, records)];
436
+
437
+ if (allFindings.length > 0) {
438
+ for (const f of allFindings) {
439
+ const at = f.line > 0 ? `${LEDGER_REL}:${f.line}` : LEDGER_REL;
440
+ process.stderr.write(`❌ ${at}: [${f.rule}] ${f.message}\n`);
441
+ }
442
+ process.stderr.write(
443
+ `❌ lint_harvest_provenance: ${allFindings.length} finding(s) — checks our own records, strict from day one\n`,
444
+ );
445
+ return 2;
446
+ }
447
+
448
+ if (!args.quiet) {
449
+ // Always name what was examined, including the zero case: a green line
450
+ // that does not distinguish "nothing to check" from "everything checked"
451
+ // is the failure mode this repo has already paid for once.
452
+ process.stdout.write(
453
+ `✅ lint_harvest_provenance: ${records.length} ledger row(s) OK · ` +
454
+ `${citations.length} citation(s) resolved across ${CITATION_SCAN_ROOTS.length} scanned root(s)` +
455
+ `${records.length === 0 && citations.length === 0 ? ' — ledger is empty and nothing cites it, which is a legitimate pass' : ''}\n`,
456
+ );
457
+ }
458
+ return 0;
459
+ }
460
+
461
+ /** Robust "am I the entry script?" — realpath-compares argv[1] to this file. */
462
+ function _isCliEntry(): boolean {
463
+ const a = process.argv[1];
464
+ if (!a) return false;
465
+ if (a === _FILE || pathToFileURL(path.resolve(a)).href === import.meta.url) return true;
466
+ try {
467
+ return fs.realpathSync(a) === fs.realpathSync(_FILE);
468
+ } catch {
469
+ return false;
470
+ }
471
+ }
472
+ if (_isCliEntry()) {
473
+ try {
474
+ process.exit(main());
475
+ } catch (exc) {
476
+ if (exc instanceof ExitCode) {
477
+ process.exit(exc.code);
478
+ }
479
+ throw exc;
480
+ }
481
+ }
482
+
483
+ export { REPO, LEDGER_REL, ExitCode };
@@ -55,6 +55,8 @@ const EVENT_VOCABULARY: ReadonlySet<string> = new Set([
55
55
  "stop",
56
56
  "pre_compact",
57
57
  "agent_error",
58
+ "subagent_start",
59
+ "subagent_stop",
58
60
  ]);
59
61
 
60
62
  // Known platform identifiers.