@event4u/agent-config 14.5.0 → 14.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +384 -3
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/decision.md +7 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  6. package/dist/agent-src/commands/analyze.md +1 -1
  7. package/dist/agent-src/commands/optimize/project.md +10 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +132 -4
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  10. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
  11. package/dist/agent-src/rules/context-hygiene.md +2 -0
  12. package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
  13. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  14. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  15. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  16. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  17. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  18. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  19. package/dist/agent-src/rules/skill-quality.md +0 -2
  20. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  21. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  22. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  23. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  24. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  25. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  27. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  28. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  29. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  30. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  31. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  32. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  33. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  34. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  35. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  36. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  37. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  38. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  39. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  40. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  41. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  42. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  44. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  45. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  46. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  47. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  48. package/dist/agent-src/templates/roadmaps.md +45 -2
  49. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  50. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  51. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  52. package/dist/cli/registry.js +1 -1
  53. package/dist/cli/registry.js.map +1 -1
  54. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  55. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  56. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  57. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  58. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  59. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  60. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  61. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  62. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
  63. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  64. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  65. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  66. package/dist/cli-delegate/cmd_conformance.js +459 -709
  67. package/dist/cli-delegate/cmd_doctor.js +6 -6
  68. package/dist/cli-delegate/cmd_explain.js +1 -1
  69. package/dist/cli-delegate/cmd_export.js +7 -6
  70. package/dist/cli-delegate/cmd_handoff.js +6 -6
  71. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  72. package/dist/cli-delegate/cmd_preflight.js +2 -2
  73. package/dist/cli-delegate/cmd_prune.js +7 -6
  74. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  75. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  76. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  77. package/dist/cli-delegate/cmd_sync.js +7 -6
  78. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  79. package/dist/cli-delegate/cmd_update.js +1 -1
  80. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  81. package/dist/cli-delegate/cmd_validate.js +7 -6
  82. package/dist/discovery/deprecation-report.md +1 -1
  83. package/dist/discovery/discovery-manifest.json +72 -29
  84. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  85. package/dist/discovery/discovery-manifest.summary.md +4 -4
  86. package/dist/discovery/dormancy-report.md +1 -1
  87. package/dist/discovery/orphan-report.md +1 -1
  88. package/dist/discovery/packs.json +10 -8
  89. package/dist/discovery/trust-report.md +3 -3
  90. package/dist/discovery/workspaces.json +8 -6
  91. package/dist/hooks/dispatch.js +3127 -2022
  92. package/dist/install/claudePathsPlan.js +187 -0
  93. package/dist/install/claudePathsPlan.js.map +1 -0
  94. package/dist/install/hostLayerFingerprint.js +136 -0
  95. package/dist/install/hostLayerFingerprint.js.map +1 -0
  96. package/dist/install/install.mjs +553 -425
  97. package/dist/install/partitionEligibility.js +276 -0
  98. package/dist/install/partitionEligibility.js.map +1 -0
  99. package/dist/mcp/registry-manifest.json +3 -3
  100. package/dist/mcp/server.json +2 -2
  101. package/dist/mcp/server.mjs +1744 -1191
  102. package/dist/router.json +1 -1
  103. package/dist/scripts/_lib/agent_src.js +41 -0
  104. package/dist/scripts/_lib/agent_src.js.map +1 -1
  105. package/dist/scripts/_lib/install_layout.js +83 -0
  106. package/dist/scripts/_lib/install_layout.js.map +1 -0
  107. package/dist/scripts/_lib/installed_lock.js +444 -0
  108. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  109. package/dist/scripts/condense.js +13 -164
  110. package/dist/scripts/condense.js.map +1 -1
  111. package/dist/server/schemas/settings.js +4 -0
  112. package/dist/server/schemas/settings.js.map +1 -1
  113. package/docs/contracts/settings-classes.md +12 -6
  114. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  115. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  116. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  117. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  118. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  119. package/package.json +1 -1
  120. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  121. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  122. package/src/agent-src/scripts/gate_budget.ts +282 -0
  123. package/src/agent-src/scripts/gate_execute.ts +72 -31
  124. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  125. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  126. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  127. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  128. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  129. package/src/config/agent-settings.template.yml +27 -2
  130. package/src/config/ci-local-parity.yml +18 -0
  131. package/src/config/estate-count-budget.json +367 -3
  132. package/src/config/gate-violation-baselines.json +41 -41
  133. package/src/config/pack-size-budget.json +3 -2
  134. package/src/config/rule-activation-census.json +14 -6
  135. package/src/config/rule-stub-ceilings.json +19 -6
  136. package/src/install/claudePathsPlan.ts +196 -0
  137. package/src/install/hostLayerFingerprint.ts +145 -0
  138. package/src/install/partitionEligibility.ts +325 -0
  139. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  140. package/src/scripts/_dispatch.bash +6 -1
  141. package/src/scripts/_lib/ac_heading.ts +30 -0
  142. package/src/scripts/_lib/agent_src.ts +42 -0
  143. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  144. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  145. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  146. package/src/scripts/_lib/installed_lock.ts +50 -5
  147. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  148. package/src/scripts/_lib/md_section.ts +129 -0
  149. package/src/scripts/_lib/scope_guard.sh +86 -4
  150. package/src/scripts/_lib/self_repair.ts +19 -0
  151. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  152. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  153. package/src/scripts/adr/regenerate_index.ts +13 -2
  154. package/src/scripts/adr_cite_check.ts +424 -0
  155. package/src/scripts/ai_council/cli_help.ts +55 -0
  156. package/src/scripts/before_complete_hook.ts +153 -28
  157. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  158. package/src/scripts/check_adr_frontmatter.ts +187 -1
  159. package/src/scripts/check_references.ts +60 -0
  160. package/src/scripts/check_release_published.ts +32 -11
  161. package/src/scripts/check_single_delivery.ts +517 -0
  162. package/src/scripts/ci_settle.ts +170 -0
  163. package/src/scripts/condense.ts +20 -170
  164. package/src/scripts/council_cli.ts +3 -3
  165. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  166. package/src/scripts/hook_manifest.json +1 -1
  167. package/src/scripts/hook_manifest.yaml +77 -14
  168. package/src/scripts/hooks/block_no_verify.ts +61 -10
  169. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  170. package/src/scripts/hooks/concern_registry.ts +4 -0
  171. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  172. package/src/scripts/hooks/run_continuation_hook.ts +887 -68
  173. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  174. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  175. package/src/scripts/hooks/state_io.ts +782 -20
  176. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  178. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  179. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  181. package/src/scripts/install.sh +12 -2
  182. package/src/scripts/install.ts +16 -11
  183. package/src/scripts/language_mirror_hook.ts +561 -25
  184. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  185. package/src/scripts/lint_plan_risk_register.ts +4 -3
  186. package/src/scripts/lint_skill_router_head.ts +4 -4
  187. package/src/scripts/mcp_server/tools.ts +2 -2
  188. package/src/scripts/memory_consolidation.ts +189 -0
  189. package/src/scripts/memory_eviction.ts +384 -0
  190. package/src/scripts/new_roadmap.ts +136 -0
  191. package/src/scripts/pack_mcp_content.ts +20 -5
  192. package/src/scripts/release.ts +37 -38
  193. package/src/scripts/report_layer_overlap.ts +78 -0
  194. package/src/scripts/report_memory_pointers.ts +622 -0
  195. package/src/scripts/self_repair_hook.ts +37 -5
  196. package/src/scripts/session_register_hook.ts +115 -15
  197. package/src/scripts/skill_usage_collect.ts +11 -1
  198. package/src/scripts/skill_usage_report.ts +278 -8
  199. package/src/scripts/sync_pr_branch.ts +7 -0
  200. package/src/server/schemas/settings.ts +8 -0
  201. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  202. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  203. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -29,7 +29,14 @@
29
29
  * - post_tool_use → inspect tool + command, record verifications
30
30
  * - stop → record stop fired (claim-done window)
31
31
  *
32
- * Output: `agents/state/verify-before-complete.json`
32
+ * Output: `agents/state/verify-before-complete/<sha256(session_id)>.json`
33
+ * — ONE FILE PER SESSION. It was one file per project root until 2026-08-20,
34
+ * which under this repo's worktree workflow (`CLAUDE_PROJECT_DIR` resolves to
35
+ * the PARENT checkout) meant one file across every concurrent run: a
36
+ * neighbour's CI witness and verification counters became this run's, and the
37
+ * in-file session-boundary reset in `_update` turned into the damage rather
38
+ * than the defense, because two live runs then clear each other in a loop.
39
+ * Consumers address it via `statePathFor`, never a path literal.
33
40
  * {
34
41
  * "schema_version": 1,
35
42
  * "session_id": "<str>",
@@ -49,14 +56,79 @@ import fs from "node:fs";
49
56
  import path from "node:path";
50
57
  import { fileURLToPath, pathToFileURL } from "node:url";
51
58
 
52
- import { atomic_write_json } from "./hooks/state_io.js";
59
+ import {
60
+ has_stable_session_id,
61
+ prune_legacy_state_file,
62
+ prune_stale_session_states,
63
+ session_state_file,
64
+ update_json_under_lock,
65
+ } from "./hooks/state_io.js";
53
66
  import { readHookStdin } from "./hooks/hook_stdin.js";
54
67
 
55
68
  // NOTE: the Python docstring says `agents/runtime/state/`, but the code
56
69
  // constant is `agents/state/`. Replicated verbatim — latent docstring/code
57
70
  // divergence in the retired Python implementation (ADR-200 § replicate latent bugs).
71
+ //
72
+ // PRE-SPLIT PATH. Nothing reads it after the per-session split below;
73
+ // `prune_legacy_state_file` removes it once this version owns the tree. Kept
74
+ // exported because `prune_legacy_state_file` needs to name the path it deletes,
75
+ // and because a reader that still resolves it should get a compile-visible
76
+ // symbol rather than a silently stale path literal. It is NOT kept for an
77
+ // "older bundle still writing during an upgrade" — that phrasing was copied
78
+ // here unchecked and is corrected at `state_io.prune_legacy_state_file`, which
79
+ // carries the measured deployment shape and the one narrow window that remains.
58
80
  export const STATE_FILE = path.join("agents", "state", "verify-before-complete.json");
59
81
 
82
+ /**
83
+ * Per-SESSION state, one file each.
84
+ *
85
+ * WHY, in one sentence: the single file above is shared by every concurrent
86
+ * session under one project root — which in this repo's worktree workflow is
87
+ * the PARENT checkout — so a neighbouring run's CI witness and verification
88
+ * counters became this run's.
89
+ *
90
+ * The in-file session-boundary reset in `_update` is NOT the fix and is the
91
+ * reason this was easy to miss. It is written for SEQUENTIAL sessions: notice a
92
+ * foreign `session_id`, clear the session-scoped counters, carry on. Under
93
+ * CONCURRENT sessions that same code is the damage — each run reads the other's
94
+ * id, resets, and writes, so two sessions erase each other's verification
95
+ * evidence in a loop. The direction of the loss is toward FORGETTING a
96
+ * verification that did happen, which then reads as "not verified this turn".
97
+ *
98
+ * The reset stays, but NOT for the reasons this comment first gave. It said the
99
+ * reset "still covers the id-less bucket and a legacy file", and a cross-model
100
+ * review (2026-08-20, both seats) showed neither is reachable: an id-less
101
+ * envelope returns from `run()` before `_update` is ever called, and the legacy
102
+ * file is never LOADED any more — only deleted. Writing an unreachable
103
+ * justification next to retained code is how dead logic survives review, so the
104
+ * real one is stated instead.
105
+ *
106
+ * Its one reachable case is a file at THIS session's digest path carrying
107
+ * somebody else's `session_id` — a copy, a restore, a hand-edit, or a buggy
108
+ * writer. That is integrity recovery on the producer side, and it pairs with the
109
+ * consumer side refusing the same file outright (`owns_session_state`). Both
110
+ * halves are needed: the producer cannot refuse to run, and the consumer cannot
111
+ * repair. `before_complete_session_isolation.test.ts` reaches it directly.
112
+ *
113
+ * Rationale, the digest-not-sanitiser property, and the claim-then-revalidate
114
+ * prune all live once in `hooks/state_io.ts` § Per-session concern state — this
115
+ * is the second concern to need them, which is what made sharing them right.
116
+ */
117
+ export const STATE_DIR = path.join("agents", "state", "verify-before-complete");
118
+
119
+ /**
120
+ * Days after which an untouched session's state is pruned.
121
+ *
122
+ * Matches the language hook and the council session-artefact window — a
123
+ * convention match, not a measurement, and stated as such.
124
+ */
125
+ export const STATE_RETENTION_DAYS = 7;
126
+
127
+ /** Path of one session's state file. */
128
+ export function statePathFor(session_id: string): string {
129
+ return session_state_file(STATE_DIR, session_id);
130
+ }
131
+
60
132
  // Tool names across platforms whose `command` / `tool_input.command` field
61
133
  // carries a shell command we can inspect. Edit tools are deliberately
62
134
  // excluded — they cannot run verification.
@@ -125,26 +197,6 @@ function _empty_state(): StateDict {
125
197
  };
126
198
  }
127
199
 
128
- function _load_state(target: string): StateDict {
129
- let isFile = false;
130
- try {
131
- isFile = fs.statSync(target).isFile();
132
- } catch {
133
- isFile = false;
134
- }
135
- if (!isFile) {
136
- return _empty_state();
137
- }
138
- try {
139
- const decoded = JSON.parse(fs.readFileSync(target, "utf-8")) as unknown;
140
- if (typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)) {
141
- return { ..._empty_state(), ...(decoded as StateDict) };
142
- }
143
- } catch {
144
- // fall through
145
- }
146
- return _empty_state();
147
- }
148
200
 
149
201
  /** Return [tool_name, command_text] from a tool-event payload. */
150
202
  function _extract_command(payload: StateDict): [string | null, string | null] {
@@ -424,19 +476,92 @@ export function run(
424
476
  }
425
477
 
426
478
  const event = (envelope["event"] || "") as string;
427
- const target = path.join(consumer_root, STATE_FILE);
428
- let state = _load_state(target);
429
- state = _update(state, event, envelope);
479
+ const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
480
+
481
+ // No stable identity → persist NOTHING. Sanitising an empty id into a shared
482
+ // literal is the original cross-session defect restored in the one case with
483
+ // no guard left (see `state_io` § has_stable_session_id).
484
+ //
485
+ // What that costs, and it is worse than the word this comment first used: on a
486
+ // host that sends no `session_id` this concern records no evidence at all, so
487
+ // `readCiSettled` returns "nothing observed" and the turn-end gate's completion
488
+ // detector never fires. The first version called that "the SAFE direction —
489
+ // under-refusing". A cross-model review (2026-08-20, both seats) rejected the
490
+ // word: for a BLOCKING gate, not refusing is fail-OPEN. A premature completion
491
+ // claim over unsettled CI passes unchallenged. This is DEGRADED ENFORCEMENT, and
492
+ // naming it "safe" hides the loss behind a reassuring adjective.
493
+ //
494
+ // It is still the right call among the options available, which is a different
495
+ // claim and the only one the evidence supports: the alternative is one shared
496
+ // file whose contents belong to whichever concurrent run wrote last, i.e. a
497
+ // gate that refuses or clears on somebody else's evidence. A gate that goes
498
+ // quiet is recoverable; a gate that acts on a foreign witness is not.
499
+ // Every host this suite binds `post_tool_use` on sends a `session_id`
500
+ // (`hook_manifest.yaml` platforms × `native_event_aliases`), so the degraded
501
+ // path is a fallback rather than a supported mode — but it is not verified per
502
+ // host, and this comment does not claim it is.
503
+ if (!has_stable_session_id(session_id)) {
504
+ if (verbose) {
505
+ process.stderr.write(
506
+ "verify-before-complete-hook: no session_id — running stateless, nothing recorded\n",
507
+ );
508
+ }
509
+ return 0;
510
+ }
430
511
 
431
- try {
432
- atomic_write_json(target, state);
433
- } catch {
512
+ const target = path.join(consumer_root, statePathFor(session_id));
513
+
514
+ // LOAD → UPDATE → PUBLISH under ONE lock, not three separate steps.
515
+ //
516
+ // This was `_load_state` / `_update` / `atomic_write_json`, which makes the
517
+ // publish atomic and leaves the transaction racy. A cross-model review
518
+ // (2026-08-20, both seats) named the interleaving, and this host runs tool
519
+ // calls in parallel, so it is reachable rather than theoretical: two
520
+ // `post_tool_use` invocations for the SAME session both load the counter at
521
+ // N, both compute N+1, both publish N+1, and one verification is lost. The
522
+ // per-session split does nothing about it — the two failures are independent,
523
+ // and closing the cross-session one made this one easier to mistake for
524
+ // solved.
525
+ //
526
+ // `state` is captured for the verbose line below; the value that lands is the
527
+ // one computed inside the lock, from state read inside the lock.
528
+ let state: StateDict = {};
529
+ // Three-state result (state_io § Three states, not two). This mutator NEVER
530
+ // returns null, so `skipped` is unreachable here — but it is handled with
531
+ // `written` rather than lumped with `failed`, because the two have opposite
532
+ // meanings for this caller: a decline means the state needed no change, a
533
+ // failure means the state on disk is not what this run computed. Writing
534
+ // `!== "written"` would have made a future mutator that learns to decline
535
+ // silently stop recording, which is the failure this API change exists to
536
+ // make impossible to write by accident.
537
+ const outcome = update_json_under_lock<StateDict>(target, (loaded) => {
538
+ // `_empty_state()` UNDER the loaded value, which is what the `_load_state`
539
+ // this replaced did (`{ ..._empty_state(), ...decoded }`). Dropping it would
540
+ // have been a silent regression that the suite could not see: `_asInt`
541
+ // treats a missing counter as 0 and the `=== true` guards treat a missing
542
+ // flag as false, so every assertion would still pass while `schema_version`
543
+ // and an explicit `ci_last: null` vanished from a freshly created file.
544
+ state = _update({ ..._empty_state(), ...loaded } as StateDict, event, envelope);
545
+ return state;
546
+ });
547
+ if (outcome === "failed") {
434
548
  if (verbose) {
435
549
  process.stderr.write("verify-before-complete-hook: state write failed\n");
436
550
  }
437
551
  return 0;
438
552
  }
439
553
 
554
+ // Housekeeping on the once-per-turn events only — never on `post_tool_use`,
555
+ // which fires many times per turn, and never before the write above.
556
+ if (event === "session_start" || event === "user_prompt_submit") {
557
+ prune_legacy_state_file(path.join(consumer_root, STATE_FILE));
558
+ prune_stale_session_states(
559
+ path.join(consumer_root, STATE_DIR),
560
+ Date.now(),
561
+ STATE_RETENTION_DAYS,
562
+ );
563
+ }
564
+
440
565
  if (verbose) {
441
566
  process.stderr.write(
442
567
  `verify-before-complete-hook: event=${event} ` +
@@ -35,6 +35,7 @@ import { fileURLToPath } from 'node:url';
35
35
 
36
36
  import { parse as parseYaml } from 'yaml';
37
37
 
38
+ import { PinnedRepoError, pristine_tree_for } from './_lib/bench_ab_pinned_repo.js';
38
39
  import { medianComplexityPerChangedFunction } from './_lib/bench_ab_complexity.js';
39
40
  import { changed_files, diff_line_counts } from './_lib/bench_ab_scoring_v2.js';
40
41
 
@@ -63,15 +64,15 @@ function _dictOr(v: unknown): Dict {
63
64
  * trial to `skipped_reason: 'task id not in the corpus'`, which is a reportable
64
65
  * state, rather than aborting a re-score that may still cover most of the report.
65
66
  */
66
- function loadCorpusFixtures(corpusPath: string | null): Map<string, string> {
67
- const byId = new Map<string, string>();
67
+ function loadCorpusFixtures(corpusPath: string | null): Map<string, Dict> {
68
+ const byId = new Map<string, Dict>();
68
69
  if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
69
70
  try {
70
71
  const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
71
72
  const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
72
73
  for (const t of tasks) {
73
- if (t['id'] !== undefined && t['fixture'] !== undefined) {
74
- byId.set(String(t['id']), String(t['fixture']));
74
+ if (t['id'] !== undefined && (t['fixture'] !== undefined || t['repo'] !== undefined)) {
75
+ byId.set(String(t['id']), t);
75
76
  }
76
77
  }
77
78
  } catch {
@@ -109,7 +110,7 @@ export async function rescoreReport(
109
110
 
110
111
  for (const rec of records) {
111
112
  const taskId = String(rec['id'] ?? '<unknown>');
112
- const fixtureRel = byId.get(taskId) ?? null;
113
+ const corpusTask = byId.get(taskId) ?? null;
113
114
  const arms = _dictOr(rec['arms']);
114
115
  for (const [arm, runsRaw] of Object.entries(arms)) {
115
116
  const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
@@ -134,12 +135,31 @@ export async function rescoreReport(
134
135
  out.push(row);
135
136
  continue;
136
137
  }
137
- if (fixtureRel === null) {
138
+ if (corpusTask === null) {
138
139
  row.skipped_reason = 'task id not in the corpus';
139
140
  out.push(row);
140
141
  continue;
141
142
  }
142
- const fixture = path.join(fixturesRoot, fixtureRel);
143
+ // Delta #9: a pinned task's pristine tree is a local cache entry, and
144
+ // this re-scorer is `offlineOnly` on purpose — a cold cache reports
145
+ // "not materialised" rather than fetching a repository during what is
146
+ // meant to be an offline re-score of a finished sweep. A malformed pin
147
+ // is reported per trial, never thrown: one bad corpus entry must not
148
+ // abort a re-score that still covers every other trial.
149
+ let fixture: string | null;
150
+ try {
151
+ fixture = pristine_tree_for(corpusTask, { fixturesRoot, offlineOnly: true });
152
+ } catch (err) {
153
+ row.skipped_reason =
154
+ err instanceof PinnedRepoError ? `invalid pin: ${err.message}` : 'invalid corpus task';
155
+ out.push(row);
156
+ continue;
157
+ }
158
+ if (fixture === null) {
159
+ row.skipped_reason = 'pinned repo not materialised locally';
160
+ out.push(row);
161
+ continue;
162
+ }
143
163
  if (!fs.existsSync(fixture)) {
144
164
  row.skipped_reason = 'fixture missing on disk';
145
165
  out.push(row);
@@ -51,6 +51,92 @@ export const REVIEW_TRIGGER_SINCE = '2026-07-25';
51
51
  /** Fields every ADR must carry — the shape that already exists in practice. */
52
52
  const REQUIRED = ['adr', 'status', 'date', 'decision'] as const;
53
53
 
54
+ /**
55
+ * Reopen-authority vocabulary (`adr-layout.md` § Reopen authority).
56
+ *
57
+ * Both fields are OPTIONAL and stay optional. An absent `reopen_policy`
58
+ * resolves to `unclassified` at the reader, never to `owner`: with 146 accepted
59
+ * ADRs a fail-closed default would encode the existing blockage into the new
60
+ * schema, which is the one thing both council seats (2026-08-19) rejected in
61
+ * the same words. This validator therefore checks the VALUE when present and
62
+ * never requires the KEY.
63
+ */
64
+ const ALLOWED_REOPEN_POLICY = new Set(['directional', 'owner', 'unclassified']);
65
+
66
+ const ALLOWED_PROTECTED_DIMENSIONS = new Set([
67
+ 'purpose',
68
+ 'security_floor',
69
+ 'privacy_floor',
70
+ 'external_commitment',
71
+ 'governance',
72
+ 'none',
73
+ ]);
74
+
75
+ /** Split an inline `[a, b]` list or a folded multi-value string into members. */
76
+ export function split_dimensions(raw: string): string[] {
77
+ return raw
78
+ .replace(/^\[/, '')
79
+ .replace(/\]$/, '')
80
+ .split(/[,\s]+/)
81
+ .map((s) =>
82
+ s
83
+ .trim()
84
+ .replace(/^-\s*/, '')
85
+ .replace(/^["'](.*)["']$/, '$1'),
86
+ )
87
+ .filter((s) => s !== '');
88
+ }
89
+
90
+ /**
91
+ * Validate the two optional reopen-authority fields — value only, never
92
+ * presence. A wrong enum is a real error (it silently mis-routes authority);
93
+ * an absent field is the documented default and not a finding.
94
+ */
95
+ export function check_reopen_authority(
96
+ rel: string,
97
+ fm: Record<string, string>,
98
+ findings: AdrFinding[],
99
+ ): void {
100
+ const policy = fm['reopen_policy'];
101
+ if (policy !== undefined && policy !== '' && !ALLOWED_REOPEN_POLICY.has(policy)) {
102
+ findings.push({
103
+ file: rel,
104
+ level: 'error',
105
+ message:
106
+ `\`reopen_policy\` \`${policy}\` is not one of: ` +
107
+ `${[...ALLOWED_REOPEN_POLICY].sort().join(', ')} (absent = unclassified)`,
108
+ });
109
+ }
110
+
111
+ const dims = fm['protected_dimensions'];
112
+ if (dims !== undefined && dims !== '') {
113
+ for (const d of split_dimensions(dims)) {
114
+ if (!ALLOWED_PROTECTED_DIMENSIONS.has(d)) {
115
+ findings.push({
116
+ file: rel,
117
+ level: 'error',
118
+ message:
119
+ `\`protected_dimensions\` member \`${d}\` is not one of: ` +
120
+ `${[...ALLOWED_PROTECTED_DIMENSIONS].sort().join(', ')}`,
121
+ });
122
+ }
123
+ }
124
+ }
125
+
126
+ // `owner` without a named dimension is the frozen-over-classification shape
127
+ // the contract warns about: it reserves every future transition, including
128
+ // the ones that only strengthen the decision, and records no reason.
129
+ if (policy === 'owner' && (dims === undefined || dims === '')) {
130
+ findings.push({
131
+ file: rel,
132
+ level: 'warn',
133
+ message:
134
+ '`reopen_policy: owner` without `protected_dimensions` — name the reserved ' +
135
+ 'interest, or the reservation cannot be checked against a proposed transition',
136
+ });
137
+ }
138
+ }
139
+
54
140
  const ALLOWED_STATUS = new Set([
55
141
  'accepted',
56
142
  'proposed',
@@ -141,6 +227,98 @@ export function check_one(rel: string, text: string): AdrFinding[] {
141
227
  message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
142
228
  });
143
229
  }
230
+ check_reopen_authority(rel, fm, findings);
231
+ check_amendment_shape(rel, fm, findings);
232
+ return findings;
233
+ }
234
+
235
+ /** `ADR-035`, `035`, `35` → `35`. Returns null when nothing resolves. */
236
+ export function adr_number(raw: string): string | null {
237
+ const m = /(\d{1,4})/.exec(raw.trim());
238
+ return m?.[1] === undefined ? null : String(Number(m[1]));
239
+ }
240
+
241
+ /**
242
+ * Validate `amends:` / `amended_by:` shape. The reciprocal half is checked in
243
+ * `check_amendment_links` (it needs the whole corpus); this one only rejects a
244
+ * value that is neither the em-dash placeholder nor an ADR reference, so a
245
+ * typo'd link is caught before it becomes an invisible one-sided amendment.
246
+ */
247
+ export function check_amendment_shape(
248
+ rel: string,
249
+ fm: Record<string, string>,
250
+ findings: AdrFinding[],
251
+ ): void {
252
+ for (const key of ['amends', 'amended_by'] as const) {
253
+ const v = fm[key];
254
+ if (v === undefined || v === '' || v === '—') continue;
255
+ for (const part of v.split(',')) {
256
+ if (adr_number(part) === null) {
257
+ findings.push({
258
+ file: rel,
259
+ level: 'error',
260
+ message: `\`${key}\` value \`${part.trim()}\` names no ADR number`,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Corpus-level check: every `amends:` has its reciprocal `amended_by:` and vice
269
+ * versa. This is the half the `supersedes:` / `superseded_by:` pair never had —
270
+ * and its absence is exactly how ADR-035 kept asserting a rejection ADR-232 had
271
+ * reopened. A one-sided link is invisible from the side that is stale, which is
272
+ * the side a reader lands on first.
273
+ */
274
+ export function check_amendment_links(
275
+ files: { rel: string; fm: Record<string, string> }[],
276
+ ): AdrFinding[] {
277
+ const findings: AdrFinding[] = [];
278
+ const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
279
+ for (const f of files) {
280
+ const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
281
+ if (n !== null) byNumber.set(n, f);
282
+ }
283
+
284
+ const reciprocal = { amends: 'amended_by', amended_by: 'amends' } as const;
285
+ for (const f of files) {
286
+ for (const key of ['amends', 'amended_by'] as const) {
287
+ const v = f.fm[key];
288
+ if (v === undefined || v === '' || v === '—') continue;
289
+ const self = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
290
+ for (const part of v.split(',')) {
291
+ const target = adr_number(part);
292
+ if (target === null) continue;
293
+ const other = byNumber.get(target);
294
+ if (other === undefined) {
295
+ findings.push({
296
+ file: f.rel,
297
+ level: 'error',
298
+ message: `\`${key}: ${part.trim()}\` points at an ADR that does not exist`,
299
+ });
300
+ continue;
301
+ }
302
+ const back = other.fm[reciprocal[key]] ?? '';
303
+ const names_self =
304
+ self !== null &&
305
+ back
306
+ .split(',')
307
+ .map((s) => adr_number(s))
308
+ .includes(self);
309
+ if (!names_self) {
310
+ findings.push({
311
+ file: f.rel,
312
+ level: 'error',
313
+ message:
314
+ `\`${key}: ${part.trim()}\` is one-sided — ${other.rel} carries no ` +
315
+ `reciprocal \`${reciprocal[key]}\`. A one-sided amendment link is ` +
316
+ `invisible from the stale side.`,
317
+ });
318
+ }
319
+ }
320
+ }
321
+ }
144
322
  return findings;
145
323
  }
146
324
 
@@ -163,13 +341,20 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
163
341
  const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
164
342
  ledger?.plan(candidates);
165
343
 
344
+ // Collected for the corpus-level reciprocal-amendment check below, which
345
+ // cannot be decided from one file.
346
+ const corpus: { rel: string; fm: Record<string, string> }[] = [];
347
+
166
348
  for (const name of candidates) {
167
349
  if (!/^ADR-.*\.md$/.test(name)) {
168
350
  ledger?.outOfScope(name, 'not_applicable_kind');
169
351
  continue;
170
352
  }
171
353
  const rel = path.relative(REPO_ROOT, path.join(dir, name));
172
- const own = check_one(rel, fs.readFileSync(path.join(dir, name), 'utf-8'));
354
+ const text = fs.readFileSync(path.join(dir, name), 'utf-8');
355
+ const parsed = parse_frontmatter(text);
356
+ if (parsed !== null) corpus.push({ rel, fm: parsed });
357
+ const own = check_one(rel, text);
173
358
  findings.push(...own);
174
359
 
175
360
  const errors = own.filter((f) => f.level === 'error').length;
@@ -179,6 +364,7 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
179
364
  ledger?.complete(name);
180
365
  }
181
366
  }
367
+ findings.push(...check_amendment_links(corpus));
182
368
  return findings;
183
369
  }
184
370
 
@@ -473,6 +473,57 @@ function _extract_personas_frontmatter(text: string): Array<[number, string]> {
473
473
  return results;
474
474
  }
475
475
 
476
+ /**
477
+ * Paths this checker must not call broken because nothing is allowed to fix them.
478
+ *
479
+ * `archive_completed_roadmaps` treats `agents/evidence/**` and every `*.patch` as
480
+ * a FROZEN RECORD and deliberately does not rewrite path strings there — a review
481
+ * artefact is a record of what a reviewer was shown, and editing it retroactively
482
+ * makes it claim something that did not happen (measured 2026-08-11: one archival
483
+ * rewrote four frozen artefacts including a `diff.patch` header, and reported it
484
+ * as "4 ref(s) migrated"). So when a roadmap is archived, a record naming its old
485
+ * path keeps naming it — correctly.
486
+ *
487
+ * Without this carve-out the two gates contradict each other: the sweep forbids
488
+ * the rewrite and this checker reds the un-rewritten path, so EVERY archival of a
489
+ * roadmap a review artefact mentions turns CI red with no compliant fix available.
490
+ * Measured on the change that made archival automatic: 2 such references.
491
+ *
492
+ * Deliberately narrow — three conditions, all required. The source is a frozen
493
+ * record; the reference is a top-level roadmap path; and the file is still
494
+ * findable under a terminal disposition. A frozen record pointing at a roadmap
495
+ * that exists NOWHERE is still broken and still reds, which is the part a blanket
496
+ * `agents/evidence/reviews` exclusion would have lost: that directory holds live
497
+ * `*.findings.md` work alongside archived records, so excluding it wholesale would
498
+ * stop checking documents that are still being acted on.
499
+ *
500
+ * The frozen-record predicate is duplicated from `_is_frozen_record` in
501
+ * `src/agent-src/scripts/archive_completed_roadmaps.ts` rather than imported: that
502
+ * module is projected into consumer installs and pulls the dashboard generator in
503
+ * with it, so a package-internal gate importing it would drag the projection layer
504
+ * into CI's gate path. `tests/scripts/check_references.test.ts` holds the two
505
+ * copies against one table so the duplication cannot drift silently.
506
+ */
507
+ const _FROZEN_RECORD_PREFIXES = ['agents/evidence/'];
508
+ const _TERMINAL_ROADMAP_DIRS = ['archive', 'skipped', 'later'];
509
+
510
+ export function _is_frozen_record_source(rel: string): boolean {
511
+ const norm = rel.split(path.sep).join('/');
512
+ return _FROZEN_RECORD_PREFIXES.some((p) => norm.startsWith(p)) || norm.endsWith('.patch');
513
+ }
514
+
515
+ /** Does `ref` name a top-level roadmap that now lives under a terminal disposition? */
516
+ export function _resolves_under_terminal_disposition(ref: string, root: string): boolean {
517
+ const norm = ref.split(path.sep).join('/');
518
+ const m = /^agents\/roadmaps\/([^/]+\.md)$/.exec(norm);
519
+ if (m === null) {
520
+ return false;
521
+ }
522
+ return _TERMINAL_ROADMAP_DIRS.some((d) =>
523
+ _exists(path.join(root, 'agents', 'roadmaps', d, m[1] as string)),
524
+ );
525
+ }
526
+
476
527
  function _find_suggestion(p: string, root: string): string {
477
528
  const name = path.basename(p);
478
529
  // DEAD-ROOT REPAIR (road-to-renewal-foundation Phase 1): the middle entry
@@ -664,6 +715,15 @@ function check_file(
664
715
  // check above already swallows refs into `agents/runtime/`,
665
716
  // so no extra carve-out is needed.
666
717
  }
718
+ if (
719
+ !resolved &&
720
+ _is_frozen_record_source(_relPosix(root, filepath)) &&
721
+ _resolves_under_terminal_disposition(rawRef, root)
722
+ ) {
723
+ // A record naming the pre-archival path of a roadmap that is
724
+ // still findable. Nothing may rewrite it — see above.
725
+ resolved = true;
726
+ }
667
727
  if (!resolved) {
668
728
  broken.push(
669
729
  makeBrokenRef(
@@ -13,8 +13,9 @@
13
13
  * matching git tag, and npm's `latest` therefore lags behind main.
14
14
  *
15
15
  * Two independent invariants:
16
- * 1. Tag invariant (always checkable, no network): the version in
17
- * `package.json` MUST have a matching git tag (local or remote).
16
+ * 1. Tag invariant: the version in `package.json` MUST have a matching git
17
+ * tag ON THE REMOTE. A local-only tag published nothing — `publish-npm.yml`
18
+ * triggers on `push: tags:` — so the probe asks `origin` (one `ls-remote`).
18
19
  * 2. npm invariant (`--check-npm`, network): `npm view <pkg>
19
20
  * dist-tags.latest` MUST equal the `package.json` version.
20
21
  *
@@ -51,13 +52,32 @@ function _package_name(): string {
51
52
  return String(data['name']);
52
53
  }
53
54
 
54
- function _tag_exists(tag: string): boolean {
55
- const [rc, out] = _git('tag', '-l', tag);
56
- if (rc === 0 && out.split('\n').includes(tag)) {
57
- return true;
58
- }
59
- const [rc2] = _git('ls-remote', '--exit-code', '--tags', 'origin', tag);
60
- return rc2 === 0;
55
+ /**
56
+ * True when the tag is PUBLISHED — present on `origin`, never merely local.
57
+ *
58
+ * This probe used to answer local-OR-remote, and that made the gate reply
59
+ * "tagged, therefore published" to the exact state it exists to detect. A tag
60
+ * created by `release.ts` step 8 whose push then failed sits in the local
61
+ * repository while nothing has shipped: `publish-npm.yml` triggers on
62
+ * `push: tags:`, so a tag that never reached the remote published nothing.
63
+ * Measured 2026-08-20 on 14.6.0 — main carried `package.json` 14.6.0, the tag
64
+ * existed only in the maintainer's checkout, and npm still served 14.5.0.
65
+ *
66
+ * In CI the old form happened to be harmless: `actions/checkout` brings no
67
+ * unpushed local tag, so the remote arm decided. The hole was LOCAL — a
68
+ * maintainer running `task check-release-published` (or `task preflight`) on
69
+ * the machine that minted the tag got a green for an unpublished release, on
70
+ * the one machine where the stuck state is visible. The sibling defect in
71
+ * `release.ts::_detect_in_flight_target` is the same construct.
72
+ *
73
+ * @param git Seam for the probe. Production passes nothing and gets the real
74
+ * `_git`; tests inject a stub, because the alternative — reaching the branches
75
+ * only by creating and deleting tags in the checkout — is why neither arm of
76
+ * this function had a test while both were wrong.
77
+ */
78
+ function _tag_exists(tag: string, git: (...args: string[]) => [number, string] = _git): boolean {
79
+ const [rc] = git('ls-remote', '--exit-code', '--tags', 'origin', tag);
80
+ return rc === 0;
61
81
  }
62
82
 
63
83
  function _on_main(): boolean {
@@ -179,8 +199,9 @@ function main(argv: readonly string[] = [], hooks: Hooks = _hooks): number {
179
199
  if (!hooks._tag_exists(version)) {
180
200
  problems.push(
181
201
  `package.json is ${version} but no git tag ${version} exists ` +
182
- `(local or origin) — the release was bumped/merged but never ` +
183
- `tagged. Complete it: tag the release-merge commit and push ` +
202
+ `on origin — the release was bumped/merged but never tagged, ` +
203
+ `or the tag push never landed. Complete it: tag the ` +
204
+ `release-merge commit and push ` +
184
205
  `(triggers publish-npm.yml), e.g. \`git tag ${version} && git ` +
185
206
  `push origin ${version}\`.`,
186
207
  );