@event4u/agent-config 14.6.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 (188) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +260 -7
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/roadmap/process-full.md +116 -30
  7. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +135 -104
  9. package/dist/agent-src/rules/context-hygiene.md +2 -0
  10. package/dist/agent-src/rules/decision-revisit-gate.md +3 -0
  11. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  12. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  13. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  14. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  15. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  16. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  17. package/dist/agent-src/rules/skill-quality.md +0 -2
  18. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  19. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  20. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  21. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  22. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  23. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  24. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  25. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  26. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  27. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  28. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  29. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  30. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  31. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  32. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  33. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  34. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  35. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  36. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  37. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  38. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  39. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  40. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  41. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  42. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  43. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  44. package/dist/agent-src/templates/roadmaps.md +45 -2
  45. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  46. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  47. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  48. package/dist/cli/registry.js +1 -1
  49. package/dist/cli/registry.js.map +1 -1
  50. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  51. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  52. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  53. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  54. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  55. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  56. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  57. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  58. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-JZ4NR2TW.js} +338 -0
  59. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  60. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  61. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  62. package/dist/cli-delegate/cmd_conformance.js +459 -709
  63. package/dist/cli-delegate/cmd_doctor.js +6 -6
  64. package/dist/cli-delegate/cmd_explain.js +1 -1
  65. package/dist/cli-delegate/cmd_export.js +7 -6
  66. package/dist/cli-delegate/cmd_handoff.js +6 -6
  67. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  68. package/dist/cli-delegate/cmd_preflight.js +2 -2
  69. package/dist/cli-delegate/cmd_prune.js +7 -6
  70. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  71. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  72. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  73. package/dist/cli-delegate/cmd_sync.js +7 -6
  74. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  75. package/dist/cli-delegate/cmd_update.js +1 -1
  76. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  77. package/dist/cli-delegate/cmd_validate.js +7 -6
  78. package/dist/discovery/deprecation-report.md +1 -1
  79. package/dist/discovery/discovery-manifest.json +68 -25
  80. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  81. package/dist/discovery/discovery-manifest.summary.md +4 -4
  82. package/dist/discovery/dormancy-report.md +1 -1
  83. package/dist/discovery/orphan-report.md +1 -1
  84. package/dist/discovery/packs.json +10 -8
  85. package/dist/discovery/trust-report.md +3 -3
  86. package/dist/discovery/workspaces.json +8 -6
  87. package/dist/hooks/dispatch.js +2901 -2053
  88. package/dist/install/claudePathsPlan.js +187 -0
  89. package/dist/install/claudePathsPlan.js.map +1 -0
  90. package/dist/install/hostLayerFingerprint.js +136 -0
  91. package/dist/install/hostLayerFingerprint.js.map +1 -0
  92. package/dist/install/install.mjs +553 -425
  93. package/dist/install/partitionEligibility.js +276 -0
  94. package/dist/install/partitionEligibility.js.map +1 -0
  95. package/dist/mcp/registry-manifest.json +3 -3
  96. package/dist/mcp/server.json +2 -2
  97. package/dist/mcp/server.mjs +1744 -1191
  98. package/dist/router.json +1 -1
  99. package/dist/scripts/_lib/agent_src.js +41 -0
  100. package/dist/scripts/_lib/agent_src.js.map +1 -1
  101. package/dist/scripts/_lib/install_layout.js +83 -0
  102. package/dist/scripts/_lib/install_layout.js.map +1 -0
  103. package/dist/scripts/_lib/installed_lock.js +444 -0
  104. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  105. package/dist/scripts/condense.js +13 -164
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/schemas/settings.js +4 -0
  108. package/dist/server/schemas/settings.js.map +1 -1
  109. package/docs/contracts/settings-classes.md +12 -6
  110. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  111. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  112. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  113. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  114. package/package.json +1 -1
  115. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  116. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  117. package/src/agent-src/scripts/gate_budget.ts +282 -0
  118. package/src/agent-src/scripts/gate_execute.ts +72 -31
  119. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  120. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  121. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  122. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  123. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  124. package/src/config/agent-settings.template.yml +27 -2
  125. package/src/config/ci-local-parity.yml +18 -0
  126. package/src/config/estate-count-budget.json +339 -3
  127. package/src/config/gate-violation-baselines.json +41 -41
  128. package/src/config/rule-activation-census.json +14 -6
  129. package/src/config/rule-stub-ceilings.json +19 -6
  130. package/src/install/claudePathsPlan.ts +196 -0
  131. package/src/install/hostLayerFingerprint.ts +145 -0
  132. package/src/install/partitionEligibility.ts +325 -0
  133. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  134. package/src/scripts/_dispatch.bash +6 -1
  135. package/src/scripts/_lib/ac_heading.ts +30 -0
  136. package/src/scripts/_lib/agent_src.ts +42 -0
  137. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  138. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  139. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  140. package/src/scripts/_lib/installed_lock.ts +50 -5
  141. package/src/scripts/_lib/md_section.ts +129 -0
  142. package/src/scripts/_lib/scope_guard.sh +26 -3
  143. package/src/scripts/_lib/self_repair.ts +19 -0
  144. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  145. package/src/scripts/ai_council/cli_help.ts +55 -0
  146. package/src/scripts/before_complete_hook.ts +153 -28
  147. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  148. package/src/scripts/check_references.ts +60 -0
  149. package/src/scripts/check_release_published.ts +32 -11
  150. package/src/scripts/check_single_delivery.ts +59 -15
  151. package/src/scripts/ci_settle.ts +170 -0
  152. package/src/scripts/condense.ts +20 -170
  153. package/src/scripts/council_cli.ts +3 -3
  154. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  155. package/src/scripts/hook_manifest.json +1 -1
  156. package/src/scripts/hook_manifest.yaml +77 -14
  157. package/src/scripts/hooks/block_no_verify.ts +61 -10
  158. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  159. package/src/scripts/hooks/concern_registry.ts +4 -0
  160. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  161. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  162. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  163. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  164. package/src/scripts/hooks/state_io.ts +782 -20
  165. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  166. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  167. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  168. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  169. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  170. package/src/scripts/install.ts +16 -11
  171. package/src/scripts/language_mirror_hook.ts +561 -25
  172. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  173. package/src/scripts/lint_plan_risk_register.ts +4 -3
  174. package/src/scripts/lint_skill_router_head.ts +4 -4
  175. package/src/scripts/mcp_server/tools.ts +2 -2
  176. package/src/scripts/new_roadmap.ts +136 -0
  177. package/src/scripts/pack_mcp_content.ts +20 -5
  178. package/src/scripts/release.ts +37 -38
  179. package/src/scripts/report_layer_overlap.ts +6 -0
  180. package/src/scripts/self_repair_hook.ts +37 -5
  181. package/src/scripts/session_register_hook.ts +11 -0
  182. package/src/scripts/skill_usage_collect.ts +11 -1
  183. package/src/scripts/skill_usage_report.ts +278 -8
  184. package/src/scripts/sync_pr_branch.ts +7 -0
  185. package/src/server/schemas/settings.ts +8 -0
  186. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  187. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  188. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The Class-B path (road-to-org-telemetry Phase 5, step 5.2).
3
+ *
4
+ * Class A is the automatic half: structural fields, no content, shipped under
5
+ * recorded org consent. Class B is an abstracted CASE — what was expected
6
+ * versus what happened, and which artefacts were involved — and it ships only
7
+ * on explicit per-case approval of the exact text that would leave.
8
+ *
9
+ * THE GATE IS THE HUMAN READING THE OUTBOUND TEXT, NOT A FILTER. Rank 1 of the
10
+ * roadmap risk register is "an abstraction miss ships a path or an identifier
11
+ * out of a consumer project", and its stated mitigation is per-case review of
12
+ * the exact outbound text rather than a filter heuristic. So this module's job
13
+ * is to make the exact bytes visible and then refuse to release them unless the
14
+ * approval names those same bytes. `approve` takes a digest of the rendered
15
+ * text; a text that changed after the human read it no longer matches, and the
16
+ * release refuses rather than shipping something nobody approved.
17
+ *
18
+ * SILENT SHIPPING IS PERMANENTLY OUT OF SCOPE, and the step says so in those
19
+ * words. There is deliberately no "auto-approve", no allow-list of
20
+ * self-evidently safe classes, and no timeout that approves by default.
21
+ *
22
+ * TWO EXISTING PRIMITIVES ARE REUSED RATHER THAN RE-INVENTED:
23
+ * - `renderReport` is "the existing symptom format" the step names.
24
+ * - `egressBlockedReason` is the audited privacy floor — the same one the
25
+ * local corpus write and the upstream release already pass through. It
26
+ * REFUSES rather than rewriting, which is the property that makes it a gate;
27
+ * a module that silently scrubbed and shipped would be a soft gate wearing
28
+ * a hard gate's name.
29
+ *
30
+ * WHAT THIS MODULE DOES NOT DO: send anything. Transport and sink-side storage
31
+ * are Phase 5 step 5.3, transferred with `sink-choice` — no sink exists for an
32
+ * approved case to travel to. `ClassBRecord` is therefore built, gated and
33
+ * held; the field that carries the text is `case_text` and it is a typed
34
+ * member of a typed record, never a string interpolated into a prompt.
35
+ */
36
+ import * as crypto from 'node:crypto';
37
+
38
+ import {
39
+ type DefectRecord,
40
+ egressBlockedReason,
41
+ renderReport,
42
+ } from './self_repair.js';
43
+
44
+ /** Schema version for the Class-B envelope, independent of Class A. */
45
+ export const CLASS_B_SCHEMA_VERSION = 1;
46
+
47
+ /**
48
+ * One approved case.
49
+ *
50
+ * `case_text` is the ONLY free-form member and it exists on purpose: Class B
51
+ * is defined as an abstracted case, so a Class-B record without text would be
52
+ * a Class-A record. The controls are that it is (a) a named, typed field
53
+ * rather than an open `payload`, (b) present only after `approve` matched a
54
+ * digest a human read, and (c) documented as data end to end — see
55
+ * `assertNeverInterpolated`.
56
+ */
57
+ export interface ClassBRecord {
58
+ schema_version: number;
59
+ record_class: 'case';
60
+ /** Detector / intake class, from the pinned vocabulary. */
61
+ defect_class: string;
62
+ /** Fingerprint of the local record this case abstracts. */
63
+ fingerprint: string;
64
+ /** SHA-256 of `case_text`, so a consumer can verify what was approved. */
65
+ case_digest: string;
66
+ /** The abstracted case, exactly as the human approved it. */
67
+ case_text: string;
68
+ }
69
+
70
+ export type ClassBRefusal =
71
+ | { ok: false; reason: string; kind: 'privacy-floor' | 'digest-mismatch' | 'empty' };
72
+
73
+ export type ClassBResult = { ok: true; record: ClassBRecord } | ClassBRefusal;
74
+
75
+ /** SHA-256 hex of the exact bytes a human is asked to approve. */
76
+ export function caseDigest(text: string): string {
77
+ return crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
78
+ }
79
+
80
+ /**
81
+ * Render the exact outbound text for one record, or refuse.
82
+ *
83
+ * Refusal comes from the audited privacy floor and is returned as a reason
84
+ * string for the caller to show. It is NOT downgraded to a scrub-and-continue:
85
+ * a record whose evidence trips the floor stays local, which is the same
86
+ * verdict the upstream release path already reaches.
87
+ */
88
+ export function renderCase(
89
+ record: DefectRecord,
90
+ repoRoot?: string | null,
91
+ ): { ok: true; text: string; digest: string } | ClassBRefusal {
92
+ const blocked = egressBlockedReason(record, repoRoot ?? null);
93
+ if (blocked !== null) {
94
+ return { ok: false, reason: blocked, kind: 'privacy-floor' };
95
+ }
96
+ // `issue` is the route whose rendering carries no patch and no repository
97
+ // path — the narrowest of the existing shapes, and the right one for a
98
+ // case that travels to a telemetry sink rather than to a code host.
99
+ const text = renderReport(record, 'issue');
100
+ if (text.trim() === '') {
101
+ return { ok: false, reason: 'nothing to send', kind: 'empty' };
102
+ }
103
+ return { ok: true, text, digest: caseDigest(text) };
104
+ }
105
+
106
+ /**
107
+ * Approve one rendered case for release.
108
+ *
109
+ * `approved_digest` is what the human saw. If the text has changed since —
110
+ * a new occurrence folded in, a re-render, anything — the digests differ and
111
+ * this refuses. That is the difference between approving a case and approving
112
+ * a category.
113
+ */
114
+ export function approve(
115
+ record: DefectRecord,
116
+ text: string,
117
+ approved_digest: string,
118
+ ): ClassBResult {
119
+ const actual = caseDigest(text);
120
+ if (actual !== approved_digest) {
121
+ return {
122
+ ok: false,
123
+ kind: 'digest-mismatch',
124
+ reason:
125
+ 'the approved text is not the text that would be sent — '
126
+ + 're-render, read it again, and approve the new digest',
127
+ };
128
+ }
129
+ return {
130
+ ok: true,
131
+ record: {
132
+ schema_version: CLASS_B_SCHEMA_VERSION,
133
+ record_class: 'case',
134
+ defect_class: record.defect_class,
135
+ fingerprint: record.fingerprint,
136
+ case_digest: actual,
137
+ case_text: text,
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Serialise an approved case as ONE JSON object.
144
+ *
145
+ * This is the whole of "quoted, typed data" (step 5.3's repository-side half):
146
+ * the text is a JSON string value, so a downstream reader parses it into a
147
+ * field. Nothing here concatenates it into a sentence, a prompt, or a shell
148
+ * argument, and rank 2 of the risk register — a crafted complaint steering a
149
+ * generated change — is what that discipline is for.
150
+ */
151
+ export function serialiseCase(record: ClassBRecord): string {
152
+ return JSON.stringify(record);
153
+ }
154
+
155
+ /**
156
+ * Guard for a downstream consumer: refuse a prompt-shaped use of case text.
157
+ *
158
+ * Deliberately a narrow, honest tool. It cannot stop a caller that never asks;
159
+ * what it does is give the generation step in Phase 6 something to call, so
160
+ * "reads taxonomy fields only, never Class-B free text" is a check rather than
161
+ * a convention. The intended use is at the boundary where a record becomes
162
+ * model input.
163
+ */
164
+ export function assertNeverInterpolated(candidate: string, record: ClassBRecord): void {
165
+ if (record.case_text !== '' && candidate.includes(record.case_text)) {
166
+ throw new Error(
167
+ 'Class-B case text must reach a model as quoted data, never as part '
168
+ + 'of an instruction string — pass the record, not a concatenation',
169
+ );
170
+ }
171
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sub-help rendering for the council CLI.
3
+ *
4
+ * Lives here rather than in `council_cli.ts` for a measured reason: that file
5
+ * sits ~2,500 lines above the 1,500-line source ceiling, and
6
+ * `check_source_size_budget` is a shrink-only ratchet — every line added there
7
+ * is a new violation, so a 45-line help renderer reds the build. Extraction is
8
+ * the documented response; raising the baseline is not.
9
+ *
10
+ * WHAT IT FIXES. `council run --help` printed `usage: agent-config council run
11
+ * [-h] ...` and no flag list at all. Measured 2026-08-20: a first council run
12
+ * cost six calls, two of them spent discovering `--confirm` and `--output` by
13
+ * grepping the CLI source. The information was available at runtime all along —
14
+ * the parser's own spec table carries it — it simply was not printed.
15
+ *
16
+ * Rendered from those specs rather than hand-listed on purpose: a hand-written
17
+ * flag list is a second source of truth that drifts the first time a flag is
18
+ * added.
19
+ */
20
+
21
+ /** The shape `council_cli`'s spec table exposes, narrowed to what help needs. */
22
+ export interface HelpSpec {
23
+ readonly positionals: readonly string[];
24
+ readonly opts: readonly { readonly flag: string; readonly takesValue: boolean; readonly choices?: readonly string[] | null }[];
25
+ readonly requiredOpts: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * `usageLine` first (its exact text is asserted elsewhere and must not move),
30
+ * then positionals, then every flag with its value shape and a `[required]`
31
+ * marker where the parser demands one.
32
+ */
33
+ export function renderSubHelp(_cmd: string, usageLine: string, spec: HelpSpec): string {
34
+ const lines: string[] = [usageLine.trimEnd()];
35
+ if (spec.positionals.length > 0) {
36
+ lines.push('', 'positional arguments:');
37
+ for (const p of spec.positionals) {
38
+ lines.push(` ${p}`);
39
+ }
40
+ }
41
+ if (spec.opts.length > 0) {
42
+ lines.push('', 'options:');
43
+ const required = new Set(spec.requiredOpts);
44
+ for (const o of spec.opts) {
45
+ const value = o.takesValue
46
+ ? o.choices && o.choices.length > 0
47
+ ? ` {${o.choices.join(',')}}`
48
+ : ' VALUE'
49
+ : '';
50
+ lines.push(` ${o.flag}${value}${required.has(o.flag) ? ' [required]' : ''}`);
51
+ }
52
+ }
53
+ lines.push('');
54
+ return lines.join('\n');
55
+ }
@@ -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);
@@ -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
  );