@event4u/agent-config 9.18.1 → 9.20.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +73 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/package.json +1 -1
- package/src/config/gate-coverage.yml +109 -14
- package/src/config/gate-violation-baselines.json +5 -0
- package/src/scripts/audit_skill_overlap.ts +4 -0
- package/src/scripts/check_completion_review.ts +43 -7
- package/src/scripts/check_gate_coverage.ts +63 -0
- package/src/scripts/check_iron_law_prominence.ts +4 -0
- package/src/scripts/check_review_dispositions.ts +188 -0
- package/src/scripts/dispatch_r2_reviewer.ts +3 -0
- package/src/scripts/lint_artefact_frontmatter.ts +4 -0
- package/src/scripts/lint_handoffs.ts +13 -1
- package/src/scripts/lint_namespace.ts +5 -0
- package/src/scripts/sweep_dead_scan_roots.ts +37 -3
|
@@ -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.
|
|
9
|
+
"version": "9.20.0",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"agent-config",
|
|
12
12
|
"skills",
|
package/CHANGELOG.md
CHANGED
|
@@ -294,6 +294,79 @@ 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.20.0](https://github.com/event4u-app/agent-config/compare/9.19.0...9.20.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:** _auto-derived, rewrite before merge:_ commits naming a default, migration or migrate in 14dc053, bb362a0.
|
|
304
|
+
- **Security and correctness:** _none_
|
|
305
|
+
- **Honest nulls:** _auto-derived, rewrite before merge:_ commits carrying an honest-null marker in ba77e6a.
|
|
306
|
+
- **Known limitations:** _none_
|
|
307
|
+
|
|
308
|
+
### Features
|
|
309
|
+
|
|
310
|
+
* **gates:** read the archived round records nothing was reading ([89eea71](https://github.com/event4u-app/agent-config/commit/89eea712b89048a032dfccf8a3bdd41f565adacc))
|
|
311
|
+
|
|
312
|
+
### Bug Fixes
|
|
313
|
+
|
|
314
|
+
* **gates:** stop the remediation text pointing at the closed hole ([2dc9e0d](https://github.com/event4u-app/agent-config/commit/2dc9e0d91262864237e50268d8ea54dcce761a62))
|
|
315
|
+
* **gates:** rows are live wherever they appear — close the fence fail-open ([ba77e6a](https://github.com/event4u-app/agent-config/commit/ba77e6a6f4cc5c31b9de4741ee30ccc41e126df7))
|
|
316
|
+
|
|
317
|
+
### Documentation
|
|
318
|
+
|
|
319
|
+
* **evidence:** close the blind-pass dispositions and correct a claim I made ([0398238](https://github.com/event4u-app/agent-config/commit/0398238d99f7451712e710204c86ab41f964de50))
|
|
320
|
+
* **contracts:** 2.7 is enforced now, and says what it still does not check ([d0b3650](https://github.com/event4u-app/agent-config/commit/d0b365035dc314183dcb0e353a09e3e2c5420284))
|
|
321
|
+
* **roadmap:** pin-then-migrate ordering; drop the index for a round-record check ([bb362a0](https://github.com/event4u-app/agent-config/commit/bb362a0b64a5e16a156d238c1acadded44f229aa))
|
|
322
|
+
* **roadmap:** record the council verdict on the fence grammar ([3e0deff](https://github.com/event4u-app/agent-config/commit/3e0deff6256050aa23fe952707ab88bf7bc9647e))
|
|
323
|
+
* **review:** give the blind-pass findings terminal statuses and a carrier ([667b81e](https://github.com/event4u-app/agent-config/commit/667b81e9fa44d892095a7b057a6407196aa429bf))
|
|
324
|
+
|
|
325
|
+
### Tests
|
|
326
|
+
|
|
327
|
+
* **gates:** pin every fence arrangement before the grammar migration ([14dc053](https://github.com/event4u-app/agent-config/commit/14dc053d51ac1e932ce4deeb50ffb2e07737bd84))
|
|
328
|
+
|
|
329
|
+
### CI
|
|
330
|
+
|
|
331
|
+
* **gates:** wire check_review_dispositions with a trippable floor ([9b03190](https://github.com/event4u-app/agent-config/commit/9b0319032494345fdcaad40ae79dbe3ea95f6fc8))
|
|
332
|
+
|
|
333
|
+
Tests: 10995 (+23 since 9.19.0)
|
|
334
|
+
|
|
335
|
+
## [9.19.0](https://github.com/event4u-app/agent-config/compare/9.18.1...9.19.0) (2026-08-04)
|
|
336
|
+
|
|
337
|
+
### Release highlights
|
|
338
|
+
|
|
339
|
+
<!-- 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. -->
|
|
340
|
+
- **Behaviour changes:** _none_
|
|
341
|
+
- **Default changes + migration:** _none_
|
|
342
|
+
- **Security and correctness:** _none_
|
|
343
|
+
- **Honest nulls:** _auto-derived, rewrite before merge:_ commits carrying an honest-null marker in 1c744c3.
|
|
344
|
+
- **Known limitations:** _none_
|
|
345
|
+
|
|
346
|
+
### Features
|
|
347
|
+
|
|
348
|
+
* **gate-coverage:** ratchet the unhardened-gate count, armed at 189 and able to fail ([5d1564f](https://github.com/event4u-app/agent-config/commit/5d1564fe0dcbeb340c97f33a29245a54c43a8591))
|
|
349
|
+
* **gate-coverage:** publish the asserted count from five gates, 17 -> 22 covered ([d228aa5](https://github.com/event4u-app/agent-config/commit/d228aa5cb5019dffe11c0e4606644630450d0816))
|
|
350
|
+
|
|
351
|
+
### Bug Fixes
|
|
352
|
+
|
|
353
|
+
* **roadmap:** repair the archived roadmap's own relative link ([ac85f3a](https://github.com/event4u-app/agent-config/commit/ac85f3a0fdefbbed74c50cc7049c5ffc72b408a8))
|
|
354
|
+
* **census:** make the scan-scope census reproducible, and regenerate it ([1e539ee](https://github.com/event4u-app/agent-config/commit/1e539ee96efc1807663342e084c2a0e666eaf9bc))
|
|
355
|
+
* **lint_handoffs:** stop the CI invocation resolving --quiet as the scan root ([6994781](https://github.com/event4u-app/agent-config/commit/6994781875f8d1d2feab6012ac7704e04232447e))
|
|
356
|
+
* **gates:** name the fence hole, drop the dead sort ([16dace4](https://github.com/event4u-app/agent-config/commit/16dace44b7d468aaba95abf16ec79d65d9862db8))
|
|
357
|
+
|
|
358
|
+
### Documentation
|
|
359
|
+
|
|
360
|
+
* **roadmap:** close road-to-gates-that-can-fail, re-charter the adoption gap ([34ff10c](https://github.com/event4u-app/agent-config/commit/34ff10c0748ea7e1c4cfdb99f423c35bba1c9a98))
|
|
361
|
+
* **claims:** register the exit-2 rate alarm in the Stage-A protocol ([4653841](https://github.com/event4u-app/agent-config/commit/46538415f7a59993aef7575f32cc84582a03126f))
|
|
362
|
+
* **review:** unsteered post-merge blind pass — 5 findings, 1 critical ([1c744c3](https://github.com/event4u-app/agent-config/commit/1c744c395f8d1bb727be946f459ce5a498320b5d))
|
|
363
|
+
|
|
364
|
+
### Chores
|
|
365
|
+
|
|
366
|
+
* **census:** regenerate after merging origin/main ([239f141](https://github.com/event4u-app/agent-config/commit/239f1413b950455efbe379976ea34a67ff02d380))
|
|
367
|
+
|
|
368
|
+
Tests: 10972 (+9 since 9.18.1)
|
|
369
|
+
|
|
297
370
|
## [9.18.1](https://github.com/event4u-app/agent-config/compare/9.18.0...9.18.1) (2026-08-04)
|
|
298
371
|
|
|
299
372
|
### 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.
|
|
42
|
+
agent_config_version: "9.20.0"
|
|
43
43
|
|
|
44
44
|
# --- Project identity ---
|
|
45
45
|
project:
|
|
@@ -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-
|
|
14405
|
+
"generated_at": "2026-08-04T21:41:44Z",
|
|
14406
14406
|
"packs": [
|
|
14407
14407
|
{
|
|
14408
14408
|
"artefact_count": 117,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
759c2227292efbd65092bc4a57aa783cb714db0e6d93a1a53f6f35ba940d32aa discovery-manifest.json
|
package/dist/mcp/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"transport": {
|
|
10
10
|
"type": "stdio"
|
|
11
11
|
},
|
|
12
|
-
"version": "9.
|
|
12
|
+
"version": "9.20.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.
|
|
19
|
+
"version": "9.20.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.
|
|
4
|
+
"version": "9.20.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-
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
|
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
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# `
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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,80 @@ 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
|
+
- id: check_review_dispositions
|
|
369
|
+
argv: []
|
|
370
|
+
min_scanned: 7
|
|
371
|
+
corpus: "archived round records — `*-review.md` under agents/evidence/reviews/, excluding live `*.findings.md` (9 at baseline)"
|
|
372
|
+
status: enforced
|
|
373
|
+
note: >
|
|
374
|
+
Contract § 2.7. Unlike the R2 entry above, this floor is genuinely
|
|
375
|
+
trippable: the corpus is 9 frozen records and archived records are not
|
|
376
|
+
deleted, so a drop to 7 means the reviews root moved or the naming
|
|
377
|
+
convention drifted — not that the work went clean. That is precisely the
|
|
378
|
+
case rule 3 exists for. The gate reads the FROZEN records because R2
|
|
379
|
+
selects only artefacts in the CURRENT review scope, so a superseded round
|
|
380
|
+
renamed out of the `*.findings.md` glob is read by nothing at all — two
|
|
381
|
+
findings sat recorded as `open` in this repo's own corpus for a day
|
|
382
|
+
through exactly that gap.
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# The five below close the gap this header named: they already called
|
|
386
|
+
# `_lib/scan_scope.assertScanned` with a real count and only had to PRINT it.
|
|
387
|
+
# The published number is not agent-derived — it is byte-identical to the value
|
|
388
|
+
# the gate's own dead-scope assertion already gates a red exit on, so there is
|
|
389
|
+
# no fresh judgement about "what a unit is" and no room for a count that drifts
|
|
390
|
+
# from what the gate inspected. That property is what made them convertible
|
|
391
|
+
# without per-gate adjudication; gates whose count would have to be INVENTED
|
|
392
|
+
# are deliberately NOT in this batch — see road-to-gate-hardening-adoption.
|
|
393
|
+
# Floors are ~87 % of the measured baseline: room for legitimate deletions,
|
|
394
|
+
# far above a collapse (rule 3).
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
- id: lint_handoffs
|
|
398
|
+
argv: ["--quiet"]
|
|
399
|
+
min_scanned: 250
|
|
400
|
+
corpus: "src/skills/*/SKILL.md (288 at baseline)"
|
|
401
|
+
status: enforced
|
|
402
|
+
note: >
|
|
403
|
+
The argv here is load-bearing, not decoration. Taskfile injects `--quiet`
|
|
404
|
+
(QUIET_FLAG) and this gate read `args[0]` as a positional path, so the CI
|
|
405
|
+
invocation resolved `--quiet` as its skills root, scanned 0 and exited 2 —
|
|
406
|
+
red in `task lint-handoffs`, green when probed bare. Fixed by filtering
|
|
407
|
+
flag-shaped args before the positional read. Registering it under the REAL
|
|
408
|
+
argv is what keeps that regression caught: a bare-probe entry would have
|
|
409
|
+
gone on passing over the broken invocation.
|
|
410
|
+
|
|
411
|
+
- id: lint_namespace
|
|
412
|
+
argv: ["--quiet"]
|
|
413
|
+
min_scanned: 540
|
|
414
|
+
corpus: "names across src/rules + src/skills + src/agent-src + src/domains/**/command.md (621 at baseline)"
|
|
415
|
+
status: enforced
|
|
416
|
+
note: >
|
|
417
|
+
Counts NAMES, not files — the scan-scope census counts files under the same
|
|
418
|
+
roots, so the two artefacts legitimately report different numbers here.
|
|
419
|
+
|
|
420
|
+
- id: lint_artefact_frontmatter
|
|
421
|
+
argv: ["--quiet"]
|
|
422
|
+
min_scanned: 540
|
|
423
|
+
corpus: "artefacts carrying frontmatter across the discovery roots (619 at baseline)"
|
|
424
|
+
status: enforced
|
|
425
|
+
|
|
426
|
+
- id: audit_skill_overlap
|
|
427
|
+
argv: ["--quiet", "--print-table", "--strict"]
|
|
428
|
+
min_scanned: 250
|
|
429
|
+
corpus: "src/skills/*/SKILL.md compared pairwise (288 at baseline)"
|
|
430
|
+
status: enforced
|
|
431
|
+
note: >
|
|
432
|
+
`--strict` is part of the CI invocation and can exit non-zero on a real
|
|
433
|
+
overlap finding. The guard reads the count independently of the exit code —
|
|
434
|
+
coverage and verdict are different questions.
|
|
435
|
+
|
|
436
|
+
- id: check_iron_law_prominence
|
|
437
|
+
argv: ["--quiet"]
|
|
438
|
+
min_scanned: 90
|
|
439
|
+
corpus: "src/rules/*.md (111 at baseline)"
|
|
440
|
+
status: enforced
|
|
441
|
+
note: >
|
|
442
|
+
Emits on stderr, not stdout: `--format json` owns this gate's stdout and a
|
|
443
|
+
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.
|
|
420
|
-
*
|
|
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
|
-
|
|
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
|
|
|
@@ -474,10 +495,18 @@ export function parseArtifact(text: string): ParsedArtifact {
|
|
|
474
495
|
// LABELLED closed fence is illustrative content. A stray fence (bare, or
|
|
475
496
|
// an unclosed opener) skips nothing (see scanFences) — the lines around
|
|
476
497
|
// it are parsed, and the artefact is reported.
|
|
477
|
-
|
|
498
|
+
const trimmed = raw.trim();
|
|
499
|
+
// §2.2 row-liveness rule: fences govern the marker / honest-null / skip
|
|
500
|
+
// lines, which a fenced illustration may legitimately quote — but NOT
|
|
501
|
+
// findings rows. Hiding a marker or an honest-null fails CLOSED (the
|
|
502
|
+
// artefact reads as absent and the gate blocks); hiding a row fails OPEN
|
|
503
|
+
// (the finding silently ceases to exist). So the one line type whose
|
|
504
|
+
// concealment is dangerous is the one fences may not conceal, and a row
|
|
505
|
+
// is live wherever it appears. Only its own Status cell can excuse it.
|
|
506
|
+
const isCandidateRow = trimmed.startsWith('|');
|
|
507
|
+
if (FENCE_RE.test(raw) || (fences.fenced.has(i) && !isCandidateRow)) {
|
|
478
508
|
continue;
|
|
479
509
|
}
|
|
480
|
-
const trimmed = raw.trim();
|
|
481
510
|
// Marker candidates must LOOK like the §2.1 HTML comment: prose that
|
|
482
511
|
// merely mentions the grammar (`the \`completion-review:\` marker …`,
|
|
483
512
|
// or a reviewer note quoting it in backticks — real artefacts in this
|
|
@@ -523,6 +552,12 @@ export function parseArtifact(text: string): ParsedArtifact {
|
|
|
523
552
|
if (first === '#' || second === 'severity') {
|
|
524
553
|
continue; // header row
|
|
525
554
|
}
|
|
555
|
+
// §2.2: `example` is the ONE illustrative marker. It is read from the
|
|
556
|
+
// Status cell of a six-cell row only — a short row cannot excuse
|
|
557
|
+
// itself, or `malformed-row` would become opt-out.
|
|
558
|
+
if (cells.length === 6 && (cells[4] ?? '').trim().toLowerCase() === 'example') {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
526
561
|
// EXACTLY six, per contract §2.2 — not `>= 6`. An over-long row is
|
|
527
562
|
// as malformed as a short one and the surplus is usually the tell:
|
|
528
563
|
// an unescaped `|` inside a cell (the shape §2.2 requires escaping)
|
|
@@ -776,8 +811,9 @@ function evaluate(input: EvalInput): Violation[] {
|
|
|
776
811
|
detail:
|
|
777
812
|
`${at}: stray \`\`\` fence — a bare fence delimits no region, and a labelled fence must ` +
|
|
778
813
|
'be closed. An artefact whose ``` fences do not resolve is malformed (contract §2.2). ' +
|
|
779
|
-
'
|
|
780
|
-
'
|
|
814
|
+
'Close the fence, or inline-escape an illustrative ```. NOTE: fences never hide a ' +
|
|
815
|
+
'findings row — a row is live wherever it appears, and the ONLY way to mark one ' +
|
|
816
|
+
'illustrative is the status `example` in its own Status cell.',
|
|
781
817
|
});
|
|
782
818
|
}
|
|
783
819
|
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Archived-review disposition check (contract §2.7 + §2.2).
|
|
4
|
+
*
|
|
5
|
+
* Gate R2 selects only artefacts relevant to the CURRENT review scope (§2.6),
|
|
6
|
+
* and superseded rounds are renamed out of the `*.findings.md` glob on purpose.
|
|
7
|
+
* The consequence nobody noticed until it bit: **no validator reads archived
|
|
8
|
+
* round records at all**, so a finding left `open` after it was fixed is caught
|
|
9
|
+
* by nothing. That is exactly how two fixed findings sat recorded as `open` for
|
|
10
|
+
* a day in this repo's own corpus.
|
|
11
|
+
*
|
|
12
|
+
* This closes that gap with the smallest mechanism that covers the observed
|
|
13
|
+
* failure: archiving a round asserts the round is CLOSED, so every row in an
|
|
14
|
+
* archived record must carry a terminal status and a non-empty Reason/Ref.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NOT the disposition index with stable ids that was originally
|
|
17
|
+
* proposed. That design assumed dispositions live OUTSIDE the round records;
|
|
18
|
+
* measured across the corpus, they do not — records are terminal in place. An
|
|
19
|
+
* index would be a second artefact to keep in sync, and therefore a new drift
|
|
20
|
+
* source, guarding a failure mode that has not occurred. Trigger to revisit: a
|
|
21
|
+
* disposition that genuinely cannot be recorded in the round record itself.
|
|
22
|
+
*
|
|
23
|
+
* Scope: `*-review.md` under the reviews root, excluding `*.findings.md` (those
|
|
24
|
+
* are live artefacts and belong to `check_completion_review`).
|
|
25
|
+
*
|
|
26
|
+
* Reference SHAPE is deliberately NOT checked, and that was a measurement, not
|
|
27
|
+
* a preference. The first draft resolved a `fixed` ref via `rev-parse` and a
|
|
28
|
+
* `deferred` ref via a path probe. Run against the real corpus it produced 8
|
|
29
|
+
* blocks — every one of them a pre-existing archived record whose reference is
|
|
30
|
+
* prose describing the change, or a carrier named by bare slug. None of the 8
|
|
31
|
+
* was the failure this gate exists to catch. Archived records are frozen
|
|
32
|
+
* (§2.7), so a shape rule cannot be satisfied retroactively without editing
|
|
33
|
+
* them; a gate whose only output is unfixable blocks is the gate that gets
|
|
34
|
+
* switched off. Trigger to revisit: a review round where an unresolvable
|
|
35
|
+
* reference actually hid a disposition, on a record written AFTER this gate
|
|
36
|
+
* shipped and therefore fixable at source.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes (contract §6): 0 = pass, 1 = policy violation (including a DEAD
|
|
39
|
+
* SCAN SCOPE — a gate that read nothing has not passed), 2 = internal error
|
|
40
|
+
* (the CALLER applies degraded advisory mode). `scanned:` is emitted on EVERY
|
|
41
|
+
* exit path, exit 2 included.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as fs from 'node:fs';
|
|
45
|
+
import * as path from 'node:path';
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
|
+
|
|
48
|
+
import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
|
|
49
|
+
import { parseArtifact } from './check_completion_review.js';
|
|
50
|
+
|
|
51
|
+
const REVIEWS_ROOT = 'agents/evidence/reviews';
|
|
52
|
+
const TERMINAL = new Set(['fixed', 'accepted-risk', 'deferred']);
|
|
53
|
+
|
|
54
|
+
export interface Violation {
|
|
55
|
+
kind: string;
|
|
56
|
+
file: string;
|
|
57
|
+
detail: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An archived record: a `*-review.md` that is not a live `*.findings.md`. */
|
|
61
|
+
export function isArchivedRecord(rel: string): boolean {
|
|
62
|
+
const base = rel.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
63
|
+
return base.endsWith('-review.md') && !base.endsWith('.findings.md');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CheckOptions {
|
|
67
|
+
repo: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Contract §2.7: every row of an archived record is terminal, with its reference. */
|
|
71
|
+
export function checkRecord(rel: string, text: string, _opts: CheckOptions): Violation[] {
|
|
72
|
+
const violations: Violation[] = [];
|
|
73
|
+
const parsed = parseArtifact(text);
|
|
74
|
+
for (const row of parsed.rows) {
|
|
75
|
+
const where = `row ${row.index} (line ${String(row.line)})`;
|
|
76
|
+
if (row.status === 'open') {
|
|
77
|
+
violations.push({
|
|
78
|
+
kind: 'open-in-archived-record',
|
|
79
|
+
file: rel,
|
|
80
|
+
detail:
|
|
81
|
+
`${where}: status \`open\` in an ARCHIVED record. Renaming a round out of the ` +
|
|
82
|
+
'`*.findings.md` glob asserts it is closed, so every row must already be terminal ' +
|
|
83
|
+
'(`fixed` / `accepted-risk` / `deferred`) — contract §2.7.',
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!TERMINAL.has(row.status)) {
|
|
88
|
+
violations.push({
|
|
89
|
+
kind: 'bad-status',
|
|
90
|
+
file: rel,
|
|
91
|
+
detail: `${where}: unknown status \`${row.status}\` — expected fixed / accepted-risk / deferred.`,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (row.reasonRef.trim() === '') {
|
|
96
|
+
violations.push({
|
|
97
|
+
kind: 'missing-reference',
|
|
98
|
+
file: rel,
|
|
99
|
+
detail: `${where}: status \`${row.status}\` requires a Reason/Ref, which is empty.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return violations;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function main(argv?: readonly string[]): number {
|
|
107
|
+
const args = argv ?? process.argv.slice(2);
|
|
108
|
+
let repo = process.cwd();
|
|
109
|
+
let root = REVIEWS_ROOT;
|
|
110
|
+
let quiet = false;
|
|
111
|
+
for (let i = 0; i < args.length; i++) {
|
|
112
|
+
const a = args[i] as string;
|
|
113
|
+
if (a === '--repo') repo = args[++i] as string;
|
|
114
|
+
else if (a === '--reviews-root') root = args[++i] as string;
|
|
115
|
+
else if (a === '--quiet') quiet = true;
|
|
116
|
+
else if (a === '-h' || a === '--help') {
|
|
117
|
+
process.stdout.write('usage: check_review_dispositions [--repo PATH] [--reviews-root PATH] [--quiet]\n');
|
|
118
|
+
process.stdout.write('scanned: 0\n');
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const abs = path.resolve(repo, root);
|
|
124
|
+
let files: string[] = [];
|
|
125
|
+
try {
|
|
126
|
+
files = fs
|
|
127
|
+
.readdirSync(abs, { withFileTypes: true })
|
|
128
|
+
.filter((e) => e.isFile() && isArchivedRecord(e.name))
|
|
129
|
+
.map((e) => path.join(root, e.name))
|
|
130
|
+
.sort();
|
|
131
|
+
} catch {
|
|
132
|
+
// Absent root: report it as a DEAD SCOPE below rather than a clean pass.
|
|
133
|
+
files = [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
assertScanned({
|
|
138
|
+
gate: 'check_review_dispositions',
|
|
139
|
+
scanned: files.length,
|
|
140
|
+
units: 'archived review record(s)',
|
|
141
|
+
roots: [root],
|
|
142
|
+
});
|
|
143
|
+
} catch (exc) {
|
|
144
|
+
// A dead scan scope is a POLICY violation (exit 1), never an internal
|
|
145
|
+
// error: exit 2 is warn-and-allow at every call site, so a moved reviews
|
|
146
|
+
// root would silently degrade this gate to advisory.
|
|
147
|
+
process.stdout.write('scanned: 0\n');
|
|
148
|
+
process.stderr.write(`❌ ${exc instanceof DeadScopeError ? exc.message : String(exc)}\n`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const violations: Violation[] = [];
|
|
153
|
+
let scanned = 0;
|
|
154
|
+
try {
|
|
155
|
+
for (const rel of files) {
|
|
156
|
+
const text = fs.readFileSync(path.resolve(repo, rel), 'utf-8');
|
|
157
|
+
scanned += 1;
|
|
158
|
+
violations.push(...checkRecord(rel, text, { repo }));
|
|
159
|
+
}
|
|
160
|
+
} catch (exc) {
|
|
161
|
+
process.stdout.write(`scanned: ${String(scanned)}\n`);
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`❌ check_review_dispositions: internal error after ${String(scanned)} record(s): ` +
|
|
164
|
+
`${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
165
|
+
);
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (violations.length > 0) {
|
|
170
|
+
process.stdout.write(`❌ ${String(violations.length)} disposition violation(s):\n\n`);
|
|
171
|
+
for (const v of violations) {
|
|
172
|
+
process.stdout.write(` ${v.kind} — ${v.file}\n │ ${v.detail}\n`);
|
|
173
|
+
}
|
|
174
|
+
} else if (!quiet) {
|
|
175
|
+
process.stdout.write(`✅ Archived review records all terminal (${String(scanned)} record(s) scanned).\n`);
|
|
176
|
+
}
|
|
177
|
+
process.stdout.write(`scanned: ${String(scanned)}\n`);
|
|
178
|
+
return violations.length > 0 ? 1 : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
182
|
+
function _isCliEntry(): boolean {
|
|
183
|
+
if (process.argv[1] === undefined) return false;
|
|
184
|
+
return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href || process.argv[1] === _HERE;
|
|
185
|
+
}
|
|
186
|
+
if (_isCliEntry()) {
|
|
187
|
+
process.exit(main());
|
|
188
|
+
}
|
|
@@ -535,6 +535,9 @@ function reviewerPrompt(args: {
|
|
|
535
535
|
'- Severity ∈ {`critical`, `high`, `medium`, `low`}, rows sorted descending',
|
|
536
536
|
' by severity (ties keep authoring order).',
|
|
537
537
|
'- Initial status of every finding: `open`.',
|
|
538
|
+
'- A row is LIVE wherever it appears — a code fence around it changes',
|
|
539
|
+
' nothing. If you quote the template as an illustration, its Status cell',
|
|
540
|
+
' must be exactly `example`, or the gate reads it as a real finding.',
|
|
538
541
|
'- 0 findings → replace the table with exactly this honest-null line',
|
|
539
542
|
' (contract §2.3):',
|
|
540
543
|
'',
|
|
@@ -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
|
-
|
|
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 => !
|
|
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 (!
|
|
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 =
|
|
615
|
+
const abs = resolveRoot(repoRoot, rel);
|
|
582
616
|
let st: fs.Stats;
|
|
583
617
|
try {
|
|
584
618
|
st = fs.statSync(abs);
|