@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
@@ -0,0 +1,692 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Offline staleness + CSV-integrity gate for the grounding corpora.
4
+ *
5
+ * ## The two gaps this closes, both measured
6
+ *
7
+ * 1. **A declared refresh cadence nothing enforced.** Six corpus manifests
8
+ * declare `refresh_cadence` (`design-intelligence`, `brand`, `database`,
9
+ * `accessibility-auditor`, `api-design`, `threat-modeling`), and
10
+ * `design-intelligence` pins `upstream.last_checked: 2026-06-07` against a
11
+ * `quarterly` cadence. Nothing read either field. A cadence with no gate is a
12
+ * promise, not a control.
13
+ *
14
+ * 2. **A CSV path nothing opened.** `corpus-grounding/scripts/schema_validator.ts`
15
+ * validates that `domains.*.file` is a well-formed relative path and then
16
+ * never opens it — its only filesystem calls are an `existsSync` and a
17
+ * `readFileSync` of the MANIFEST. So a re-vendor could land a CSV that is
18
+ * missing, empty, or whose header no longer carries the declared
19
+ * `search_cols` / `output_cols`, and every gate would stay green.
20
+ *
21
+ * The ordering is load-bearing and is the roadmap's, not a preference: this gate
22
+ * lands BEFORE any re-vendor. After one, it can only certify whatever arrived.
23
+ *
24
+ * ## OFFLINE AND DETERMINISTIC BY CONSTRUCTION
25
+ *
26
+ * Manifests and CSV headers are read from disk and compared against a date. No
27
+ * subprocess, no socket, no PATH lookup. Time is the only external input, so it
28
+ * is INJECTABLE: `--today YYYY-MM-DD` overrides the clock, which is why the
29
+ * fixtures pin it. A test whose verdict flips 91 days from now is a broken test,
30
+ * not a passing one. Same doctrine as `check_reach_staleness`, whose CLI shape
31
+ * this deliberately mirrors — one staleness idiom, not two.
32
+ *
33
+ * A `last_checked` in the FUTURE is a violation of its own class rather than a
34
+ * negative age silently reading as fresh: the only ways to get one are a typo
35
+ * and a hand-edit, and both should surface.
36
+ *
37
+ * ## What it does NOT check
38
+ *
39
+ * Row semantics. A header carrying every declared column says the shape
40
+ * survived, not that the content is still true — that judgement is the corpus
41
+ * owner's and no offline gate can make it. Claiming otherwise would be the
42
+ * over-reach this file exists to replace.
43
+ *
44
+ * Exit codes: 0 clean · 1 violations · 3 unusable input (missing / unparseable
45
+ * manifest, bad `--today`, or a dead scan scope).
46
+ */
47
+ import * as fs from 'node:fs';
48
+ import * as os from 'node:os';
49
+ import * as path from 'node:path';
50
+ import { fileURLToPath, pathToFileURL } from 'node:url';
51
+
52
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
53
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
54
+
55
+ const _HERE = fileURLToPath(import.meta.url);
56
+ /** Repo root — two dirs up from src/scripts. */
57
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
58
+
59
+ /** Where corpus manifests live: `src/skills/<skill>/data/manifest.json`. */
60
+ export const SKILLS_DIR =
61
+ process.env['CHECK_CORPUS_STALENESS_SKILLS_DIR'] ?? path.join(ROOT, 'src', 'skills');
62
+
63
+ /**
64
+ * Days after which a `quarterly` corpus is stale.
65
+ *
66
+ * 100, not 90: a quarter is ~91 days and a cadence met on the day it comes due
67
+ * would otherwise red for the maintainer who honoured it. The slack is the
68
+ * grace, stated rather than hidden in an off-by-one.
69
+ */
70
+ export const QUARTERLY_STALE_DAYS = 100;
71
+
72
+ /** Days for the other declarable cadences. Absent cadence → no staleness check. */
73
+ export const CADENCE_DAYS: Readonly<Record<string, number>> = {
74
+ monthly: 40,
75
+ quarterly: QUARTERLY_STALE_DAYS,
76
+ 'semi-annual': 190,
77
+ annual: 380,
78
+ };
79
+
80
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
81
+ const MS_PER_DAY = 86_400_000;
82
+
83
+ /** Every finding carries the locator that makes it actionable without a grep. */
84
+ export interface CorpusFinding {
85
+ /** `design-intelligence` or `design-intelligence:domains.colors`. */
86
+ readonly locator: string;
87
+ /** Machine-stable violation class — tests assert on this, not on prose. */
88
+ readonly kind:
89
+ | 'stale-corpus'
90
+ | 'unparseable-date'
91
+ | 'future-date'
92
+ | 'missing-csv'
93
+ | 'empty-csv'
94
+ | 'missing-column'
95
+ | 'attribution-date-mismatch';
96
+ readonly message: string;
97
+ }
98
+
99
+ function as_object(value: unknown): Record<string, unknown> | null {
100
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
101
+ ? (value as Record<string, unknown>)
102
+ : null;
103
+ }
104
+
105
+ /**
106
+ * Parse an ISO calendar date to UTC midnight. Returns null for anything that is
107
+ * not a `YYYY-MM-DD` string, so a JSON number or a loose date is rejected rather
108
+ * than silently coerced.
109
+ */
110
+ export function parse_iso_date(value: unknown): number | null {
111
+ if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
112
+ return null;
113
+ }
114
+ const stamp = Date.parse(`${value}T00:00:00Z`);
115
+ if (Number.isNaN(stamp)) {
116
+ return null;
117
+ }
118
+ // Round-trip guard: `2026-02-31` parses in some engines but is not a date.
119
+ return new Date(stamp).toISOString().slice(0, 10) === value ? stamp : null;
120
+ }
121
+
122
+ /** Whole days between two UTC-midnight stamps. */
123
+ export function days_between(fromStamp: number, toStamp: number): number {
124
+ return Math.floor((toStamp - fromStamp) / MS_PER_DAY);
125
+ }
126
+
127
+ /** Today as `YYYY-MM-DD` in UTC — the only place the clock is read. */
128
+ export function today_iso(now: Date = new Date()): string {
129
+ return now.toISOString().slice(0, 10);
130
+ }
131
+
132
+ /**
133
+ * Split one CSV header line into field names.
134
+ *
135
+ * Quote-aware because a declared column may legitimately contain a comma. Not a
136
+ * full CSV parser and does not pretend to be one — only the header is read, and
137
+ * only to compare a set of names.
138
+ */
139
+ export function parse_csv_header(line: string): string[] {
140
+ const out: string[] = [];
141
+ let cur = '';
142
+ let quoted = false;
143
+ for (let i = 0; i < line.length; i += 1) {
144
+ const ch = line[i];
145
+ if (ch === '"') {
146
+ if (quoted && line[i + 1] === '"') {
147
+ cur += '"';
148
+ i += 1;
149
+ } else {
150
+ quoted = !quoted;
151
+ }
152
+ } else if (ch === ',' && !quoted) {
153
+ out.push(cur.trim());
154
+ cur = '';
155
+ } else {
156
+ cur += ch;
157
+ }
158
+ }
159
+ out.push(cur.trim());
160
+ // Strip a UTF-8 BOM off the first field — an editor artefact, not a column.
161
+ if (out.length > 0 && out[0] !== undefined) {
162
+ out[0] = out[0].replace(/^/, '');
163
+ }
164
+ return out;
165
+ }
166
+
167
+ export interface Corpus {
168
+ /** Skill slug — the directory name under src/skills. */
169
+ readonly slug: string;
170
+ readonly manifestPath: string;
171
+ readonly manifest: Record<string, unknown>;
172
+ }
173
+
174
+ /** Every `src/skills/<slug>/data/manifest.json` on disk, sorted by slug. */
175
+ export function discover_corpora(skillsDir: string = SKILLS_DIR): Corpus[] {
176
+ let entries: fs.Dirent[];
177
+ try {
178
+ entries = fs.readdirSync(skillsDir, { withFileTypes: true });
179
+ } catch {
180
+ return [];
181
+ }
182
+ const out: Corpus[] = [];
183
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
184
+ if (!entry.isDirectory()) {
185
+ continue;
186
+ }
187
+ const manifestPath = path.join(skillsDir, entry.name, 'data', 'manifest.json');
188
+ let raw: string;
189
+ try {
190
+ raw = fs.readFileSync(manifestPath, 'utf-8');
191
+ } catch {
192
+ continue;
193
+ }
194
+ let parsed: unknown;
195
+ try {
196
+ parsed = JSON.parse(raw);
197
+ } catch {
198
+ // A corpus whose manifest does not parse is unusable input, not a
199
+ // clean corpus — surfaced by the caller as exit 3.
200
+ throw new Error(`${path.relative(ROOT, manifestPath)}: manifest is not valid JSON`);
201
+ }
202
+ const obj = as_object(parsed);
203
+ if (obj === null) {
204
+ throw new Error(`${path.relative(ROOT, manifestPath)}: manifest is not an object`);
205
+ }
206
+ out.push({ slug: entry.name, manifestPath, manifest: obj });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** Staleness half — the declared cadence against `upstream.last_checked`. */
212
+ export function check_staleness(corpus: Corpus, todayStamp: number): CorpusFinding[] {
213
+ const provenance = as_object(corpus.manifest['provenance']) ?? corpus.manifest;
214
+ const cadence = provenance['refresh_cadence'];
215
+ if (typeof cadence !== 'string') {
216
+ // No declared cadence → nothing promised, nothing to enforce.
217
+ return [];
218
+ }
219
+ const limit = CADENCE_DAYS[cadence];
220
+ if (limit === undefined) {
221
+ return [
222
+ {
223
+ locator: corpus.slug,
224
+ kind: 'unparseable-date',
225
+ message:
226
+ `refresh_cadence: "${cadence}" is not one of ` +
227
+ `${Object.keys(CADENCE_DAYS).join(' | ')} — a cadence this gate cannot ` +
228
+ `read is a cadence it cannot enforce`,
229
+ },
230
+ ];
231
+ }
232
+ const upstream = as_object(provenance['upstream']);
233
+ if (upstream === null) {
234
+ // `upstream: null` is legitimate — a corpus this project authored has no
235
+ // upstream to re-check. brand/data/manifest.json is exactly that case.
236
+ return [];
237
+ }
238
+ const stamp = parse_iso_date(upstream['last_checked']);
239
+ if (stamp === null) {
240
+ return [
241
+ {
242
+ locator: `${corpus.slug}:upstream.last_checked`,
243
+ kind: 'unparseable-date',
244
+ message:
245
+ `declares refresh_cadence: ${cadence} but upstream.last_checked is ` +
246
+ `${JSON.stringify(upstream['last_checked'])} — expected YYYY-MM-DD`,
247
+ },
248
+ ];
249
+ }
250
+ const age = days_between(stamp, todayStamp);
251
+ if (age < 0) {
252
+ return [
253
+ {
254
+ locator: `${corpus.slug}:upstream.last_checked`,
255
+ kind: 'future-date',
256
+ message: `upstream.last_checked is ${String(upstream['last_checked'])}, ${String(-age)} day(s) in the future`,
257
+ },
258
+ ];
259
+ }
260
+ if (age > limit) {
261
+ return [
262
+ {
263
+ locator: `${corpus.slug}:upstream.last_checked`,
264
+ kind: 'stale-corpus',
265
+ message:
266
+ `last checked ${String(upstream['last_checked'])} — ${String(age)} days ago, over the ` +
267
+ `${String(limit)}-day bound for refresh_cadence: ${cadence}. Re-check upstream and ` +
268
+ `update last_checked in BOTH data/manifest.json and ATTRIBUTION.md`,
269
+ },
270
+ ];
271
+ }
272
+ return [];
273
+ }
274
+
275
+ /**
276
+ * The human-readable twin can drift from the machine one.
277
+ *
278
+ * `ATTRIBUTION.md` carries `Last checked: <date>` and the manifest carries
279
+ * `upstream.last_checked`. Two copies of one fact is a drift source, and this is
280
+ * the cheap half of the fix — detect it. (Deriving one from the other would be
281
+ * the other half and is not this gate's business.)
282
+ */
283
+ export function check_attribution_pin(corpus: Corpus): CorpusFinding[] {
284
+ const provenance = as_object(corpus.manifest['provenance']) ?? corpus.manifest;
285
+ const upstream = as_object(provenance['upstream']);
286
+ const declared = upstream?.['last_checked'];
287
+ if (typeof declared !== 'string') {
288
+ return [];
289
+ }
290
+ const attribution = path.join(path.dirname(path.dirname(corpus.manifestPath)), 'ATTRIBUTION.md');
291
+ let text: string;
292
+ try {
293
+ text = fs.readFileSync(attribution, 'utf-8');
294
+ } catch {
295
+ // No ATTRIBUTION.md → nothing to disagree with.
296
+ return [];
297
+ }
298
+ const m = /^[-*]?\s*Last checked:\s*(\S+)\s*$/m.exec(text);
299
+ if (m === null || m[1] === undefined) {
300
+ return [];
301
+ }
302
+ if (m[1] !== declared) {
303
+ return [
304
+ {
305
+ locator: `${corpus.slug}:ATTRIBUTION.md`,
306
+ kind: 'attribution-date-mismatch',
307
+ message:
308
+ `ATTRIBUTION.md says "Last checked: ${m[1]}" while data/manifest.json says ` +
309
+ `upstream.last_checked: ${declared} — one fact, two copies, already diverged`,
310
+ },
311
+ ];
312
+ }
313
+ return [];
314
+ }
315
+
316
+ interface CsvScan {
317
+ readonly findings: CorpusFinding[];
318
+ /** How many CSV files were actually opened — the scan-scope unit. */
319
+ readonly opened: number;
320
+ }
321
+
322
+ /**
323
+ * Integrity half — every declared CSV exists, is non-empty, and its header
324
+ * carries every declared column.
325
+ *
326
+ * This is the check `schema_validator.ts` stops short of: it computes the path
327
+ * and refuses an escape, then never opens the file.
328
+ */
329
+ export function check_csv_integrity(corpus: Corpus): CsvScan {
330
+ const findings: CorpusFinding[] = [];
331
+ let opened = 0;
332
+ const dataDir = path.dirname(corpus.manifestPath);
333
+
334
+ const domains = as_object(corpus.manifest['domains']) ?? {};
335
+ const stacks = as_object(corpus.manifest['stacks']) ?? {};
336
+
337
+ /** `{ relative-csv-path: required column names }`, merged per file. */
338
+ const required = new Map<string, Set<string>>();
339
+ const note = (rel: unknown, cols: readonly unknown[]): void => {
340
+ if (typeof rel !== 'string' || rel === '') {
341
+ return;
342
+ }
343
+ const set = required.get(rel) ?? new Set<string>();
344
+ for (const c of cols) {
345
+ if (typeof c === 'string' && c !== '') {
346
+ set.add(c);
347
+ }
348
+ }
349
+ required.set(rel, set);
350
+ };
351
+
352
+ for (const spec of Object.values(domains)) {
353
+ const d = as_object(spec);
354
+ if (d === null) {
355
+ continue;
356
+ }
357
+ const search = Array.isArray(d['search_cols']) ? d['search_cols'] : [];
358
+ const output = Array.isArray(d['output_cols']) ? d['output_cols'] : [];
359
+ const stack = Array.isArray(d['stack_cols']) ? d['stack_cols'] : [];
360
+ note(d['file'], [...search, ...output, ...stack]);
361
+ }
362
+ const reasoning = as_object(corpus.manifest['reasoning']);
363
+ if (reasoning !== null) {
364
+ const mc = reasoning['match_column'];
365
+ note(reasoning['file'], typeof mc === 'string' ? [mc] : []);
366
+ }
367
+ for (const rel of Object.values(stacks)) {
368
+ // Stack entries are `stack-id → csv path`; their columns are not declared
369
+ // per stack, so only existence and non-emptiness are checkable.
370
+ note(rel, []);
371
+ }
372
+
373
+ for (const rel of [...required.keys()].sort()) {
374
+ const cols = required.get(rel) ?? new Set<string>();
375
+ const abs = path.join(dataDir, rel);
376
+ let text: string;
377
+ try {
378
+ text = fs.readFileSync(abs, 'utf-8');
379
+ } catch {
380
+ findings.push({
381
+ locator: `${corpus.slug}:${rel}`,
382
+ kind: 'missing-csv',
383
+ message: `declared in data/manifest.json but not present on disk`,
384
+ });
385
+ continue;
386
+ }
387
+ opened += 1;
388
+ const lines = text.split('\n').filter((l) => l.trim() !== '');
389
+ if (lines.length === 0) {
390
+ findings.push({
391
+ locator: `${corpus.slug}:${rel}`,
392
+ kind: 'empty-csv',
393
+ message: 'file is empty — no header, no rows',
394
+ });
395
+ continue;
396
+ }
397
+ if (lines.length === 1) {
398
+ findings.push({
399
+ locator: `${corpus.slug}:${rel}`,
400
+ kind: 'empty-csv',
401
+ message: 'header only, zero data rows — a corpus of nothing reads exactly like a clean one',
402
+ });
403
+ continue;
404
+ }
405
+ const header = new Set(parse_csv_header(lines[0] as string));
406
+ const missing = [...cols].filter((c) => !header.has(c)).sort();
407
+ if (missing.length > 0) {
408
+ findings.push({
409
+ locator: `${corpus.slug}:${rel}`,
410
+ kind: 'missing-column',
411
+ message:
412
+ `header is missing declared column(s) ${missing.join(', ')} — ` +
413
+ `the manifest promises them to every consumer of this corpus`,
414
+ });
415
+ }
416
+ }
417
+ return { findings, opened };
418
+ }
419
+
420
+ export interface RunResult {
421
+ readonly findings: CorpusFinding[];
422
+ /** Corpora inspected — the scan-scope unit. */
423
+ readonly corpora: number;
424
+ /** CSVs actually opened, reported so "checked nothing" cannot read as clean. */
425
+ readonly csvs: number;
426
+ }
427
+
428
+ export function run_checks(todayStamp: number, skillsDir: string = SKILLS_DIR): RunResult {
429
+ const corpora = discover_corpora(skillsDir);
430
+ const findings: CorpusFinding[] = [];
431
+ let csvs = 0;
432
+ for (const corpus of corpora) {
433
+ findings.push(...check_staleness(corpus, todayStamp));
434
+ findings.push(...check_attribution_pin(corpus));
435
+ const csv = check_csv_integrity(corpus);
436
+ findings.push(...csv.findings);
437
+ csvs += csv.opened;
438
+ }
439
+ return { findings, corpora: corpora.length, csvs };
440
+ }
441
+
442
+ const HELP = `check_corpus_staleness — offline staleness + CSV-integrity gate for the grounding corpora
443
+
444
+ Usage:
445
+ tsx src/scripts/check_corpus_staleness.ts [options]
446
+
447
+ Options:
448
+ --today <YYYY-MM-DD> Reference date (default: the real UTC date). Time is the
449
+ only external input, so it is injectable — the fixtures
450
+ pin it instead of expiring on a calendar boundary.
451
+ --quiet Suppress the success line
452
+ --help This text
453
+
454
+ Checks (all offline — no network, no subprocess):
455
+ stale-corpus upstream.last_checked older than the declared refresh_cadence
456
+ future-date upstream.last_checked in the future (typo or hand-edit)
457
+ unparseable-date a cadence this gate cannot read, or a non-ISO last_checked
458
+ attribution-date-mismatch ATTRIBUTION.md and data/manifest.json disagree
459
+ missing-csv a declared CSV is not on disk
460
+ empty-csv a declared CSV has no rows (header-only counts)
461
+ missing-column a declared search/output/stack/match column is not in the header
462
+
463
+ Cadence bounds: ${Object.entries(CADENCE_DAYS).map(([k, v]) => `${k}=${String(v)}d`).join(' · ')}
464
+ A corpus with no refresh_cadence, or with upstream: null, is exempt by declaration.
465
+
466
+ Exit codes: 0 clean · 1 violations · 3 unusable input (unparseable manifest, bad --today, dead scope).
467
+ `;
468
+
469
+ /**
470
+ * `--self-test` — drives the REAL binary over synthetic corpora.
471
+ *
472
+ * Not optional for a new gate: `gate-self-test:registered-non-adopters` is a
473
+ * shrink-only count over the enforced manifest, so registering this gate without
474
+ * a self-test would raise it and red CI. That is the ratchet working as designed
475
+ * — the cheapest moment to prove a gate discriminates is when it is written.
476
+ */
477
+ export function selfTest(): number {
478
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-'));
479
+ /** Build a one-corpus skills tree and return its root. */
480
+ const mk = (manifest: unknown, files: Readonly<Record<string, string>> = {}): string => {
481
+ const root = fs.mkdtempSync(path.join(tmp, 'skills-'));
482
+ const dataDir = path.join(root, 'c', 'data');
483
+ fs.mkdirSync(dataDir, { recursive: true });
484
+ fs.writeFileSync(path.join(dataDir, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
485
+ for (const [rel, body] of Object.entries(files)) {
486
+ const p = path.join(dataDir, rel);
487
+ fs.mkdirSync(path.dirname(p), { recursive: true });
488
+ fs.writeFileSync(p, body, 'utf-8');
489
+ }
490
+ return root;
491
+ };
492
+ const run = (root: string): number => {
493
+ process.env['CHECK_CORPUS_STALENESS_SKILLS_DIR'] = root;
494
+ try {
495
+ return runGateCli(
496
+ ROOT,
497
+ 'src/scripts/check_corpus_staleness.ts',
498
+ ['--quiet', '--today', '2026-08-06'],
499
+ ROOT,
500
+ );
501
+ } finally {
502
+ delete process.env['CHECK_CORPUS_STALENESS_SKILLS_DIR'];
503
+ }
504
+ };
505
+ const fresh = { refresh_cadence: 'quarterly', upstream: { last_checked: '2026-07-01' } };
506
+ const csv = 'a,b\n1,2\n';
507
+ try {
508
+ return runSelfTest({
509
+ gate: 'check_corpus_staleness',
510
+ minCases: 3,
511
+ minRejectCases: 2,
512
+ cases: [
513
+ {
514
+ name: 'a corpus inside its cadence with a complete CSV passes',
515
+ expect: 'accept',
516
+ run: () =>
517
+ run(
518
+ mk(
519
+ { ...fresh, domains: { d: { file: 'x.csv', search_cols: ['a'], output_cols: ['b'] } } },
520
+ { 'x.csv': csv },
521
+ ),
522
+ ),
523
+ },
524
+ {
525
+ name: 'a corpus past its declared cadence is rejected',
526
+ expect: 'reject',
527
+ run: () =>
528
+ run(
529
+ mk(
530
+ {
531
+ refresh_cadence: 'quarterly',
532
+ upstream: { last_checked: '2026-01-01' },
533
+ domains: { d: { file: 'x.csv', search_cols: ['a'], output_cols: ['b'] } },
534
+ },
535
+ { 'x.csv': csv },
536
+ ),
537
+ ),
538
+ },
539
+ {
540
+ name: 'a declared CSV missing from disk is rejected — the gap schema_validator leaves',
541
+ expect: 'reject',
542
+ run: () => run(mk({ ...fresh, domains: { d: { file: 'gone.csv', search_cols: ['a'], output_cols: ['b'] } } })),
543
+ },
544
+ {
545
+ name: 'a header missing a declared column is rejected',
546
+ expect: 'reject',
547
+ run: () =>
548
+ run(
549
+ mk(
550
+ { ...fresh, domains: { d: { file: 'x.csv', search_cols: ['nope'], output_cols: ['b'] } } },
551
+ { 'x.csv': csv },
552
+ ),
553
+ ),
554
+ },
555
+ {
556
+ name: 'a header-only CSV is rejected — zero rows reads exactly like a clean corpus',
557
+ expect: 'reject',
558
+ run: () =>
559
+ run(
560
+ mk(
561
+ { ...fresh, domains: { d: { file: 'x.csv', search_cols: ['a'], output_cols: ['b'] } } },
562
+ { 'x.csv': 'a,b\n' },
563
+ ),
564
+ ),
565
+ },
566
+ {
567
+ name: 'upstream: null is exempt by declaration — a corpus this project authored',
568
+ expect: 'accept',
569
+ run: () =>
570
+ run(
571
+ mk(
572
+ { refresh_cadence: 'quarterly', upstream: null, domains: { d: { file: 'x.csv', search_cols: ['a'], output_cols: ['b'] } } },
573
+ { 'x.csv': csv },
574
+ ),
575
+ ),
576
+ },
577
+ {
578
+ name: 'an empty skills tree is a DEAD SCOPE (exit 3), never a clean pass',
579
+ expect: 'reject',
580
+ run: () => run(fs.mkdtempSync(path.join(tmp, 'empty-'))),
581
+ },
582
+ ],
583
+ });
584
+ } finally {
585
+ fs.rmSync(tmp, { recursive: true, force: true });
586
+ }
587
+ }
588
+
589
+ export function main(argv: string[] = process.argv.slice(2)): number {
590
+ if (argv.includes('--help') || argv.includes('-h')) {
591
+ process.stdout.write(HELP);
592
+ return 0;
593
+ }
594
+ if (argv.includes('--self-test')) {
595
+ return selfTest();
596
+ }
597
+ const quiet = argv.includes('--quiet');
598
+
599
+ let today: string | undefined;
600
+ for (let index = 0; index < argv.length; index += 1) {
601
+ const arg = argv[index];
602
+ if (arg === undefined) {
603
+ continue;
604
+ }
605
+ if (arg === '--today') {
606
+ const value = argv[index + 1];
607
+ if (!value) {
608
+ process.stderr.write('check_corpus_staleness: --today needs a date\n');
609
+ return 3;
610
+ }
611
+ today = value;
612
+ index += 1;
613
+ } else if (arg.startsWith('--today=')) {
614
+ today = arg.slice('--today='.length);
615
+ } else if (arg !== '--quiet') {
616
+ process.stderr.write(`check_corpus_staleness: unrecognized argument: ${arg}\n`);
617
+ return 3;
618
+ }
619
+ }
620
+
621
+ const todayStamp = parse_iso_date(today ?? today_iso());
622
+ if (todayStamp === null) {
623
+ process.stderr.write(`check_corpus_staleness: --today must be YYYY-MM-DD, got ${String(today)}\n`);
624
+ return 3;
625
+ }
626
+
627
+ let result: RunResult;
628
+ try {
629
+ result = run_checks(todayStamp);
630
+ } catch (err) {
631
+ process.stderr.write(`❌ ${String(err instanceof Error ? err.message : err)}\n`);
632
+ return 3;
633
+ }
634
+
635
+ // No `allowEmpty`: zero corpora means the gate could not run, and that reads
636
+ // exactly like a clean tree. Exit 3 (unusable input) over 0.
637
+ try {
638
+ assertScanned({
639
+ gate: 'check_corpus_staleness',
640
+ scanned: result.corpora,
641
+ units: 'corpus manifest(s)',
642
+ roots: [`${path.relative(ROOT, SKILLS_DIR)}/*/data/manifest.json`],
643
+ });
644
+ } catch (err) {
645
+ if (err instanceof DeadScopeError) {
646
+ process.stderr.write(`❌ ${err.message}\n`);
647
+ return 3;
648
+ }
649
+ throw err;
650
+ }
651
+
652
+ // gate-coverage contract (src/config/gate-coverage.yml): corpora inspected.
653
+ process.stdout.write(`scanned: ${String(result.corpora)}\n`);
654
+
655
+ if (result.findings.length > 0) {
656
+ process.stdout.write(`❌ ${String(result.findings.length)} corpus finding(s):\n`);
657
+ for (const f of result.findings) {
658
+ process.stdout.write(` - [${f.kind}] ${f.locator}: ${f.message}\n`);
659
+ }
660
+ return 1;
661
+ }
662
+
663
+ if (!quiet) {
664
+ process.stdout.write(
665
+ `✅ ${String(result.corpora)} corpus manifest(s), ${String(result.csvs)} CSV(s) opened: ` +
666
+ `every declared cadence is met, every declared CSV exists with its declared columns ` +
667
+ `(offline check, reference date ${today ?? today_iso()}).\n`,
668
+ );
669
+ }
670
+ return 0;
671
+ }
672
+
673
+ function _isCliEntry(): boolean {
674
+ if (!process.argv[1]) {
675
+ return false;
676
+ }
677
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
678
+ if (import.meta.url === argvUrl) {
679
+ return true;
680
+ }
681
+ try {
682
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
683
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
684
+ return here === argv;
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+
690
+ if (_isCliEntry()) {
691
+ process.exit(main());
692
+ }