@event4u/agent-config 13.0.0 → 14.1.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +195 -315
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +336 -112
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2128 -1326
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1764 -899
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +5 -5
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gate_ledger.ts +5 -1
  75. package/src/scripts/_lib/gh_transient.ts +80 -0
  76. package/src/scripts/_lib/release_material.ts +16 -5
  77. package/src/scripts/_lib/review_independence.ts +111 -0
  78. package/src/scripts/_lib/session_register.ts +14 -0
  79. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  80. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  81. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  82. package/src/scripts/ai_council/_py_json.ts +96 -0
  83. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  84. package/src/scripts/ai_council/clients.ts +80 -143
  85. package/src/scripts/ai_council/config.ts +25 -11
  86. package/src/scripts/ai_council/probe_store.ts +196 -0
  87. package/src/scripts/ai_council/qualification.ts +425 -0
  88. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  89. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  90. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  91. package/src/scripts/build_archive_index.ts +62 -3
  92. package/src/scripts/capture_skill_catalogue.ts +264 -4
  93. package/src/scripts/check_evaluator_schema.ts +249 -0
  94. package/src/scripts/check_gate_coverage.ts +93 -0
  95. package/src/scripts/check_pr_ci_current.ts +492 -0
  96. package/src/scripts/check_review_schema.ts +195 -0
  97. package/src/scripts/check_rule_activation_census.ts +451 -0
  98. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  99. package/src/scripts/condense.ts +1 -1
  100. package/src/scripts/council_cli.ts +141 -191
  101. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  102. package/src/scripts/git_authorization_hook.ts +186 -1
  103. package/src/scripts/hook_manifest.yaml +72 -5
  104. package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
  105. package/src/scripts/hooks/concern_registry.ts +4 -0
  106. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  107. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  108. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  109. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  110. package/src/scripts/hooks_doctor.ts +112 -0
  111. package/src/scripts/interruption_report.ts +443 -0
  112. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  113. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  114. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  115. package/src/scripts/release.ts +38 -53
  116. package/src/scripts/rule_activation_census.ts +292 -0
  117. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  118. package/src/scripts/schemas/review-findings.schema.json +31 -0
  119. package/src/scripts/self_review_gate.ts +6 -0
  120. package/src/scripts/session_register_hook.ts +30 -1
  121. package/src/scripts/sync_pr_branch.ts +271 -0
  122. package/src/scripts/test_council_qualification.ts +213 -0
  123. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -86,16 +86,27 @@ import {
86
86
  parseHostBudgetEvent,
87
87
  readObservationLog,
88
88
  readProjectedCatalogue,
89
+ resolveSkillsRoot,
89
90
  type HostProjectionRow,
90
91
  type ProjectionMode,
91
92
  type ProjectionModeCounts,
92
93
  type SelectorReport,
93
94
  } from './_lib/skill_catalogue.js';
95
+ import {
96
+ cadenceStatus,
97
+ formatCadenceStatus,
98
+ formatPointableBare,
99
+ joinPointableBare,
100
+ scopeFlagDecision,
101
+ } from './_lib/skill_catalogue_series.js';
94
102
  import { scoped_projection_stats } from './_lib/scoped_projection.js';
95
103
  import { iter_skills } from './update_counts.js';
96
104
 
97
- // Re-exported so the CLI module stays the one public name for this tool.
105
+ // Re-exported so the CLI module stays the one public name for this tool —
106
+ // both halves, so the split into a series layer is an internal seam and not a
107
+ // second import path every consumer has to learn.
98
108
  export * from './_lib/skill_catalogue.js';
109
+ export * from './_lib/skill_catalogue_series.js';
99
110
 
100
111
  const _HERE = fileURLToPath(import.meta.url);
101
112
  const REPO = path.resolve(path.dirname(_HERE), '..', '..');
@@ -151,6 +162,230 @@ function expandHome(p: string): string {
151
162
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
152
163
  }
153
164
 
165
+ /**
166
+ * `--today <ISO>` for a deterministic run, else the machine's own date.
167
+ *
168
+ * Range-checked by round-trip, not by shape alone. `2026-13-45` matches the
169
+ * pattern and then parses to `NaN`, which reaches `cadenceStatus` and makes
170
+ * every host report `stamp unparseable (<the record's date>)` — a diagnostic
171
+ * blaming the corpus for an operator typo.
172
+ */
173
+ function argToday(): string {
174
+ const explicit = argValue('--today');
175
+ if (explicit) {
176
+ const parsed = new Date(`${explicit}T00:00:00Z`);
177
+ if (
178
+ !/^\d{4}-\d{2}-\d{2}$/.test(explicit) ||
179
+ Number.isNaN(parsed.getTime()) ||
180
+ parsed.toISOString().slice(0, 10) !== explicit
181
+ ) {
182
+ throw new Error(`--today must be a real ISO date (YYYY-MM-DD), got: ${explicit}`);
183
+ }
184
+ return explicit;
185
+ }
186
+ return new Date().toISOString().slice(0, 10);
187
+ }
188
+
189
+ /**
190
+ * `--cadence` — is an observation due on any host, and how far is the corpus?
191
+ *
192
+ * The recurring half of the capture. It reports and prints the next command; it
193
+ * never records anything itself. Two reasons, both deliberate: recording is what
194
+ * `--host-event --record` and `--observed --record` already do, and a mode that
195
+ * both decides freshness AND writes a record could refresh a series with a
196
+ * reading nobody took.
197
+ *
198
+ * The asymmetry between the hosts is printed rather than smoothed over. The
199
+ * codex round is one shell pipeline and can be scheduled; the claude round needs
200
+ * an agent to read its own delivered context, which no scheduler can do.
201
+ */
202
+ function runCadenceMode(): number {
203
+ const today = argToday();
204
+ const records = readObservationLog(path.join(REPO, OBSERVATION_LOG));
205
+ const rows = cadenceStatus(records, today);
206
+
207
+ if (process.argv.includes('--json')) {
208
+ process.stdout.write(
209
+ `${JSON.stringify(
210
+ {
211
+ today,
212
+ // `daysSince` is NaN when a record's stamp is unparseable, and
213
+ // `JSON.stringify(NaN)` emits `null` — indistinguishable from an
214
+ // absent field to a machine consumer. This module states every
215
+ // other absence explicitly, so this one is stated too.
216
+ hosts: rows.map((row) => ({
217
+ ...row,
218
+ daysSince: Number.isNaN(row.daysSince) ? null : row.daysSince,
219
+ daysSinceKnown: !Number.isNaN(row.daysSince),
220
+ })),
221
+ },
222
+ null,
223
+ 2,
224
+ )}\n`,
225
+ );
226
+ return 0;
227
+ }
228
+
229
+ process.stdout.write(`${formatCadenceStatus(rows, today)}\n`);
230
+
231
+ if (rows.length === 0) {
232
+ // The one state in which every host is maximally overdue is the state
233
+ // with no host to name: `cadenceStatus` only knows hosts it has already
234
+ // seen. Saying so beats printing a "next round" header over nothing.
235
+ process.stdout.write(
236
+ '\nno host is known yet, so no next command can be named — the corpus has never been\n' +
237
+ 'written to. Take a first observation on any host, then this mode has a series to keep current.\n',
238
+ );
239
+ return 0;
240
+ }
241
+
242
+ const due = rows.filter((row) => row.due);
243
+ if (due.length === 0) return 0;
244
+
245
+ // ONE walk, hoisted: `projectionModeCounts` parses the frontmatter of every
246
+ // skill in the tree, and the counts are a property of the package, not of
247
+ // the host being reported — calling it per due host walked the tree twice
248
+ // for a value that cannot differ, against its own docstring.
249
+ const counts = projectionModeCounts();
250
+
251
+ process.stdout.write('\nnext round — run what is due:\n\n');
252
+ for (const row of due) {
253
+ const scopeFlag = projectionModeFlagFor(row.host, counts);
254
+ // Keyed on the HOST, never on the latest record's source. Only codex is
255
+ // known to publish a parseable budget event, and `row.source` comes from
256
+ // a single headline record whose same-date tie `_supersedes` breaks on
257
+ // `dropped_count` — a self-report record has none, so one same-date
258
+ // host-event row could flip a self-report host onto the codex pipeline
259
+ // and record codex's truncation under another host's name.
260
+ if (row.host === 'codex') {
261
+ process.stdout.write(
262
+ ` # ${row.host} — deterministic, read off the host's own JSON channel\n` +
263
+ ` codex exec --json --skip-git-repo-check "reply with exactly: OK" \\\n` +
264
+ ` | agent-config exec capture_skill_catalogue \\\n` +
265
+ ` --host-event - --host ${row.host} --host-root ~/.${row.host} \\\n` +
266
+ ` ${scopeFlag.flag}--record --observed-at ${today}\n` +
267
+ `${scopeFlag.note}\n`,
268
+ );
269
+ continue;
270
+ }
271
+ process.stdout.write(
272
+ ` # ${row.host} — self-report; only an agent reading its own context can produce it.\n` +
273
+ ` # Write the entries seen bare and the ones seen described, one name per line, to\n` +
274
+ ` # agents/evidence/metrics/skill-catalogue/${today}-${row.host}-{bare,described}.txt\n` +
275
+ ` agent-config exec capture_skill_catalogue \\\n` +
276
+ ` --observed agents/evidence/metrics/skill-catalogue/${today}-${row.host}-bare.txt \\\n` +
277
+ ` --described agents/evidence/metrics/skill-catalogue/${today}-${row.host}-described.txt \\\n` +
278
+ ` --host ${row.host} ${scopeFlag.flag}--record --observed-at ${today}\n` +
279
+ `${scopeFlag.note}\n`,
280
+ );
281
+ }
282
+ return 0;
283
+ }
284
+
285
+ /**
286
+ * The `--projection-mode` flag for one host's round, MEASURED off its installed
287
+ * root — or omitted, with the reason, when the root carries neither mode count.
288
+ *
289
+ * Printing `--projection-mode <scoped|legacy-all>` as a placeholder invites the
290
+ * operator to pick one, and on an `indeterminate` root either pick is a label
291
+ * nobody measured. Measured 2026-08-18 on this machine: `~/.codex` holds 297
292
+ * skills against a scoped projection of 219 and a legacy-all of 290 — it matches
293
+ * neither, because it is a stale install rather than a broken one. So the honest
294
+ * output for that root is no flag and a stated reason, which keeps the record's
295
+ * absent-scope field meaning "not determinable" instead of "not asked".
296
+ */
297
+ function projectionModeFlagFor(host: string, counts: ProjectionModeCounts): { flag: string; note: string } {
298
+ const root = expandHome(`~/.${host}`);
299
+ const exists = fs.existsSync(root);
300
+ const decision = scopeFlagDecision(
301
+ root,
302
+ exists,
303
+ exists ? classifyHostProjection(measureCatalogueVolume(host, root), counts) : null,
304
+ );
305
+ const wrapped = decision.reason
306
+ .split(/(?<=\.) (?=[A-Z])/)
307
+ .map((part) => ` # ${part}`)
308
+ .join('\n');
309
+ return decision.mode === null
310
+ ? { flag: '', note: ` # no --projection-mode:\n${wrapped}\n` }
311
+ : { flag: `--projection-mode ${decision.mode} `, note: `${wrapped}\n` };
312
+ }
313
+
314
+ /**
315
+ * `--pointable-bare` — the D-4 join: host truth against disk truth.
316
+ *
317
+ * Intersects the bare names of every per-entry observation with the catalogue
318
+ * the runtime ranker reads, and publishes how many skills the ranker can name
319
+ * while the model never received their description.
320
+ *
321
+ * `--catalogue-root` is honoured here, through the SAME resolver the main path
322
+ * uses. Two resolvers over one catalogue is how the ranker and the tool that
323
+ * reports on it start reading different trees — `resolveSkillsRoot`'s own
324
+ * docstring names that hazard — and silently ignoring the flag in one mode is
325
+ * exactly that split inside a single invocation.
326
+ */
327
+ function runPointableBareMode(): number {
328
+ const explicitRoot = argValue('--catalogue-root');
329
+ const rankerRoot = explicitRoot ? resolveCatalogueRoot(explicitRoot) : resolveSkillsRoot(REPO);
330
+ if (rankerRoot === null) {
331
+ process.stderr.write(
332
+ '❌ no catalogue root resolved for the ranker — tried ' +
333
+ `${DEFAULT_CATALOGUE_ROOTS.join(', ')} under ${REPO}.\n` +
334
+ ' An empty catalogue is never a clean join: it would report 0 pointable\n' +
335
+ ' entries because nothing was read, not because nothing diverged.\n',
336
+ );
337
+ return 1;
338
+ }
339
+ const catalogueNames = readProjectedCatalogue(rankerRoot).map((entry) => entry.name);
340
+ // The guard the error text above already promised, and did not have. A
341
+ // present-but-empty or half-generated projection resolves fine — the
342
+ // resolver returns the first EXISTING directory, not the first non-empty
343
+ // one — and then every row scores 0 pointable off a catalogue nobody read,
344
+ // printing a clean D-4 verdict. That is the zero-inferred-from-silence this
345
+ // module's header forbids outright, and the sibling path guards it the same
346
+ // way.
347
+ if (catalogueNames.length === 0) {
348
+ process.stderr.write(
349
+ `❌ the ranker catalogue at ${rankerRoot} holds no entries.\n` +
350
+ ' An empty catalogue is never a clean join: every observation would score 0\n' +
351
+ ' pointable because nothing was read, not because nothing diverged.\n',
352
+ );
353
+ return 1;
354
+ }
355
+ const records = readObservationLog(path.join(REPO, OBSERVATION_LOG));
356
+ const join = joinPointableBare(records, catalogueNames);
357
+
358
+ if (process.argv.includes('--json')) {
359
+ process.stdout.write(
360
+ `${JSON.stringify(
361
+ {
362
+ ranker_catalogue_root: path.relative(REPO, rankerRoot) || rankerRoot,
363
+ ranker_catalogue_entries: catalogueNames.length,
364
+ // The disk side carries the same scope discipline the host
365
+ // side does: `pointableBare` grows with a stale-large root,
366
+ // so which projection the join ran against is recorded
367
+ // rather than left to the entry count to imply.
368
+ ranker_catalogue_projection: classifyHostProjection(
369
+ measureCatalogueVolume('ranker', rankerRoot),
370
+ projectionModeCounts(),
371
+ ).matches,
372
+ skipped_non_per_entry: join.skippedNonPerEntry,
373
+ skipped_malformed: join.skippedMalformed,
374
+ observations: join.rows,
375
+ },
376
+ null,
377
+ 2,
378
+ )}\n`,
379
+ );
380
+ return 0;
381
+ }
382
+
383
+ process.stdout.write(
384
+ `${formatPointableBare(join, path.relative(REPO, rankerRoot) || rankerRoot, catalogueNames.length)}\n`,
385
+ );
386
+ return 0;
387
+ }
388
+
154
389
  /**
155
390
  * `--limits` — publish the per-host picture: what each host was measured to
156
391
  * deliver, and whether the observed hosts truncate the same way.
@@ -196,14 +431,19 @@ function runLimitsMode(): number {
196
431
  * same pair of numbers in one run. Each host is reported on its own row: no
197
432
  * host's count is ever used to say anything about another's.
198
433
  */
199
- function runProjectionModesMode(): number {
434
+ /** The package's two projection-mode counts, one walk. Shared by both modes. */
435
+ function projectionModeCounts(): ProjectionModeCounts {
200
436
  const stats = scoped_projection_stats(REPO, iter_skills());
201
- const counts: ProjectionModeCounts = {
437
+ return {
202
438
  scoped: stats.projected,
203
439
  legacyAll: stats.total,
204
440
  prunedUnderScoped: stats.pruned,
205
441
  activePacks: stats.active_packs,
206
442
  };
443
+ }
444
+
445
+ function runProjectionModesMode(): number {
446
+ const counts = projectionModeCounts();
207
447
 
208
448
  const rows: HostProjectionRow[] = [];
209
449
  for (const rootArg of argValues('--host-root')) {
@@ -350,6 +590,8 @@ function runHostEventMode(source: string): number {
350
590
  }
351
591
 
352
592
  function main(): number {
593
+ if (process.argv.includes('--cadence')) return runCadenceMode();
594
+ if (process.argv.includes('--pointable-bare')) return runPointableBareMode();
353
595
  if (process.argv.includes('--limits')) return runLimitsMode();
354
596
  if (process.argv.includes('--projection-modes')) return runProjectionModesMode();
355
597
 
@@ -425,9 +667,27 @@ function main(): number {
425
667
  }
426
668
  const logPath = path.join(REPO, OBSERVATION_LOG);
427
669
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
670
+ const projectionMode = argProjectionMode();
671
+ if (projectionMode === undefined) {
672
+ // A WARNING, not a refusal. Recording without the scope is a
673
+ // legitimate reading — the record type says absence is not
674
+ // `legacy-all` and a consumer comparing modes skips it — so
675
+ // rejecting it would refuse an observation to protect a
676
+ // comparison. But an unscoped record is silently incomparable,
677
+ // which is the failure this line exists to make loud.
678
+ process.stdout.write(
679
+ '\n⚠️ no --projection-mode given: this record will carry no projection scope.\n' +
680
+ ' Absence is NOT `legacy-all` — a mode comparison skips a record that\n' +
681
+ ' carries none, so the observation stays outside that series. Pass\n' +
682
+ ' --projection-mode <scoped|legacy-all> to keep it comparable.\n',
683
+ );
684
+ }
428
685
  fs.appendFileSync(
429
686
  logPath,
430
- `${JSON.stringify(buildObservationRecord(report, host, stampedAt))}\n`,
687
+ // The catalogue root IS the skill tree, so every projected entry is
688
+ // a skill: the count is file-measured, not derived from the
689
+ // artefact total the way a host root's would be.
690
+ `${JSON.stringify(buildObservationRecord(report, host, stampedAt, projected.length, projectionMode))}\n`,
431
691
  );
432
692
  process.stdout.write(`\nrecorded → ${OBSERVATION_LOG}\n`);
433
693
  }
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Gate — every registered evaluator emits output conforming to
4
+ * `evaluator-output.schema.json`, over recorded verifier fixtures.
5
+ *
6
+ * Ships in the SAME change as the schema on purpose: the estate's standing
7
+ * constraint is that a convention without a machine backstop is dead text, so a
8
+ * schema landing without its check would be exactly the failure the roadmap's
9
+ * Risk-Register rank-5 names.
10
+ *
11
+ * WHY FIXTURES RATHER THAN LIVE RUNS. The adapters are pure functions from a
12
+ * verifier's captured `{stdout, stderr, exitCode}` to the contract shape, so the
13
+ * mapping — the part that can be wrong — is fully exercised without re-running
14
+ * three verifiers CI already runs once. Running them here would double their CI
15
+ * cost to test code that never touches them. `--live` re-records nothing and is
16
+ * not offered: a fixture that drifts from its verifier is caught by that
17
+ * verifier's own gate changing shape, which is a review event, not a silent one.
18
+ *
19
+ * The corpus is (evaluator x fixture) pairs, and it includes deliberately RED
20
+ * and DEGRADED fixtures — a gate that only ever sees green output cannot tell a
21
+ * working mapping from one that hardcodes success.
22
+ *
23
+ * Exit codes:
24
+ * 0 — every pair conforms and every expectation holds
25
+ * 1 — one or more pairs violate the schema or their expected reading
26
+ * 2 — the gate could not run (fixture directory missing or empty)
27
+ */
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ import * as os from 'node:os';
33
+
34
+ import { EVALUATORS, validateEvaluatorOutput, type RawRun } from './_lib/evaluator_contract.js';
35
+ import { GateLedger } from './_lib/gate_ledger.js';
36
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
37
+
38
+ const _HERE = fileURLToPath(import.meta.url);
39
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
40
+ const FIXTURE_DIR = path.join(process.cwd(), 'tests', 'fixtures', 'evaluator-output');
41
+
42
+ /** What each fixture flavour must read as, so a green-only corpus cannot pass. */
43
+ const EXPECTED: Readonly<Record<string, { pass: boolean; metric_state: string }>> = {
44
+ green: { pass: true, metric_state: 'present' },
45
+ red: { pass: false, metric_state: 'present' },
46
+ degraded: { pass: true, metric_state: 'unreadable' },
47
+ };
48
+
49
+ function readRun(name: string, flavour: string): RawRun | null {
50
+ const base = path.join(FIXTURE_DIR, `${name}.${flavour}`);
51
+ if (!fs.existsSync(`${base}.stdout`)) return null;
52
+ return {
53
+ stdout: fs.readFileSync(`${base}.stdout`, 'utf-8'),
54
+ stderr: fs.existsSync(`${base}.stderr`) ? fs.readFileSync(`${base}.stderr`, 'utf-8') : '',
55
+ exitCode: fs.existsSync(`${base}.exit`)
56
+ ? Number(fs.readFileSync(`${base}.exit`, 'utf-8').trim())
57
+ : 0,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * `--self-test` — prove, from the shipped binary, that the rejections still fire.
63
+ *
64
+ * Each case builds a throwaway tree holding only a fixture directory and runs
65
+ * this gate against it with `cwd` pointing there, so the cases exercise the CLI
66
+ * a contributor actually invokes rather than an imported function.
67
+ */
68
+ function selfTest(): number {
69
+ const mk = (files: Record<string, string>): string => {
70
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ces-selftest-'));
71
+ const dir = path.join(root, 'tests', 'fixtures', 'evaluator-output');
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ for (const [rel, body] of Object.entries(files)) {
74
+ fs.writeFileSync(path.join(dir, rel), body, 'utf-8');
75
+ }
76
+ return root;
77
+ };
78
+ const run = (root: string): number =>
79
+ runGateCli(REPO_ROOT, 'src/scripts/check_evaluator_schema.ts', ['--quiet'], root);
80
+
81
+ const GREEN_FM = '\n== Frontmatter schema: 437 artefacts, 0 failing, 0 with warnings ==\n';
82
+
83
+ return runSelfTest({
84
+ gate: 'check_evaluator_schema',
85
+ minCases: 4,
86
+ minRejectCases: 3,
87
+ cases: [
88
+ {
89
+ name: 'a conformant green fixture passes',
90
+ expect: 'accept',
91
+ run: () =>
92
+ run(
93
+ mk({
94
+ 'validate_frontmatter.green.stdout': GREEN_FM,
95
+ 'validate_frontmatter.green.exit': '0\n',
96
+ }),
97
+ ),
98
+ },
99
+ {
100
+ name: 'a green fixture with no parseable count is rejected — unreadable is not present',
101
+ expect: 'reject',
102
+ run: () =>
103
+ run(
104
+ mk({
105
+ 'validate_frontmatter.green.stdout': 'done, nothing to report\n',
106
+ 'validate_frontmatter.green.exit': '0\n',
107
+ }),
108
+ ),
109
+ },
110
+ {
111
+ name: 'a red fixture that exits 0 is rejected — pass must follow the exit code',
112
+ expect: 'reject',
113
+ run: () =>
114
+ run(
115
+ mk({
116
+ 'validate_frontmatter.red.stdout':
117
+ '\n== Frontmatter schema: 437 artefacts, 3 failing, 0 with warnings ==\n',
118
+ 'validate_frontmatter.red.exit': '0\n',
119
+ }),
120
+ ),
121
+ },
122
+ {
123
+ name: 'an empty fixture directory is rejected — a gate that scanned nothing has verified nothing',
124
+ expect: 'reject',
125
+ run: () => run(mk({})),
126
+ },
127
+ ],
128
+ });
129
+ }
130
+
131
+ function main(argv?: readonly string[]): number {
132
+ const args = argv ?? process.argv.slice(2);
133
+ if (args.includes('--self-test')) return selfTest();
134
+ const quiet = args.includes('--quiet');
135
+ const problems: string[] = [];
136
+ let scanned = 0;
137
+
138
+ if (!fs.existsSync(FIXTURE_DIR)) {
139
+ process.stderr.write(
140
+ `❌ check_evaluator_schema: fixture directory absent (${FIXTURE_DIR}) — the gate cannot run\n`,
141
+ );
142
+ return 2;
143
+ }
144
+
145
+ // Every (evaluator x flavour) pair is PLANNED before the loop, so a pair
146
+ // that falls through without a terminal outcome is a finalize() throw rather
147
+ // than a silently smaller denominator. A missing fixture is a real skip with
148
+ // a named reason, never an invisible `continue`.
149
+ const ledger = new GateLedger('check_evaluator_schema');
150
+ const flavours = Object.keys(EXPECTED);
151
+ ledger.plan(EVALUATORS.flatMap((a) => flavours.map((f) => `${a.name}.${f}`)));
152
+
153
+ for (const adapter of EVALUATORS) {
154
+ for (const flavour of flavours) {
155
+ const pair = `${adapter.name}.${flavour}`;
156
+ const raw = readRun(adapter.name, flavour);
157
+ if (raw === null) {
158
+ ledger.skip(pair, 'no_applicable_files');
159
+ continue;
160
+ }
161
+ scanned += 1;
162
+
163
+ let out;
164
+ try {
165
+ out = adapter.parse(raw);
166
+ } catch (exc) {
167
+ problems.push(`${pair}: adapter threw — ${String(exc)}`);
168
+ ledger.fail(pair, 'adapter threw');
169
+ continue;
170
+ }
171
+ const before = problems.length;
172
+
173
+ for (const v of validateEvaluatorOutput(out)) {
174
+ problems.push(`${pair}: schema — ${v}`);
175
+ }
176
+
177
+ const want = EXPECTED[flavour]!;
178
+ if (out.pass !== want.pass) {
179
+ problems.push(`${pair}: pass is ${out.pass}, fixture asserts ${want.pass}`);
180
+ }
181
+ if (out.metric_state !== want.metric_state) {
182
+ problems.push(
183
+ `${pair}: metric_state is ${String(out.metric_state)}, fixture asserts ${want.metric_state}`,
184
+ );
185
+ }
186
+ // The higher-is-better invariant is the one a consumer relies on
187
+ // without checking, so it is asserted rather than documented.
188
+ if (out.metric_state === 'present' && out.direction === 'minimize') {
189
+ if (out.score !== -(out.metric ?? NaN)) {
190
+ problems.push(
191
+ `${pair}: score ${out.score} is not the negation of metric ${String(out.metric)}`,
192
+ );
193
+ }
194
+ }
195
+
196
+ if (problems.length > before) {
197
+ ledger.fail(pair, `${String(problems.length - before)} violation(s)`);
198
+ } else {
199
+ ledger.complete(pair);
200
+ }
201
+ }
202
+ }
203
+
204
+ ledger.finalize();
205
+
206
+ if (scanned === 0) {
207
+ process.stderr.write(
208
+ `❌ check_evaluator_schema: 0 evaluator/fixture pairs found — a gate that scanned nothing has not verified anything\n`,
209
+ );
210
+ return 2;
211
+ }
212
+
213
+ process.stderr.write(`scanned: ${scanned}\n`);
214
+
215
+ if (problems.length > 0) {
216
+ process.stdout.write(
217
+ `❌ check_evaluator_schema: ${problems.length} violation(s) across ${scanned} pair(s)\n\n`,
218
+ );
219
+ for (const p of problems) process.stdout.write(` ${p}\n`);
220
+ process.stdout.write(`\nContract: docs/contracts/evaluator-output.md\n`);
221
+ return 1;
222
+ }
223
+
224
+ if (!quiet) {
225
+ process.stdout.write(
226
+ `✅ check_evaluator_schema: ${scanned} evaluator/fixture pair(s) conform, ${EVALUATORS.length} evaluator(s) registered.\n`,
227
+ );
228
+ }
229
+ return 0;
230
+ }
231
+
232
+ function _isCliEntry(): boolean {
233
+ if (process.argv[1] === undefined) return false;
234
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
235
+ try {
236
+ return (
237
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
238
+ fs.realpathSync(path.resolve(process.argv[1]))
239
+ );
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ if (_isCliEntry() || process.argv[1] === _HERE) {
246
+ process.exit(main());
247
+ }
248
+
249
+ export { main };
@@ -50,6 +50,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
50
50
 
51
51
  import * as yaml from 'js-yaml';
52
52
  import { checkRatchet } from './_lib/gate_baseline.js';
53
+ import { GateLedger, type GateSkipReason } from './_lib/gate_ledger.js';
53
54
  import { runCountedProbe } from './_lib/counted_probe.js';
54
55
  import { listGateScripts } from './_lib/gate_population.js';
55
56
  import { describeOutcome, namesEstateInvalidatingError } from './_lib/gate_result.js';
@@ -187,6 +188,59 @@ export function parse_scanned(output: string): number | null {
187
188
  return m === null ? null : Number(m[1]);
188
189
  }
189
190
 
191
+ /**
192
+ * Map a gate verdict onto its ledger outcome.
193
+ *
194
+ * Extracted and exported so the mapping can be PINNED — it was inline, and the
195
+ * completion review's three sharpest findings were all mis-mappings that a
196
+ * discrimination test would have caught in one line each.
197
+ *
198
+ * The split is INSPECTED vs NOT INSPECTED, never pass vs fail. `fail` counts
199
+ * into the ledger's `scanned=` (`report()` sets `checked = completed + failed`),
200
+ * so a target that could not be READ must never land there — that inflates the
201
+ * exact number the library exists to make trustworthy. This file's own comment
202
+ * already says a gate that could not MEASURE "is not a gate that found
203
+ * violations, and it is not a gate that passed either"; this agrees with it.
204
+ */
205
+ export function ledgerOutcomeFor(
206
+ verdict: GateResult['verdict'],
207
+ ): 'complete' | 'fail' | GateSkipReason {
208
+ switch (verdict) {
209
+ case 'pending':
210
+ return 'disabled_by_configuration';
211
+ case 'unavailable':
212
+ // NOT `missing_credentials`. The only manifest row carrying
213
+ // `unavailable_exit` is unavailable for an unbuilt `site/dist`, and the
214
+ // skip sentence IS the audit surface — naming a credential would send the
215
+ // reader hunting an unset token instead of running the build.
216
+ return 'no_applicable_files';
217
+ case 'crashed':
218
+ // NOT `dead_scan_root` — a gate that threw has no dead root, and the skip
219
+ // sentence would send the reader to check a scan path that is fine. The
220
+ // vocabulary was extended rather than approximated, same reasoning as the
221
+ // `unavailable` case above.
222
+ return 'check_did_not_run';
223
+ case 'estate_invalid':
224
+ // Nothing was read here either. `crashed` still reds the build through
225
+ // this gate's own filter and `estate_invalid` deliberately does not, but
226
+ // neither belongs in the inspected count.
227
+ return 'dead_scan_root';
228
+ case 'silent':
229
+ case 'below_floor':
230
+ return 'fail';
231
+ case 'ok':
232
+ return 'complete';
233
+ default: {
234
+ // Exhaustiveness guard. A `Verdict` member added later must be classified
235
+ // deliberately: without this, a new member would silently count as
236
+ // INSPECTED and both the switch and its test would stay green —
237
+ // re-creating the over-report this function was extracted to pin.
238
+ const unhandled: never = verdict;
239
+ throw new Error(`ledgerOutcomeFor: unhandled verdict ${String(unhandled)}`);
240
+ }
241
+ }
242
+ }
243
+
190
244
  /** Classify one gate's run against its declared floor. Pure — testable without
191
245
  * spawning anything. */
192
246
  export function classify(
@@ -323,6 +377,31 @@ export function main(argv: readonly string[]): number {
323
377
  return 0;
324
378
  }
325
379
 
380
+ // Per-target accounting over the manifest rows. The gate that audits gate
381
+ // coverage was itself unaudited.
382
+ //
383
+ // Stated precisely, because the obvious justification overstates it: this gate
384
+ // ALREADY prints counted `pending N · unavailable N` warnings, so those two
385
+ // classes were not invisible. What the ledger adds is that they are expressed
386
+ // in the estate-wide vocabulary every other adopter uses, so a reader
387
+ // comparing gates does not have to learn this one's private status names — and
388
+ // that `crashed` and `estate_invalid`, which had no counted line at all, stop
389
+ // being counted as inspected.
390
+ //
391
+ // Note the two numbers this produces in one run: the ledger's `scanned=` (what
392
+ // was inspected) and the contract line `scanned:` (manifest rows read). They
393
+ // differ by the skips and that is correct, but only the latter is what
394
+ // `SCANNED_RE` parses.
395
+ const ledger = new GateLedger('check_gate_coverage');
396
+ // Deduplicated on purpose. `load_manifest` does not check id uniqueness, and a
397
+ // copy-pasted row with a forgotten rename would otherwise make `plan()` throw
398
+ // an uncaught LedgerUsageError — surfacing a manifest mistake as a stack trace
399
+ // and Node's exit 1, which this file's own contract reads as "a gate is blind,
400
+ // collapsed, or silent" rather than as the exit 2 a malformed manifest earns.
401
+ // The duplicate still runs twice and still prints twice; only the accounting
402
+ // refuses to double-count it.
403
+ ledger.plan([...new Set(specs.map((s) => s.id))]);
404
+
326
405
  const results = specs.map((s) => {
327
406
  if (spec_is_pending(s)) {
328
407
  const { scanned, crashed } = probe_pending(s);
@@ -332,6 +411,20 @@ export function main(argv: readonly string[]): number {
332
411
  return classify(s, scanned, crashed, exit_code, output);
333
412
  });
334
413
 
414
+ // `recorded` pairs with the deduplicated plan above: a duplicated manifest id
415
+ // must be accounted once, and recording it twice would raise the same
416
+ // LedgerUsageError the dedupe exists to avoid.
417
+ const recorded = new Set<string>();
418
+ for (const r of results) {
419
+ if (recorded.has(r.id)) continue;
420
+ recorded.add(r.id);
421
+ const outcome = ledgerOutcomeFor(r.verdict);
422
+ if (outcome === 'complete') ledger.complete(r.id);
423
+ else if (outcome === 'fail') ledger.fail(r.id, r.message);
424
+ else ledger.skip(r.id, outcome);
425
+ }
426
+ ledger.report();
427
+
335
428
  if (wantJson) {
336
429
  process.stdout.write(`${JSON.stringify({ generated_by: 'check_gate_coverage', results }, null, 2)}\n`);
337
430
  } else {