@event4u/agent-config 14.0.0 → 14.2.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.
@@ -409,3 +409,94 @@ export function formatPointableBare(
409
409
  }
410
410
  return lines.join('\n');
411
411
  }
412
+
413
+ /* ------------------------------------------------------------------ *
414
+ * The delivery filter — what the runtime router must not name.
415
+ * ------------------------------------------------------------------ */
416
+
417
+ /**
418
+ * The bare names one host is KNOWN to have delivered, or `null` when nothing
419
+ * about that host's per-entry delivery was ever enumerated.
420
+ *
421
+ * `joinPointableBare` above answers "how large is the D-4 divergence" for an
422
+ * operator report. This answers the runtime question the same log can settle:
423
+ * given this host, which skill names must a pointer line not name? It is the
424
+ * reducer `skill_route_hook` consumes, and it lives here rather than in that
425
+ * hook for one reason — every guard below is already argued in this module's
426
+ * prose, and a second reducer over the same log is how two readers start
427
+ * breaking the same tie in opposite directions (`_supersedes`, in the parent
428
+ * module, exists because that happened once and printed two drop counts for one
429
+ * host on one day).
430
+ *
431
+ * THE THREE-WAY DISTINCTION IS THE WHOLE POINT, and collapsing any two of them
432
+ * is the failure:
433
+ *
434
+ * - **`null` — nothing was enumerated.** No record for this host, or none of
435
+ * its records is a `per-entry` observation, or the latest one carries no
436
+ * `bare_names` array. The consumer must fail OPEN: filtering on this would
437
+ * be a zero inferred from silence, and a filter that quietly narrows on
438
+ * missing data is worse than the divergence it treats.
439
+ * - **An EMPTY set — enumerated, and nothing was bare.** A legitimate answer,
440
+ * and behaviourally identical to `null` at today's only consumer. It is
441
+ * still returned distinctly, because "measured clean" and "never measured"
442
+ * are different facts and this module refuses to let them look alike.
443
+ * - **A NON-EMPTY set — these names went dark.** The pointer must not name
444
+ * them.
445
+ *
446
+ * A `budget-strip-and-drop` host is therefore always `null` here, never an
447
+ * empty set: it publishes no per-entry list at all, so its empty `bare_names`
448
+ * records that nothing was counted. That is the same skip `joinPointableBare`
449
+ * makes, for the same reason, and it is why a codex install gets no filtering
450
+ * rather than a filter that believes nothing was degraded.
451
+ *
452
+ * LATEST WINS, per host, by `observed_at` — the `latestPointableBarePerHost`
453
+ * tie-break rather than a union across the series. A union would let a
454
+ * superseded observation keep suppressing a skill the host now delivers fine,
455
+ * which is a filter that only ever narrows; the whole series is available to an
456
+ * operator report, and a runtime filter wants the current reading.
457
+ *
458
+ * `bare_names` is guarded with `Array.isArray`, and `observed_at` with a
459
+ * `typeof` check, because `readObservationLog` produces records by an unchecked
460
+ * `JSON.parse … as` over an append-only log with more than one producer — the
461
+ * same guard, for the same recorded reason, that `joinPointableBare` and
462
+ * `knownHostLimits` carry. Both guards resolve to `null`, never to an older
463
+ * reading: a malformed LATEST record means the current state is unknown, and
464
+ * falling back to a superseded one would be the stale-suppression failure this
465
+ * function's own tie-break exists to prevent.
466
+ */
467
+ export function knownBareNames(
468
+ records: readonly ObservationRecord[],
469
+ host: string,
470
+ ): Set<string> | null {
471
+ // The latest record for the host decides, and the mode is read OFF that
472
+ // record rather than used to select it. R2 finding 2: filtering `per-entry`
473
+ // first meant a NEWER `budget-strip-and-drop` record could never supersede
474
+ // an older `per-entry` one, so a host that changed truncation mode kept
475
+ // being filtered against a bare set from before the change — the precise
476
+ // failure the LATEST-WINS paragraph above claims to prevent.
477
+ //
478
+ // `headlineRecordPerHost` rather than a fourth inline tie-break (R2 finding
479
+ // 7): this module already imports it, already defines
480
+ // `latestPointableBarePerHost`, and its own comment cites `_supersedes` as
481
+ // the reason duplicate reducers are dangerous. Restating the rule here would
482
+ // have meant the runtime filter silently not inheriting any precedence the
483
+ // parent adds later.
484
+ //
485
+ // The `observed_at` pre-filter is R2 finding 6, and it is a pre-filter
486
+ // rather than a change to `_supersedes` because that reducer is shared.
487
+ // `_supersedes` compares dates with `!==` then `>`, and BOTH
488
+ // `undefined > "2026-08-12"` and `"2026-08-12" > undefined` are false — so a
489
+ // record with a missing or non-string date, once it becomes the incumbent,
490
+ // can never be displaced by a well-formed later one. One malformed line in
491
+ // an append-only log with two producers would pin the filter to it forever.
492
+ // Feeding the shared reducer only well-dated records avoids that without
493
+ // changing behaviour for its other callers.
494
+ const dated = records.filter(
495
+ (record) => record.host === host && typeof record.observed_at === 'string',
496
+ );
497
+ const latest = headlineRecordPerHost(dated).get(host);
498
+ if (latest === undefined) return null;
499
+ if (truncationModeOf(latest) !== 'per-entry') return null;
500
+ if (!Array.isArray(latest.bare_names)) return null;
501
+ return new Set(latest.bare_names.filter((name): name is string => typeof name === 'string'));
502
+ }
@@ -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(