@event4u/agent-config 9.18.1 → 9.19.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
9
- "version": "9.18.1",
9
+ "version": "9.19.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -294,6 +294,41 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
294
294
  > that forces a new era split (`# Era: 9.19.x`, etc.) — see
295
295
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
296
296
 
297
+ ## [9.19.0](https://github.com/event4u-app/agent-config/compare/9.18.1...9.19.0) (2026-08-04)
298
+
299
+ ### Release highlights
300
+
301
+ <!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
302
+ - **Behaviour changes:** _none_
303
+ - **Default changes + migration:** _none_
304
+ - **Security and correctness:** _none_
305
+ - **Honest nulls:** _auto-derived, rewrite before merge:_ commits carrying an honest-null marker in 1c744c3.
306
+ - **Known limitations:** _none_
307
+
308
+ ### Features
309
+
310
+ * **gate-coverage:** ratchet the unhardened-gate count, armed at 189 and able to fail ([5d1564f](https://github.com/event4u-app/agent-config/commit/5d1564fe0dcbeb340c97f33a29245a54c43a8591))
311
+ * **gate-coverage:** publish the asserted count from five gates, 17 -> 22 covered ([d228aa5](https://github.com/event4u-app/agent-config/commit/d228aa5cb5019dffe11c0e4606644630450d0816))
312
+
313
+ ### Bug Fixes
314
+
315
+ * **roadmap:** repair the archived roadmap's own relative link ([ac85f3a](https://github.com/event4u-app/agent-config/commit/ac85f3a0fdefbbed74c50cc7049c5ffc72b408a8))
316
+ * **census:** make the scan-scope census reproducible, and regenerate it ([1e539ee](https://github.com/event4u-app/agent-config/commit/1e539ee96efc1807663342e084c2a0e666eaf9bc))
317
+ * **lint_handoffs:** stop the CI invocation resolving --quiet as the scan root ([6994781](https://github.com/event4u-app/agent-config/commit/6994781875f8d1d2feab6012ac7704e04232447e))
318
+ * **gates:** name the fence hole, drop the dead sort ([16dace4](https://github.com/event4u-app/agent-config/commit/16dace44b7d468aaba95abf16ec79d65d9862db8))
319
+
320
+ ### Documentation
321
+
322
+ * **roadmap:** close road-to-gates-that-can-fail, re-charter the adoption gap ([34ff10c](https://github.com/event4u-app/agent-config/commit/34ff10c0748ea7e1c4cfdb99f423c35bba1c9a98))
323
+ * **claims:** register the exit-2 rate alarm in the Stage-A protocol ([4653841](https://github.com/event4u-app/agent-config/commit/46538415f7a59993aef7575f32cc84582a03126f))
324
+ * **review:** unsteered post-merge blind pass — 5 findings, 1 critical ([1c744c3](https://github.com/event4u-app/agent-config/commit/1c744c395f8d1bb727be946f459ce5a498320b5d))
325
+
326
+ ### Chores
327
+
328
+ * **census:** regenerate after merging origin/main ([239f141](https://github.com/event4u-app/agent-config/commit/239f1413b950455efbe379976ea34a67ff02d380))
329
+
330
+ Tests: 10972 (+9 since 9.18.1)
331
+
297
332
  ## [9.18.1](https://github.com/event4u-app/agent-config/compare/9.18.0...9.18.1) (2026-08-04)
298
333
 
299
334
  ### Release highlights
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "9.18.1"
42
+ agent_config_version: "9.19.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-08-04T15:34:05Z`
3
+ - Generated: `2026-08-04T16:08:00Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -14402,7 +14402,7 @@
14402
14402
  "reason": "scaffold template for ticket-bundle tickets (ADR-101); carries no runtime frontmatter — emit-tickets fills it in per ticket"
14403
14403
  }
14404
14404
  ],
14405
- "generated_at": "2026-08-04T15:34:05Z",
14405
+ "generated_at": "2026-08-04T16:08:00Z",
14406
14406
  "packs": [
14407
14407
  {
14408
14408
  "artefact_count": 117,
@@ -1 +1 @@
1
- 9221573649a81d2b695576370a341ecdcd2d64ba0246a685d3caaf4582a78ca8 discovery-manifest.json
1
+ 771e350a2329d84c1a47378ed09ebeed75e5ada993d722cfc079b3f5befec595 discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-08-04T15:34:05Z`
3
+ - Generated: `2026-08-04T16:08:00Z`
4
4
  - Scanner: `c24312e116f8`
5
5
  - Artefacts: **594**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-08-04T15:34:05Z`
3
+ - Generated: `2026-08-04T16:08:00Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-04T15:34:05Z",
3
+ "generated_at": "2026-08-04T16:08:00Z",
4
4
  "packs": [
5
5
  {
6
6
  "always_on": false,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-08-04T15:34:05Z`
3
+ - Generated: `2026-08-04T16:08:00Z`
4
4
  - Workspaces tracked: **10**
5
5
  - Human-review-required artefacts: **3**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-04T15:34:05Z",
3
+ "generated_at": "2026-08-04T16:08:00Z",
4
4
  "scanner_version": "c24312e116f8",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "9.18.1"
12
+ "version": "9.19.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "9.18.1"
12
+ "version": "9.19.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "9.18.1"
19
+ "version": "9.19.0"
20
20
  }
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": "9.18.1",
4
+ "version": "9.19.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,
@@ -40,25 +40,43 @@
40
40
  # visible, or it repeats the sin it exists to catch.
41
41
  # canary optional mutation recipe (Phase 7) — see CANARY below
42
42
  #
43
- # COVERAGE — THE HONEST DENOMINATOR (measured 2026-08-02)
44
- # ------------------------------------------------------
45
- # 211 `lint_*` / `check_*` / `audit_*` scripts live under `src/scripts/`.
46
- # **8 of them emit the machine-readable `scanned: <N>` line under their
47
- # CI-identical argv, and all 8 are listed below.** The manifest is therefore
48
- # complete with respect to its own contract, and covers ~4 % of the gate
49
- # population.
43
+ # COVERAGE — THE HONEST DENOMINATOR (re-measured 2026-08-04)
44
+ # ----------------------------------------------------------
45
+ # 223 `lint_*` / `check_*` / `audit_*` scripts live under `src/scripts/`
46
+ # (the count is computed, not asserted — `check_gate_coverage.count_gate_scripts`).
47
+ # **22 of them emit the machine-readable `scanned: <N>` line under their
48
+ # CI-identical argv, and all 22 are listed below.** The manifest is therefore
49
+ # complete with respect to its own contract, and covers ~10 % of the population.
50
50
  #
51
- # The other ~203 are NOT covered, for one reason: rule 1. They print their count
51
+ # The previous revision of this header claimed "8 of 211". Both halves had
52
+ # drifted: entries were added without the header following, and the population
53
+ # grew by 12. Numbers written in prose beside a mechanism that can compute them
54
+ # is how a denominator goes stale — the population figure now comes from the
55
+ # function, and the honest-denominator test bounds the drift.
56
+ #
57
+ # The other ~201 are NOT covered, for one reason: rule 1. They print their count
52
58
  # in prose ("111 file(s) scanned", "0 declarer(s)") or not at all, and a guard
53
59
  # that parses prose is the fragile thing being replaced. Padding this list with
54
60
  # them would manufacture exactly the false green the file exists to prevent.
55
61
  #
56
- # Closing the gap is one line per gate, not a new mechanism: a gate that already
57
- # computes its count for `_lib/scan_scope.assertScanned` need only also write
58
- # `scanned: <N>`. Seven gates are one line away today — they call `assertScanned`
59
- # / `assertWatchlistResolves` with a real count in hand: `audit_skill_overlap`,
60
- # `check_iron_law_prominence`, `check_safety_floor_untouched`, `lint_handoffs`,
61
- # `lint_namespace`, `lint_artefact_frontmatter`, `skill_linter` (listed).
62
+ # THE "SEVEN GATES ONE LINE AWAY" NOTE IS DISCHARGED (2026-08-04). It named
63
+ # `audit_skill_overlap`, `check_iron_law_prominence`, `check_safety_floor_untouched`,
64
+ # `lint_handoffs`, `lint_namespace`, `lint_artefact_frontmatter`, `skill_linter`.
65
+ # Five are now converted and listed below; `skill_linter` was already listed when
66
+ # the note was written. `check_safety_floor_untouched` is deliberately NOT
67
+ # converted: it is the one watch-list gate, so its "count" is the size of a fixed
68
+ # guard list (4), not a corpus. A floor under a constant can never trip — that is
69
+ # the false-count shape Phase 2 of the roadmap repaired, and re-introducing it
70
+ # here to raise a coverage number would be the manufactured green this file
71
+ # exists to prevent. It stays unlisted, named, with the reason.
72
+ #
73
+ # What remains is NOT one line per gate. For the ~201 the count would have to be
74
+ # INVENTED (what is this gate's unit?) rather than published, and that judgement
75
+ # is per-gate. It is tracked as a vulnerability ratchet — `gate-hardening:
76
+ # unhardened-scan-scope` in src/config/gate-violation-baselines.json, which fails
77
+ # on a RISE and targets 0 — and charter-owned by
78
+ # `agents/roadmaps/road-to-gate-hardening-adoption.md`.
79
+ #
62
80
  # `tests/scripts/check_gate_coverage.test.ts` fails the build the moment a gate
63
81
  # starts emitting the line without being registered here, so the list cannot
64
82
  # silently fall behind again (that omission is exactly how `check_site_links`
@@ -346,3 +364,64 @@ gates:
346
364
  from the CI invocation; nothing else changes at that switch. The entry
347
365
  stays registered so the guard reports this gate rather than skipping it
348
366
  silently.
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # The five below close the gap this header named: they already called
370
+ # `_lib/scan_scope.assertScanned` with a real count and only had to PRINT it.
371
+ # The published number is not agent-derived — it is byte-identical to the value
372
+ # the gate's own dead-scope assertion already gates a red exit on, so there is
373
+ # no fresh judgement about "what a unit is" and no room for a count that drifts
374
+ # from what the gate inspected. That property is what made them convertible
375
+ # without per-gate adjudication; gates whose count would have to be INVENTED
376
+ # are deliberately NOT in this batch — see road-to-gate-hardening-adoption.
377
+ # Floors are ~87 % of the measured baseline: room for legitimate deletions,
378
+ # far above a collapse (rule 3).
379
+ # ---------------------------------------------------------------------------
380
+
381
+ - id: lint_handoffs
382
+ argv: ["--quiet"]
383
+ min_scanned: 250
384
+ corpus: "src/skills/*/SKILL.md (288 at baseline)"
385
+ status: enforced
386
+ note: >
387
+ The argv here is load-bearing, not decoration. Taskfile injects `--quiet`
388
+ (QUIET_FLAG) and this gate read `args[0]` as a positional path, so the CI
389
+ invocation resolved `--quiet` as its skills root, scanned 0 and exited 2 —
390
+ red in `task lint-handoffs`, green when probed bare. Fixed by filtering
391
+ flag-shaped args before the positional read. Registering it under the REAL
392
+ argv is what keeps that regression caught: a bare-probe entry would have
393
+ gone on passing over the broken invocation.
394
+
395
+ - id: lint_namespace
396
+ argv: ["--quiet"]
397
+ min_scanned: 540
398
+ corpus: "names across src/rules + src/skills + src/agent-src + src/domains/**/command.md (621 at baseline)"
399
+ status: enforced
400
+ note: >
401
+ Counts NAMES, not files — the scan-scope census counts files under the same
402
+ roots, so the two artefacts legitimately report different numbers here.
403
+
404
+ - id: lint_artefact_frontmatter
405
+ argv: ["--quiet"]
406
+ min_scanned: 540
407
+ corpus: "artefacts carrying frontmatter across the discovery roots (619 at baseline)"
408
+ status: enforced
409
+
410
+ - id: audit_skill_overlap
411
+ argv: ["--quiet", "--print-table", "--strict"]
412
+ min_scanned: 250
413
+ corpus: "src/skills/*/SKILL.md compared pairwise (288 at baseline)"
414
+ status: enforced
415
+ note: >
416
+ `--strict` is part of the CI invocation and can exit non-zero on a real
417
+ overlap finding. The guard reads the count independently of the exit code —
418
+ coverage and verdict are different questions.
419
+
420
+ - id: check_iron_law_prominence
421
+ argv: ["--quiet"]
422
+ min_scanned: 90
423
+ corpus: "src/rules/*.md (111 at baseline)"
424
+ status: enforced
425
+ note: >
426
+ Emits on stderr, not stdout: `--format json` owns this gate's stdout and a
427
+ count written there would corrupt the payload. The guard reads both streams.
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "$comment": "Violation ratchet for gates whose dead scan root was repaired. A repair REVEALS pre-existing debt; it does not create it. Each entry records the count revealed at repair time — the gate fails only when its count RISES. Lowering a count is a normal commit; raising one is a defect. Entries expire: a baseline that has not dropped within 56 days of `landed` (or of `reaffirmed.date`) fails its gate, because a number that never moves is suppression with extra steps. Mechanism + rationale: src/scripts/_lib/gate_baseline.ts. Decision: AI council 2026-08-02, roadmap road-to-gates-that-can-fail blocker `dead-gate-finding-triage`.",
3
3
  "gates": {
4
+ "gate-hardening:unhardened-scan-scope": {
5
+ "count": 189,
6
+ "landed": "2026-08-04",
7
+ "note": "Gate scripts under src/scripts (population 223, the lint_/check_/audit_ prefix set) that neither route through _lib/scan_scope nor emit the machine-readable 'scanned:' line — the gates that can still report success over a moved or empty root without saying so. This is a VULNERABILITY count, not a coverage percentage: it measures how much of the original defect class is still reachable, it RISES whenever a new gate is written without a scope assertion, and its target is 0. That direction is the whole point — an adoption-reach metric ('hardened gates must increase') can never regress and would be threshold-lowering wearing a ratchet's clothes (AI council 2026-08-04, anthropic/claude-sonnet-4-5 + openai/gpt-4o, convergent on scope decomposition plus this discriminator). Clearing it is the charter of road-to-gate-hardening-adoption; the 56-day non-stagnation clause applies unchanged, so a number that never drops fails this gate rather than hardening into configuration. NEW ENTRY, not a raised threshold: this records newly-measured pre-existing exposure at first measurement — nothing was loosened."
8
+ },
4
9
  "check_no_new_legacy_path:hardcoded-scan-roots": {
5
10
  "count": 51,
6
11
  "landed": "2026-08-02",
@@ -499,6 +499,10 @@ export function main(argv: string[] | null = null): number {
499
499
  process.stderr.write(`❌ ${exc.message}\n`);
500
500
  return 3;
501
501
  }
502
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
503
+ // asserted count machine-readably. Written to stderr, not stdout, because
504
+ // this audit's stdout carries the report body consumers read.
505
+ process.stderr.write(`scanned: ${String(skills.length)}\n`);
502
506
  const pairs = find_pairs(skills, args.threshold);
503
507
  const outDir = args.outDir ?? REPORT_DIR;
504
508
  const outJson = path.join(outDir, OUT_JSON_NAME);
@@ -416,8 +416,26 @@ function fenceParts(line: string): { ticks: number; info: string } | null {
416
416
  * So no arrangement of bare fences can hide a row — a swallowed `open` finding
417
417
  * needs a deliberate label, which is the authoring act that means "illustration".
418
418
  * Closing follows CommonMark (bare, and at least as many backticks as the
419
- * opener), which also makes a ```` ````-wrapped ``` block nest correctly. An
420
- * unterminated labelled opener skips nothing either and is itself a stray.
419
+ * opener), which also makes a ```` ````-wrapped ``` block nest correctly. A
420
+ * labelled opener with NO bare fence after it anywhere is a stray and skips
421
+ * nothing.
422
+ *
423
+ * KNOWN HOLE, still open — this function does NOT deliver the property the two
424
+ * paragraphs above imply (blind-pass findings 1-3, 2026-08-04). A labelled
425
+ * opener is closed by the first later bare fence ANYWHERE in the artefact, even
426
+ * one the author never meant as its closer: the lines between them are skipped,
427
+ * a live `open` row among them vanishes from `rows`, and because the opener was
428
+ * consumed `strays` stays empty so no `unbalanced-fence` fires. Reproduced; an
429
+ * unreviewed `open` finding passes. The remediation string this file prints for
430
+ * `unbalanced-fence` leads an author into exactly that arrangement.
431
+ *
432
+ * Not patched here on purpose: the obvious guard — a findings-shaped row inside
433
+ * a fenced region is a violation — fires on every artefact that quotes the
434
+ * template, including the skeleton `dispatch_r2_reviewer` writes, which carries
435
+ * an `open` row. The fix is a §2.2 grammar decision (declare illustrative
436
+ * regions explicitly, or move the safety check off fence pairing), recorded in
437
+ * the contract as open rather than replaced with a guard that reds every future
438
+ * artefact.
421
439
  */
422
440
  export function scanFences(lines: readonly string[]): FenceScan {
423
441
  const fenced = new Set<number>();
@@ -448,7 +466,10 @@ export function scanFences(lines: readonly string[]): FenceScan {
448
466
  if (open !== null) {
449
467
  strays.push(open.at + 1);
450
468
  }
451
- strays.sort((a, b) => a - b);
469
+ // No sort: bare strays are only pushed while no opener is open, so they land
470
+ // in ascending line order, and the unterminated-opener push above is always
471
+ // the largest index. A sort here was dead code (blind-pass finding 5) —
472
+ // keeping it would imply an ordering hazard that cannot occur.
452
473
  return { fenced, strays };
453
474
  }
454
475
 
@@ -49,6 +49,7 @@ import process from 'node:process';
49
49
  import { fileURLToPath, pathToFileURL } from 'node:url';
50
50
 
51
51
  import * as yaml from 'js-yaml';
52
+ import { checkRatchet } from './_lib/gate_baseline.js';
52
53
  import { runCountedProbe } from './_lib/counted_probe.js';
53
54
 
54
55
  const _HERE = fileURLToPath(import.meta.url);
@@ -318,14 +319,76 @@ export function main(argv: readonly string[]): number {
318
319
  `⚠️ ${String(pending.length)} gate(s) are listed but NOT enforced — this guard's coverage is partial by declaration.\n`,
319
320
  );
320
321
  }
322
+ const hardening = report_hardening_ratchet();
321
323
  if (failed.length > 0) {
322
324
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
323
325
  return 1;
324
326
  }
327
+ if (hardening !== 0) {
328
+ return hardening;
329
+ }
325
330
  process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
326
331
  return 0;
327
332
  }
328
333
 
334
+ /**
335
+ * Vulnerability ratchet — the count that must not RISE.
336
+ *
337
+ * This deliberately measures **defect exposure**, not adoption reach. A
338
+ * coverage-percentage ratchet ("hardened gates must increase") would be
339
+ * threshold-lowering in a ratchet's clothes: it tracks how far the fix has
340
+ * spread and can never regress, so it grades the solution instead of the
341
+ * problem. The number below is the opposite — how many gates could still exit 0
342
+ * over a moved root without saying so. It CAN regress (every new gate written
343
+ * without a scope assertion raises it) and the target stays 0.
344
+ *
345
+ * Recorded via the existing violation ratchet (`_lib/gate_baseline.ts`), so the
346
+ * 56-day non-stagnation clause applies unchanged: a count that never drops
347
+ * fails, because a frozen baseline is suppression with extra steps.
348
+ */
349
+ export function report_hardening_ratchet(): number {
350
+ const unhardened = list_unhardened_gates();
351
+ const verdict = checkRatchet({
352
+ gate: 'gate-hardening:unhardened-scan-scope',
353
+ actual: unhardened.length,
354
+ repoRoot: REPO_ROOT,
355
+ });
356
+ process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
357
+ return verdict.ok ? 0 : 1;
358
+ }
359
+
360
+ /**
361
+ * Gate scripts that neither assert their scan scope nor publish a count — the
362
+ * population still able to report success over an empty corpus.
363
+ *
364
+ * A gate counts as hardened when it routes through `_lib/scan_scope` (the
365
+ * assertion that turns a zero scan into a red exit) OR emits the machine-
366
+ * readable `scanned:` line the coverage guard reads. Either one makes a dead
367
+ * root visible; neither is satisfiable by a gate that read nothing.
368
+ */
369
+ export function list_unhardened_gates(dir = path.join(REPO_ROOT, 'src/scripts')): string[] {
370
+ let entries: string[];
371
+ try {
372
+ entries = fs.readdirSync(dir);
373
+ } catch {
374
+ return [];
375
+ }
376
+ const gates = entries.filter((f) => /^(lint|check|audit)_.*\.ts$/.test(f) && !f.endsWith('.d.ts'));
377
+ const out: string[] = [];
378
+ for (const f of gates) {
379
+ let src: string;
380
+ try {
381
+ src = fs.readFileSync(path.join(dir, f), 'utf8');
382
+ } catch {
383
+ continue;
384
+ }
385
+ const asserts = /assertScanned\(|assertWatchlistResolves\(/.test(src);
386
+ const emits = /(?:process\.(?:stdout|stderr)\.write|lines\.push)\(\s*`scanned: /.test(src);
387
+ if (!asserts && !emits) out.push(f.replace(/\.ts$/, ''));
388
+ }
389
+ return out.sort();
390
+ }
391
+
329
392
  function spec_is_pending(s: GateSpec): boolean {
330
393
  return s.status === 'pending';
331
394
  }
@@ -277,6 +277,10 @@ export function main(argv?: readonly string[]): number {
277
277
  process.stderr.write(`❌ ${exc instanceof Error ? exc.message : String(exc)}\n`);
278
278
  return 2;
279
279
  }
280
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
281
+ // asserted count machine-readably. Written to stderr because `--format json`
282
+ // owns stdout here — a count on stdout would corrupt the JSON payload.
283
+ process.stderr.write(`scanned: ${String(targets.length)}\n`);
280
284
 
281
285
  const all_violations: Violation[] = [];
282
286
  for (const p of targets) {
@@ -359,6 +359,10 @@ function main(argv?: readonly string[]): number {
359
359
  process.stderr.write(`❌ ${exc.message}\n`);
360
360
  return 2;
361
361
  }
362
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
363
+ // asserted count machine-readably, before the verdict branches, so a run
364
+ // that finds errors still reports the corpus it read.
365
+ process.stdout.write(`scanned: ${String(artefacts.length)}\n`);
362
366
  const allErrs: string[] = [];
363
367
  for (const p of artefacts) {
364
368
  allErrs.push(..._check_one(p, wsIds, packIds, quarantine));
@@ -362,7 +362,13 @@ export function validate_handoff_artifact(text: string): string[] {
362
362
 
363
363
  export function main(argv?: readonly string[]): number {
364
364
  let skills_dir = SKILLS_DIR;
365
- const args = argv ?? process.argv.slice(2);
365
+ // Positional-only: flags are NOT paths. Before this filter the CI invocation
366
+ // (`lint_handoffs --quiet`, injected by Taskfile's QUIET_FLAG) resolved
367
+ // `--quiet` as the skills root, scanned 0 files and exited 2 — the gate was
368
+ // red under the exact argv CI runs while green when probed bare. That is the
369
+ // inverse of this suite's dead-scope defect and the reason gate-coverage
370
+ // rule 2 pins CI-identical argv.
371
+ const args = (argv ?? process.argv.slice(2)).filter((a) => !String(a).startsWith('-'));
366
372
  if (args.length > 0 && String(args[0]).endsWith('HANDOFF.md')) {
367
373
  // artifact mode — validate the workflow-resume file's required fields
368
374
  const text = fs.readFileSync(_resolve(args[0] as string), 'utf-8');
@@ -399,6 +405,12 @@ export function main(argv?: readonly string[]): number {
399
405
  process.stderr.write(`❌ ${exc.message}\n`);
400
406
  return 2;
401
407
  }
408
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
409
+ // count the assertion above just validated. Emitted before the verdict
410
+ // branches so a run WITH violations still reports its corpus — coverage and
411
+ // verdict are different questions — and outside the QUIET guard, because CI
412
+ // passes --quiet and a count only visible without it is not a count.
413
+ process.stdout.write(`scanned: ${String(scanned)}\n`);
402
414
 
403
415
  const violations = lint(skills_dir);
404
416
  if (violations.length === 0) {
@@ -347,6 +347,11 @@ function main(argv?: readonly string[]): number {
347
347
  process.stderr.write(`❌ ${exc.message}\n`);
348
348
  return 2;
349
349
  }
350
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
351
+ // asserted count machine-readably. The human line below is quiet-suppressed
352
+ // and only prints on the clean path; this one prints on both, because a gate
353
+ // that found issues still scanned its corpus.
354
+ process.stdout.write(`scanned: ${String(checked)}\n`);
350
355
  if (issues) {
351
356
  process.stderr.write(`BASELINE: ${issues} issue(s) across ${checked} name(s)\n`);
352
357
  return 1;
@@ -372,7 +372,7 @@ export function analyze(src: string, repoRoot: string): Analysis {
372
372
  const confirmed: Finding[] = [];
373
373
  const unproven: Finding[] = [];
374
374
  const roots = new Map<string, string>();
375
- const missing = (rel: string): boolean => !fs.existsSync(path.join(repoRoot, rel));
375
+ const missing = (rel: string): boolean => !rootExists(repoRoot, rel);
376
376
 
377
377
  for (const [rel, e] of found) {
378
378
  roots.set(rel, [...e.names].join(',') || '(inline)');
@@ -549,12 +549,46 @@ export function censusOnlyRoots(src: string, repoRoot: string): Map<string, stri
549
549
  if (rel === '' || rel.startsWith('..')) continue;
550
550
  // `node_modules/.bin/tsx` is a tool path a gate SPAWNS, not a corpus it reads.
551
551
  if (rel === 'node_modules' || rel.startsWith('node_modules/')) continue;
552
- if (!fs.existsSync(path.join(repoRoot, rel))) continue; // the safety filter — see the doc comment
552
+ if (!rootExists(repoRoot, rel)) continue; // the safety filter — see the doc comment
553
553
  if (!out.has(rel)) out.set(rel, '(census-only)');
554
554
  }
555
555
  return out;
556
556
  }
557
557
 
558
+ /**
559
+ * Does a declared root exist, independent of checkout shape?
560
+ *
561
+ * A plain `existsSync` answers differently in a linked git worktree, where
562
+ * `.git` is a FILE containing `gitdir: …` rather than a directory — so a root
563
+ * like `.git/HEAD` resolves in a clone and vanishes in a worktree. The census
564
+ * has to "match a fresh run" to be worth committing, and a report whose rows
565
+ * depend on which checkout generated it cannot. Resolving `.git/*` through the
566
+ * real git directory makes the two agree.
567
+ */
568
+ export function resolveRoot(repoRoot: string, rel: string): string {
569
+ if (rel === '.git' || rel.startsWith('.git/')) {
570
+ const gitPath = path.join(repoRoot, '.git');
571
+ try {
572
+ if (fs.statSync(gitPath).isFile()) {
573
+ // `gitdir: <abs-or-relative path>` — the worktree's real git dir.
574
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
575
+ const target = m?.[1]?.trim();
576
+ if (target !== undefined && target !== '') {
577
+ const gitDir = path.isAbsolute(target) ? target : path.join(repoRoot, target);
578
+ return rel === '.git' ? gitDir : path.join(gitDir, rel.slice('.git/'.length));
579
+ }
580
+ }
581
+ } catch {
582
+ /* fall through to the plain join — a missing .git is genuinely absent */
583
+ }
584
+ }
585
+ return path.join(repoRoot, rel);
586
+ }
587
+
588
+ export function rootExists(repoRoot: string, rel: string): boolean {
589
+ return fs.existsSync(resolveRoot(repoRoot, rel));
590
+ }
591
+
558
592
  /** Directories never worth walking for a unit count. */
559
593
  const COUNT_SKIP: ReadonlySet<string> = new Set(['node_modules', '.git', '.venv', '__pycache__']);
560
594
 
@@ -578,7 +612,7 @@ export interface RootCount {
578
612
  * a container migration shows up as a diff instead of as silence.
579
613
  */
580
614
  export function countUnits(repoRoot: string, rel: string): RootCount {
581
- const abs = path.join(repoRoot, rel);
615
+ const abs = resolveRoot(repoRoot, rel);
582
616
  let st: fs.Stats;
583
617
  try {
584
618
  st = fs.statSync(abs);