@event4u/agent-config 14.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.
@@ -34092,6 +34092,21 @@ var init_language_mirror_hook = __esm({
34092
34092
  import fs55 from "node:fs";
34093
34093
  import path56 from "node:path";
34094
34094
  import { fileURLToPath as fileURLToPath31, pathToFileURL as pathToFileURL20 } from "node:url";
34095
+ function sessionSlug(session_id) {
34096
+ return session_id.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 80);
34097
+ }
34098
+ function ledgerFileFor(session_id) {
34099
+ if (!session_id) {
34100
+ return STATE_FILE2;
34101
+ }
34102
+ return path56.join(STATE_DIR, `${sessionSlug(session_id)}.json`);
34103
+ }
34104
+ function pendingFileFor(session_id) {
34105
+ if (!session_id) {
34106
+ return PENDING_FILE;
34107
+ }
34108
+ return path56.join(PENDING_DIR, `${sessionSlug(session_id)}.json`);
34109
+ }
34095
34110
  function _isObject4(v) {
34096
34111
  return typeof v === "object" && v !== null && !Array.isArray(v);
34097
34112
  }
@@ -34110,6 +34125,18 @@ function isInterrogative(prose) {
34110
34125
  t
34111
34126
  );
34112
34127
  }
34128
+ function isAffirmative(prose) {
34129
+ const t = prose.trim().replace(/[.!]+$/u, "");
34130
+ if (!t || t.length > 24) {
34131
+ return false;
34132
+ }
34133
+ if (isInterrogative(t)) {
34134
+ return false;
34135
+ }
34136
+ return /^(?:option\s*)?\d{1,2}[.)]?$/iu.test(t) || /^(ja|jo|jep|yes|yep|yeah|ok|okay|okey|klar|passt|gut|los|go|go ahead|mach|mach das|mach es|leg los|do it|proceed|bitte)$/iu.test(
34137
+ t
34138
+ );
34139
+ }
34113
34140
  function splitFences(prompt) {
34114
34141
  const fences = [];
34115
34142
  const prose = prompt.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, body) => {
@@ -34148,6 +34175,40 @@ function classifyAuthorization(prompt) {
34148
34175
  }
34149
34176
  return { authorized: [...authorized], evidence };
34150
34177
  }
34178
+ function takePending(consumer_root, session_id, now) {
34179
+ const file = path56.join(consumer_root, pendingFileFor(session_id));
34180
+ let raw;
34181
+ try {
34182
+ raw = fs55.readFileSync(file, "utf8");
34183
+ } catch {
34184
+ return null;
34185
+ }
34186
+ try {
34187
+ fs55.rmSync(file, { force: true });
34188
+ } catch {
34189
+ }
34190
+ try {
34191
+ const decoded = JSON.parse(raw);
34192
+ if (!_isObject4(decoded)) {
34193
+ return null;
34194
+ }
34195
+ const op = decoded["op"];
34196
+ if (typeof op !== "string" || !ALL_OPS.includes(op)) {
34197
+ return null;
34198
+ }
34199
+ const recorded = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
34200
+ if (session_id && recorded && recorded !== session_id) {
34201
+ return null;
34202
+ }
34203
+ const at = Date.parse(String(decoded["refused_at"] ?? ""));
34204
+ if (!Number.isFinite(at) || now - at > PENDING_MAX_AGE_MS) {
34205
+ return null;
34206
+ }
34207
+ return { op, session_id: recorded, refused_at: String(decoded["refused_at"]) };
34208
+ } catch {
34209
+ return null;
34210
+ }
34211
+ }
34151
34212
  function run2(stdin_text, options) {
34152
34213
  let envelope = {};
34153
34214
  if (stdin_text.trim()) {
@@ -34173,6 +34234,12 @@ function run2(stdin_text, options) {
34173
34234
  return EXIT_ALLOW2;
34174
34235
  }
34175
34236
  const { authorized, evidence } = classifyAuthorization(prompt);
34237
+ const session = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
34238
+ const pending = takePending(options.consumer_root, session, Date.now());
34239
+ if (pending && !authorized.includes(pending.op) && isAffirmative(prompt)) {
34240
+ authorized.push(pending.op);
34241
+ evidence[pending.op] = `confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): "${prompt.trim().slice(0, 40)}"`;
34242
+ }
34176
34243
  const ledger = {
34177
34244
  session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
34178
34245
  detected_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -34181,7 +34248,7 @@ function run2(stdin_text, options) {
34181
34248
  prompt_chars: prompt.length
34182
34249
  };
34183
34250
  try {
34184
- atomic_write_json(path56.join(options.consumer_root, STATE_FILE2), ledger);
34251
+ atomic_write_json(path56.join(options.consumer_root, ledgerFileFor(session)), ledger);
34185
34252
  } catch {
34186
34253
  }
34187
34254
  return EXIT_ALLOW2;
@@ -34219,7 +34286,7 @@ function _isCliEntry18() {
34219
34286
  return false;
34220
34287
  }
34221
34288
  }
34222
- var EXIT_ALLOW2, STATE_FILE2, PHRASES, OUTPUT_LINE, PASTED_COMMANDS;
34289
+ var EXIT_ALLOW2, STATE_FILE2, STATE_DIR, PENDING_DIR, PENDING_FILE, PENDING_MAX_AGE_MS, ALL_OPS, PHRASES, OUTPUT_LINE, PASTED_COMMANDS;
34223
34290
  var init_git_authorization_hook = __esm({
34224
34291
  "src/scripts/git_authorization_hook.ts"() {
34225
34292
  "use strict";
@@ -34227,6 +34294,20 @@ var init_git_authorization_hook = __esm({
34227
34294
  init_hook_stdin();
34228
34295
  EXIT_ALLOW2 = 0;
34229
34296
  STATE_FILE2 = path56.join("agents", "state", "git-authorization.json");
34297
+ STATE_DIR = path56.join("agents", "state", "git-authorization");
34298
+ PENDING_DIR = path56.join("agents", "state", "git-authorization-pending");
34299
+ PENDING_FILE = path56.join("agents", "state", "git-authorization-pending.json");
34300
+ PENDING_MAX_AGE_MS = 10 * 60 * 1e3;
34301
+ ALL_OPS = [
34302
+ "commit",
34303
+ "push",
34304
+ "branch",
34305
+ "pr-create",
34306
+ "pr-merge",
34307
+ "tag",
34308
+ "release",
34309
+ "publish"
34310
+ ];
34230
34311
  PHRASES = [
34231
34312
  { op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
34232
34313
  { op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
@@ -34456,17 +34537,30 @@ function commandOp(command) {
34456
34537
  return found;
34457
34538
  }
34458
34539
  function _loadLedger(consumer_root, session_id, now) {
34540
+ const candidates = [ledgerFileFor(session_id)];
34541
+ if (!candidates.includes(STATE_FILE2)) {
34542
+ candidates.push(STATE_FILE2);
34543
+ }
34544
+ for (const rel of candidates) {
34545
+ const found = _readLedgerFile(path57.join(consumer_root, rel), session_id, now);
34546
+ if (found !== null) {
34547
+ return found;
34548
+ }
34549
+ }
34550
+ return { authorized: /* @__PURE__ */ new Set(), present: false };
34551
+ }
34552
+ function _readLedgerFile(file, session_id, now) {
34459
34553
  try {
34460
- const raw = fs56.readFileSync(path57.join(consumer_root, STATE_FILE2), "utf8");
34554
+ const raw = fs56.readFileSync(file, "utf8");
34461
34555
  const decoded = JSON.parse(raw);
34462
34556
  if (_isObject5(decoded) && Array.isArray(decoded["authorized"])) {
34463
34557
  const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
34464
34558
  if (session_id && ledgerSession && ledgerSession !== session_id) {
34465
- return { authorized: /* @__PURE__ */ new Set(), present: false };
34559
+ return null;
34466
34560
  }
34467
34561
  const at = Date.parse(String(decoded["detected_at"] ?? ""));
34468
34562
  if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
34469
- return { authorized: /* @__PURE__ */ new Set(), present: false };
34563
+ return null;
34470
34564
  }
34471
34565
  return {
34472
34566
  authorized: new Set(decoded["authorized"].filter(Boolean)),
@@ -34475,7 +34569,7 @@ function _loadLedger(consumer_root, session_id, now) {
34475
34569
  }
34476
34570
  } catch {
34477
34571
  }
34478
- return { authorized: /* @__PURE__ */ new Set(), present: false };
34572
+ return null;
34479
34573
  }
34480
34574
  function decide(command, ledger) {
34481
34575
  if (command === null) {
@@ -34489,7 +34583,7 @@ function decide(command, ledger) {
34489
34583
  if (BLOCK_OPS.has(op)) {
34490
34584
  const reason = `Blocked: \`${op}\` with no authorization in this turn's prompt. This operation is irreversible and non-destructive-by-default already declares it never-autonomous. Command: ${shown}. Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
34491
34585
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}
34492
- ` };
34586
+ `, blockedOp: op };
34493
34587
  }
34494
34588
  if (WARN_OPS.has(op)) {
34495
34589
  const reason = `No \`${op}\` authorization found in this turn's prompt (commit-policy: a one-shot authorization is spent on the operation it named). If the user did authorize it this turn, proceed; otherwise stop and ask. Command: ${shown}`;
@@ -34519,6 +34613,16 @@ function run3(stdin_text, options) {
34519
34613
  extractCommand(envelope),
34520
34614
  _loadLedger(options.consumer_root, session_id, Date.now())
34521
34615
  );
34616
+ if (decision.blockedOp) {
34617
+ try {
34618
+ atomic_write_json(path57.join(options.consumer_root, pendingFileFor(session_id)), {
34619
+ op: decision.blockedOp,
34620
+ session_id,
34621
+ refused_at: (/* @__PURE__ */ new Date()).toISOString()
34622
+ });
34623
+ } catch {
34624
+ }
34625
+ }
34522
34626
  if (decision.stdout) {
34523
34627
  process.stdout.write(decision.stdout);
34524
34628
  }
@@ -34565,6 +34669,7 @@ var init_block_unauthorized_git = __esm({
34565
34669
  "src/scripts/hooks/block_unauthorized_git.ts"() {
34566
34670
  "use strict";
34567
34671
  init_hook_stdin();
34672
+ init_state_io();
34568
34673
  init_git_authorization_hook();
34569
34674
  EXIT_ALLOW3 = 0;
34570
34675
  EXIT_BLOCK = 1;
@@ -35610,12 +35715,12 @@ function resolveDepth(parentRef, open) {
35610
35715
  if (!parent) return { depth: 2, depth_basis: "asserted-parent" };
35611
35716
  return { depth: parent.depth + 1, depth_basis: "observed" };
35612
35717
  }
35613
- function ledgerFileFor(root, ts) {
35718
+ function ledgerFileFor2(root, ts) {
35614
35719
  return path60.join(root, LEDGER_DIR, `${ts.slice(0, 7)}.jsonl`);
35615
35720
  }
35616
35721
  function appendLedgerLine(root, ts, line) {
35617
35722
  if (is_replay_mode()) return;
35618
- const file = ledgerFileFor(root, ts);
35723
+ const file = ledgerFileFor2(root, ts);
35619
35724
  fs59.mkdirSync(path60.dirname(file), { recursive: true });
35620
35725
  fs59.appendFileSync(file, `${JSON.stringify(line)}
35621
35726
  `, "utf8");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.0.0",
4
+ "version": "14.1.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -34,9 +34,9 @@
34
34
  "note": "Markdown links between source artefacts that cross a pack boundary the source pack's `requires` graph does not reach — revealed when the scan root moved off the deleted packages/ tree to the live corpus (0 -> 753 artefacts scanned). 82 distinct pack pairs across 131 source files; largest single class is engineering-base -> meta (73), skills in a consumer-installable pack citing this package's own maintenance skills. Each is a link that dangles for a consumer who installs the source pack without the target's. Judged real drift rather than an over-strict gate because it is long-tailed — top bucket 22%, no single systematic cause — unlike the lint_artefact_frontmatter case, where one required shape had 0-of-288 adoption. Fix per finding: add the edge to packs.yml requires, retarget the link, or move the artefact."
35
35
  },
36
36
  "check_gate_completeness": {
37
- "count": 216,
38
- "landed": "2026-08-12",
39
- "note": "Ledger adoption ratchet. 216 of 244 registered gates do not yet carry per-target completeness accounting (`_lib/gate_ledger.ts`) or a `// ledger-exempt:` marker. This is deliberately NOT a sweep: road-to-skill-ecosystem-gate-integrity Phase 1 adopts exactly three gates (lint_framework_leakage, check_references, lint_roadmap_complexity) plus the checker itself to prove the interface, and the remainder migrates as gates are touched. Shrink-only from here — the number may not grow, so a NEW gate must adopt or exempt.",
37
+ "count": 215,
38
+ "landed": "2026-08-17",
39
+ "note": "Ledger adoption ratchet. Gates registered under the CI task roots that carry neither per-target completeness accounting (`_lib/gate_ledger.ts`) nor a `// ledger-exempt:` marker. This is deliberately NOT a sweep: road-to-skill-ecosystem-gate-integrity Phase 1 adopts exactly three gates (lint_framework_leakage, check_references, lint_roadmap_complexity) plus the checker itself to prove the interface, and the remainder migrates as gates are touched. Shrink-only from here — the number may not grow, so a NEW gate must adopt or exempt. Lowered 216 -> 215 on 2026-08-17 by discharging the obligation three NEW arrivals had incurred, which is the standing rule being enforced rather than a drain: the registered population grew 244 -> 258 and three of the fourteen arrivals adopted nothing — `build_archive_index`, `check_gate_coverage`, `check_pr_ci_current`. The reading was 218 against this baseline of 216, and the missing arithmetic is that one PRE-existing gate (`lint_rule_skill_pack_reach`) had adopted in the meantime: 216 + 3 - 1 = 218. All three adopted a real ledger. `check_pr_ci_current` first took a reasoned exemption claiming no target population; the completion review refuted it from the file itself — the check rows ARE a population, and rows whose `state` is not a string were being dropped before any verdict ran, so a dropped row could be the failing one. The exemption was withdrawn and the ledger adopted, counting kept rows as read, dropped rows as skips, and rows a pre-row verdict never reached as out of scope. The denominator in this note was 14 stale (it said 244) and is now expressed without one, because a count pinned to a population that grows weekly rots faster than the count itself. Note what this lowering does NOT buy: headroom is zero either way, so the next un-adopted arrival reds this gate immediately — which is the intended behaviour and the reason the number is not padded. The refreshed `landed` is load-bearing rather than bookkeeping: `checkRatchet` returns `improved` BEFORE it evaluates staleness, so an entry left at 216 while the tree measures 215 would never expire at any age (pinned by tests/lib/gate_baseline.test.ts, which asserts `improved` at landed + STALE_AFTER_DAYS + 400 days) — the greenest available state and the worst, since it switches off the anti-fossilization clause exactly when someone has done real work.",
40
40
  "falsifier": "./scripts-run src/scripts/check_gate_completeness"
41
41
  },
42
42
  "check_depth_budget": {
@@ -57,7 +57,9 @@ export type GateSkipReason =
57
57
  | 'generated_artifact'
58
58
  | 'dead_scan_root'
59
59
  | 'declared_exemption'
60
- | 'not_applicable_kind';
60
+ | 'not_applicable_kind'
61
+ | 'precondition_unmet'
62
+ | 'check_did_not_run';
61
63
 
62
64
  /**
63
65
  * One sentence per skip code, printed verbatim in the ledger's own output.
@@ -79,6 +81,8 @@ export const SKIP_REASON_MESSAGE: Record<GateSkipReason, string> = {
79
81
  dead_scan_root: 'the root this target was enumerated from no longer exists',
80
82
  declared_exemption: 'the target carries an explicit, reviewable exemption from this check',
81
83
  not_applicable_kind: 'the target is a kind of artifact this check does not apply to',
84
+ precondition_unmet: 'a precondition of this run settled the verdict before the target could be inspected',
85
+ check_did_not_run: 'the check itself could not be executed, so the target was never read',
82
86
  };
83
87
 
84
88
  /** Terminal outcomes a planned target can reach. Exactly one, exactly once. */
@@ -45,6 +45,7 @@ import * as path from 'node:path';
45
45
  import process from 'node:process';
46
46
  import { fileURLToPath, pathToFileURL } from 'node:url';
47
47
 
48
+ import { GateLedger } from './_lib/gate_ledger.js';
48
49
  import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
49
50
  import { parse_frontmatter } from './validate_frontmatter.js';
50
51
 
@@ -217,12 +218,24 @@ export function archiveFiles(dir: string): string[] {
217
218
  .sort();
218
219
  }
219
220
 
220
- export function buildIndex(dir: string): ArchiveEntry[] {
221
- return archiveFiles(dir).map((name) =>
221
+ /**
222
+ * Build entries from an enumeration the CALLER already performed.
223
+ *
224
+ * Split out so the ledger's plan and the results can come from ONE walk. With
225
+ * two walks, a file created between them reports as a skip that never happened
226
+ * and a file deleted between them yields an entry that was never planned — the
227
+ * plan would describe a different directory state than the results.
228
+ */
229
+ export function buildIndexFrom(dir: string, names: readonly string[]): ArchiveEntry[] {
230
+ return names.map((name) =>
222
231
  buildEntry(name.replace(/\.md$/, ''), fs.readFileSync(path.join(dir, name), 'utf8')),
223
232
  );
224
233
  }
225
234
 
235
+ export function buildIndex(dir: string): ArchiveEntry[] {
236
+ return buildIndexFrom(dir, archiveFiles(dir));
237
+ }
238
+
226
239
  /** Table-cell text: pipes escaped, newlines flattened, never empty. */
227
240
  function _cell(value: string | null): string {
228
241
  if (value === null) {
@@ -309,7 +322,44 @@ export function main(argv: readonly string[]): number {
309
322
  const quiet = argv.includes('--quiet');
310
323
  const dir = path.join(ROOT, ARCHIVE_REL);
311
324
 
312
- const entries = buildIndex(dir);
325
+ // ONE walk feeds both the plan and the results.
326
+ //
327
+ // Planning the result set and completing it in the next statement would make
328
+ // `planned === completed, skipped=0` true by construction — an accounting
329
+ // line that can never report anything. Planning a SECOND walk is only
330
+ // marginally better: it describes a directory state the results did not come
331
+ // from. Enumerating once and passing that list into the builder makes the
332
+ // gap real — the natural future fix for an unreadable archive file is a
333
+ // `try/catch { continue }` in the builder, and that file then shows up here
334
+ // as a skip instead of vanishing.
335
+ //
336
+ // `not-extractable` is a real reading rather than a skip and therefore
337
+ // completes — but a frontmatter PARSE failure is swallowed by `_frontmatter`
338
+ // and zeroes title/verdict/status, which is a genuine per-target degrade the
339
+ // disposition tally cannot show. Both are visible here as the difference
340
+ // between the planned and completed sets.
341
+ const walked = archiveFiles(dir);
342
+ const entries = buildIndexFrom(dir, walked);
343
+ const planned = walked.map((f) => path.basename(f, '.md'));
344
+ const produced = new Set(entries.map((e) => e.slug));
345
+ const ledger = new GateLedger('build_archive_index');
346
+ ledger.plan(planned);
347
+ for (const slug of planned) {
348
+ // `check_did_not_run`, not `no_applicable_files`: the file IS one the
349
+ // check applies to — the entry build did not produce a reading for it.
350
+ if (produced.has(slug)) ledger.complete(slug);
351
+ else ledger.skip(slug, 'check_did_not_run');
352
+ }
353
+ // A produced entry outside the plan means the two sets came apart despite
354
+ // the single walk; the ledger would under-state the denominator silently.
355
+ const unplanned = [...produced].filter((s) => !planned.includes(s));
356
+ if (unplanned.length > 0) {
357
+ throw new Error(
358
+ `build_archive_index: ${String(unplanned.length)} entry/entries were produced but never ` +
359
+ `planned (${unplanned.slice(0, 5).join(', ')}) — the ledger denominator is wrong.`,
360
+ );
361
+ }
362
+
313
363
  try {
314
364
  reportScanned({
315
365
  gate: 'build_archive_index',
@@ -325,6 +375,15 @@ export function main(argv: readonly string[]): number {
325
375
  throw err;
326
376
  }
327
377
 
378
+ // Reported before the `--check` and write paths branch, so both publish the
379
+ // same accounting. NOT on all three: the earliest exit — `reportScanned`
380
+ // throwing `DeadScopeError` on an empty walk — returns above this line and
381
+ // reports nothing, which is the one exit where the accounting would say the
382
+ // most. Left as-is rather than papered over: the ledger has no targets to
383
+ // report there either, and moving it above the scope assertion would publish
384
+ // a plan for a root the assertion is about to reject.
385
+ ledger.report();
386
+
328
387
  const mdPath = path.join(dir, INDEX_MD);
329
388
  const jsonPath = path.join(dir, INDEX_JSON);
330
389
  const md = renderMarkdown(entries);
@@ -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 {
@@ -34,12 +34,29 @@
34
34
  * Exit codes (contract §6): 0 = current, or unobservable-and-said-so ·
35
35
  * 1 = the remote answered and the verdict is not about the branch head ·
36
36
  * 2 = internal error. `scanned:` is emitted on EVERY exit path.
37
+ *
38
+ * ## The per-target ledger, and the exemption that was withdrawn
39
+ *
40
+ * This gate first shipped with a `// ledger-exempt:` marker arguing it had "no
41
+ * target population to account for — a short-circuiting chain of 0-3 remote
42
+ * facts". Its completion review refuted that from the file itself: the check
43
+ * rows ARE a population, and the parse below **drops** every row whose `state`
44
+ * is not a string. A dropped row could be the failing one, so the gate could
45
+ * print *"green on its own head"* over a check it never read — the precise
46
+ * "absence of scanning looks like absence of findings" shape the ledger exists
47
+ * to count.
48
+ *
49
+ * The exemption's secondary claim was accurate — every `main()` exit already
50
+ * self-reports through `scanReport` with an explicit `allowEmpty` reason — and
51
+ * that is exactly why the first argument was easy to believe. It was still
52
+ * resting on the false half.
37
53
  */
38
54
 
39
55
  import * as path from 'node:path';
40
56
  import { spawnSync } from 'node:child_process';
41
57
  import { fileURLToPath, pathToFileURL } from 'node:url';
42
58
 
59
+ import { GateLedger } from './_lib/gate_ledger.js';
43
60
  import { reportScanned } from './_lib/scan_scope.js';
44
61
 
45
62
  const NETWORK_TIMEOUT_MS = 8_000;
@@ -117,6 +134,12 @@ export interface Facts {
117
134
  /** The head SHA the reported checks ran against. */
118
135
  checksHead: string | null;
119
136
  rows: readonly CheckRow[];
137
+ /**
138
+ * Names of check rows the parse DROPPED because their `state` was not a
139
+ * string. Carried out rather than swallowed: a dropped row could be the
140
+ * failing one, so it is a skip the ledger must count, not a non-event.
141
+ */
142
+ droppedRows?: readonly string[];
120
143
  /** Set when something could not be observed; forces the degrade path. */
121
144
  unobservable: string | null;
122
145
  /**
@@ -235,6 +258,51 @@ export function decide(f: Facts, opts: DecideOptions = {}): Verdict {
235
258
  };
236
259
  }
237
260
 
261
+ /**
262
+ * Whether {@link decide} reached the check rows at all.
263
+ *
264
+ * Every branch above the row logic — unobservable, no open PR, behind/diverged,
265
+ * ahead — settles the verdict WITHOUT reading a single row state. Recording
266
+ * those rows as `complete` would publish `scanned=N` for a run that inspected
267
+ * none of them: the absence-of-scanning-reads-as-absence-of-findings inflation
268
+ * the ledger exists to count, in the gate that adopted it. Under `--pre-push`,
269
+ * `ahead` is the NORMAL state, so this is the common path, not the exotic one.
270
+ */
271
+ export function rowsWereEvaluated(f: Facts): boolean {
272
+ if (f.unobservable !== null) return false;
273
+ if (f.pr === null) return false;
274
+ return f.relation !== 'behind' && f.relation !== 'diverged' && f.relation !== 'ahead';
275
+ }
276
+
277
+ /**
278
+ * Publish the row-level accounting for one run.
279
+ *
280
+ * Three populations, three outcomes: rows the parse dropped are skips (a dropped
281
+ * row could be the failing one), rows a row-reading verdict covered are
282
+ * completions, and rows the verdict never reached are out of scope for THIS run.
283
+ *
284
+ * The line is emitted even under `--quiet` — {@link GateLedger.report} writes
285
+ * unconditionally by design, so a `--quiet` run of this gate is one line louder
286
+ * than before. Stated here because it is a change to this gate's own contract.
287
+ */
288
+ function reportRowLedger(f: Facts): void {
289
+ const ledger = new GateLedger('check_pr_ci_current');
290
+ const dropped = new Set(f.droppedRows ?? []);
291
+ // Duplicate check names are a normal remote condition — a cancelled re-run
292
+ // beside a live one, two workflows sharing a job name, or a dropped row
293
+ // whose name equals a kept row's. `plan` throws on a repeat, and this gate
294
+ // promises to degrade rather than block, so the plan is deduplicated.
295
+ const planned = [...new Set([...f.rows.map((r) => r.name), ...dropped])];
296
+ ledger.plan(planned);
297
+ const evaluated = rowsWereEvaluated(f);
298
+ for (const name of planned) {
299
+ if (dropped.has(name)) ledger.skip(name, 'not_applicable_kind');
300
+ else if (evaluated) ledger.complete(name);
301
+ else ledger.outOfScope(name, 'precondition_unmet');
302
+ }
303
+ ledger.report();
304
+ }
305
+
238
306
  /**
239
307
  * Ancestry between the local tip and the PR head.
240
308
  *
@@ -261,7 +329,7 @@ export function relate(
261
329
  /** Gather the facts `decide` consumes. Every failure becomes `unobservable`. */
262
330
  export function gather(repo: string): Facts {
263
331
  const empty: Facts = {
264
- pr: null, prHead: null, localHead: null, checksHead: null, rows: [], unobservable: null, relation: 'unknown',
332
+ pr: null, prHead: null, localHead: null, checksHead: null, rows: [], droppedRows: [], unobservable: null, relation: 'unknown',
265
333
  };
266
334
 
267
335
  const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo);
@@ -296,9 +364,19 @@ export function gather(repo: string): Facts {
296
364
  // with parseable stdout is DATA, not an error. Treating it as unobservable
297
365
  // would make the gate blind in exactly the red case it exists for.
298
366
  let rows: CheckRow[] = [];
367
+ // A row whose `state` is not a string is DROPPED, and that drop is the one
368
+ // thing in this gate worth counting: a dropped row could be the failing one,
369
+ // and without accounting the gate prints "green on its own head" over a
370
+ // check it never read. `dropped` is surfaced by the caller through the
371
+ // ledger rather than swallowed here.
372
+ let dropped: string[] = [];
299
373
  if (checks.stdout.trim() !== '') {
300
374
  try {
301
- rows = (JSON.parse(checks.stdout) as CheckRow[]).filter((r) => typeof r.state === 'string');
375
+ const parsed = JSON.parse(checks.stdout) as CheckRow[];
376
+ rows = parsed.filter((r) => typeof r.state === 'string');
377
+ dropped = parsed
378
+ .filter((r) => typeof r.state !== 'string')
379
+ .map((r, i) => (typeof r.name === 'string' ? r.name : `row-${String(i)}`));
302
380
  } catch {
303
381
  rows = [];
304
382
  }
@@ -329,6 +407,7 @@ export function gather(repo: string): Facts {
329
407
  localHead: localSha,
330
408
  checksHead: runsHead.ok && runsHead.stdout.trim() !== '' ? runsHead.stdout.trim() : null,
331
409
  rows,
410
+ droppedRows: dropped,
332
411
  unobservable: null,
333
412
  relation: relate(repo, localSha, prSha),
334
413
  };
@@ -374,7 +453,11 @@ export function main(argv?: readonly string[]): number {
374
453
 
375
454
  let verdict: Verdict;
376
455
  try {
377
- verdict = decide(gather(repo), { prePush });
456
+ const facts = gather(repo);
457
+ // The verdict comes FIRST: whether the rows were inspected at all is a
458
+ // property of the verdict's path, not of the parse (see reportRowLedger).
459
+ verdict = decide(facts, { prePush });
460
+ reportRowLedger(facts);
378
461
  } catch (exc) {
379
462
  scanReport(0, 'internal error — nothing was compared');
380
463
  process.stderr.write(