@event4u/agent-config 14.6.0 → 14.8.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 (258) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +255 -145
  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/evals/git-pr-merge.json +13 -0
  7. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  8. package/dist/agent-src/commands/pr/create.md +14 -2
  9. package/dist/agent-src/commands/pr/merge.md +379 -0
  10. package/dist/agent-src/commands/roadmap/process-full.md +272 -32
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
  15. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
  16. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  17. package/dist/agent-src/rules/context-hygiene.md +2 -0
  18. package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
  19. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  21. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  22. package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
  23. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  24. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  25. package/dist/agent-src/rules/skill-quality.md +0 -2
  26. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  27. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  28. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  29. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  30. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  31. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  32. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  33. package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
  34. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  35. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  36. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  37. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  38. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  39. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  40. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  41. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  42. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  43. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  44. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  45. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  46. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  47. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  48. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  49. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  50. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  51. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  52. package/dist/agent-src/skills/security/SKILL.md +8 -0
  53. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  54. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  55. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  56. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  57. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  58. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  59. package/dist/agent-src/templates/roadmaps.md +65 -4
  60. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  61. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  62. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  63. package/dist/cli/registry.js +2 -1
  64. package/dist/cli/registry.js.map +1 -1
  65. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  66. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
  67. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  68. package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
  69. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
  70. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  71. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
  72. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  73. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
  74. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  75. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  76. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
  77. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  78. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  79. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  80. package/dist/cli-delegate/cmd_conformance.js +466 -716
  81. package/dist/cli-delegate/cmd_converge.js +3 -3
  82. package/dist/cli-delegate/cmd_doctor.js +10 -10
  83. package/dist/cli-delegate/cmd_explain.js +1 -1
  84. package/dist/cli-delegate/cmd_export.js +10 -9
  85. package/dist/cli-delegate/cmd_handoff.js +10 -9
  86. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  89. package/dist/cli-delegate/cmd_preflight.js +5 -5
  90. package/dist/cli-delegate/cmd_prune.js +11 -10
  91. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  92. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  93. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  94. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  95. package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
  96. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  97. package/dist/cli-delegate/cmd_sync.js +11 -10
  98. package/dist/cli-delegate/cmd_uninstall.js +13 -11
  99. package/dist/cli-delegate/cmd_update.js +4 -4
  100. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  101. package/dist/cli-delegate/cmd_validate.js +11 -10
  102. package/dist/cli-delegate/cmd_versions.js +3 -3
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +111 -37
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +5 -5
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +15 -12
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +11 -8
  112. package/dist/hooks/dispatch.js +3165 -2192
  113. package/dist/install/claudePathsPlan.js +187 -0
  114. package/dist/install/claudePathsPlan.js.map +1 -0
  115. package/dist/install/globalRuleLayers.js +169 -0
  116. package/dist/install/globalRuleLayers.js.map +1 -0
  117. package/dist/install/hostLayerFingerprint.js +136 -0
  118. package/dist/install/hostLayerFingerprint.js.map +1 -0
  119. package/dist/install/install.mjs +553 -425
  120. package/dist/install/partitionEligibility.js +432 -0
  121. package/dist/install/partitionEligibility.js.map +1 -0
  122. package/dist/install/ruleLayerPartition.js +157 -0
  123. package/dist/install/ruleLayerPartition.js.map +1 -0
  124. package/dist/mcp/registry-manifest.json +3 -3
  125. package/dist/mcp/server.json +2 -2
  126. package/dist/mcp/server.mjs +1863 -1234
  127. package/dist/router.json +1 -1
  128. package/dist/scripts/_lib/agent_src.js +41 -0
  129. package/dist/scripts/_lib/agent_src.js.map +1 -1
  130. package/dist/scripts/_lib/install_layout.js +83 -0
  131. package/dist/scripts/_lib/install_layout.js.map +1 -0
  132. package/dist/scripts/_lib/installed_lock.js +444 -0
  133. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  134. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  135. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  136. package/dist/scripts/condense.js +63 -251
  137. package/dist/scripts/condense.js.map +1 -1
  138. package/dist/server/schemas/settings.js +4 -0
  139. package/dist/server/schemas/settings.js.map +1 -1
  140. package/docs/contracts/settings-classes.md +12 -6
  141. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  142. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  143. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  144. package/docs/guidelines/agent-infra/false-green.md +47 -1
  145. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  146. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  147. package/package.json +1 -1
  148. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  149. package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  150. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  151. package/src/agent-src/scripts/gate_budget.ts +282 -0
  152. package/src/agent-src/scripts/gate_execute.ts +72 -31
  153. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  154. package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
  155. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  156. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  157. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  158. package/src/config/agent-settings.template.yml +27 -2
  159. package/src/config/ci-local-parity.yml +39 -0
  160. package/src/config/estate-count-budget.json +612 -3
  161. package/src/config/evaluator-budgets.json +3 -3
  162. package/src/config/gate-coverage.yml +220 -12
  163. package/src/config/gate-violation-baselines.json +73 -63
  164. package/src/config/gitignore-block.txt +15 -2
  165. package/src/config/hook-latency-budget.json +5 -0
  166. package/src/config/rule-activation-census.json +14 -6
  167. package/src/config/rule-stub-ceilings.json +34 -7
  168. package/src/install/claudePathsPlan.ts +196 -0
  169. package/src/install/globalRuleLayers.ts +197 -0
  170. package/src/install/hostLayerFingerprint.ts +145 -0
  171. package/src/install/partitionEligibility.ts +502 -0
  172. package/src/install/ruleLayerPartition.ts +212 -0
  173. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  174. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  175. package/src/scripts/_dispatch.bash +17 -1
  176. package/src/scripts/_lib/ac_heading.ts +30 -0
  177. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  178. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  179. package/src/scripts/_lib/agent_src.ts +42 -0
  180. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  181. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  182. package/src/scripts/_lib/envelope_grounding.ts +49 -9
  183. package/src/scripts/_lib/installed_lock.ts +50 -5
  184. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  185. package/src/scripts/_lib/md_section.ts +129 -0
  186. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  187. package/src/scripts/_lib/scope_guard.sh +26 -3
  188. package/src/scripts/_lib/self_repair.ts +19 -0
  189. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  190. package/src/scripts/adr/evidence_census.ts +899 -0
  191. package/src/scripts/adr/regenerate_index.ts +12 -32
  192. package/src/scripts/adr_cite_check.ts +540 -39
  193. package/src/scripts/ai_council/cli_help.ts +55 -0
  194. package/src/scripts/audit_adr_coverage.ts +103 -17
  195. package/src/scripts/before_complete_hook.ts +153 -28
  196. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  197. package/src/scripts/bench_hook_latency.ts +204 -0
  198. package/src/scripts/build_archive_index.ts +34 -0
  199. package/src/scripts/check_adr_frontmatter.ts +574 -39
  200. package/src/scripts/check_hook_bundle_content.ts +219 -0
  201. package/src/scripts/check_new_adr_evidence.ts +529 -0
  202. package/src/scripts/check_references.ts +78 -0
  203. package/src/scripts/check_release_published.ts +32 -11
  204. package/src/scripts/check_rule_layer_partition.ts +531 -0
  205. package/src/scripts/check_single_delivery.ts +194 -17
  206. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  207. package/src/scripts/ci_settle.ts +170 -0
  208. package/src/scripts/condense.ts +79 -259
  209. package/src/scripts/council_cli.ts +3 -3
  210. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  211. package/src/scripts/external_sources_denylist.json +4 -1
  212. package/src/scripts/hook_manifest.json +1 -1
  213. package/src/scripts/hook_manifest.yaml +77 -14
  214. package/src/scripts/hooks/block_no_verify.ts +61 -10
  215. package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
  216. package/src/scripts/hooks/concern_registry.ts +4 -0
  217. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  218. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  219. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  220. package/src/scripts/hooks/host_semantics.ts +21 -0
  221. package/src/scripts/hooks/injection_budget.ts +13 -3
  222. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  223. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  224. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  225. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  226. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  227. package/src/scripts/hooks/state_io.ts +870 -21
  228. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  229. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  230. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  231. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  232. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  233. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  234. package/src/scripts/injection_scan_hook.ts +137 -5
  235. package/src/scripts/install.ts +16 -11
  236. package/src/scripts/language_mirror_hook.ts +561 -25
  237. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  238. package/src/scripts/lint_hook_manifest.ts +49 -0
  239. package/src/scripts/lint_plan_risk_register.ts +4 -3
  240. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  241. package/src/scripts/lint_skill_router_head.ts +4 -4
  242. package/src/scripts/mcp_server/tools.ts +2 -2
  243. package/src/scripts/new_roadmap.ts +136 -0
  244. package/src/scripts/pack_mcp_content.ts +20 -5
  245. package/src/scripts/release.ts +37 -38
  246. package/src/scripts/report_layer_overlap.ts +6 -0
  247. package/src/scripts/routing_doctor.ts +5 -1
  248. package/src/scripts/self_repair_hook.ts +37 -5
  249. package/src/scripts/session_register_hook.ts +11 -0
  250. package/src/scripts/skill_usage_collect.ts +11 -1
  251. package/src/scripts/skill_usage_report.ts +278 -8
  252. package/src/scripts/sync_pr_branch.ts +170 -20
  253. package/src/server/schemas/settings.ts +8 -0
  254. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  255. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  256. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  257. package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
  258. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The stdin-read-failure policy, extracted from `dispatch_hook.ts`.
3
+ *
4
+ * These functions are pure with respect to dispatch_hook's own state: they take
5
+ * what they need as parameters and return a verdict. They do call two imported
6
+ * helpers (`readFd0ToEnd`, `isBlockCapable`), which move with them as imports. The module-level flag they feed, `_stdin_read_failed`, stays in
7
+ * dispatch_hook.ts, written at exactly one call site and read at three. That
8
+ * asymmetry is what makes moving the functions safe and the state untouched.
9
+ *
10
+ * Extracted because dispatch_hook.ts sits at the 1500-line source ceiling.
11
+ */
12
+ import type { JsonObject } from './dispatch_hook.js';
13
+ import { readFd0ToEnd } from './hook_stdin.js';
14
+ import { isBlockCapable } from './host_semantics.js';
15
+
16
+ /**
17
+ * The seam where a FAILED read becomes an empty string — isolated so the three
18
+ * failure classes the policy names can be driven with real errno values.
19
+ *
20
+ * Returns the failure message, or `null` when the read succeeded. The read
21
+ * itself is injectable for exactly one reason: `EAGAIN` exhaustion, `EIO` and
22
+ * `EBADF` cannot be staged against a live fd 0 portably, and a policy whose
23
+ * trigger is untestable is a policy nobody can show works. What the injection
24
+ * does NOT buy is proof that the production path reaches here — that is the
25
+ * wiring, pinned separately by driving `main()`.
26
+ */
27
+ export function stdinReadFailure(read: () => string = readFd0ToEnd): {
28
+ text: string;
29
+ failure: string | null;
30
+ } {
31
+ try {
32
+ // Reads to EOF and retries on EAGAIN rather than treating it as empty
33
+ // input; shared with the concern-side reader so both paths cannot drift.
34
+ return { text: read(), failure: null };
35
+ } catch (exc) {
36
+ return { text: "", failure: exc instanceof Error ? exc.message : String(exc) };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Is this concern one whose verdict a failed read silently converted into an
42
+ * allow?
43
+ *
44
+ * Both halves are required and neither is redundant. `fail_closed: true` is the
45
+ * manifest's declaration that this concern's own crash must not become a pass;
46
+ * `severity: blocking` is the declaration that it can refuse at all. An
47
+ * advisory concern that happens to be `fail_closed` has no verdict to lose, and
48
+ * a blocking concern that is not `fail_closed` has already declared the opposite
49
+ * intent.
50
+ */
51
+ export function _is_fail_closed_blocking(concern: JsonObject): boolean {
52
+ const severity = String(concern["severity"] ?? "").trim().toLowerCase();
53
+ return concern["fail_closed"] === true && severity === "blocking";
54
+ }
55
+
56
+ /**
57
+ * `b-stdin-read-failure-policy`, option (c) — deny a failed read ONLY where a
58
+ * deny is honoured AND something was actually silenced.
59
+ *
60
+ * Decided by council 2026-08-20, 2/2 quorum, both seats on option (c):
61
+ * "on a read failure, deny only when the slot is block-capable and at least one
62
+ * selected concern is both blocking and `fail_closed: true`".
63
+ *
64
+ * The two rejected options are worth keeping visible, because each is the
65
+ * obvious move. Denying on EVERY slot (option (a)) refuses nothing on
66
+ * `post_tool_use` — the tool already ran — and on `stop` it breaks a turn end
67
+ * to protect a guard that was never there; it buys availability risk for no
68
+ * enforcement. Keeping the allow (option (b), the status quo) leaves a
69
+ * documented allow-on-failure on a security path: F-1 measured a
70
+ * `git commit --no-verify` DENIED at small payload size and ALLOWED at 300 KB,
71
+ * and the loud stderr line that now ships makes that visible without making it
72
+ * stop.
73
+ *
74
+ * The cost is stated rather than hidden: a transient I/O error on
75
+ * `pre_tool_use` now refuses a tool call the user must retry. The retry budget
76
+ * already survived ~10 s of `EAGAIN` before this point, so the class of error
77
+ * that reaches here is not "the writer was slow".
78
+ *
79
+ * Returns `null` when the dispatch proceeds normally.
80
+ */
81
+ export function denyOnStdinFailure(
82
+ platform: string,
83
+ event: string,
84
+ concerns: readonly JsonObject[],
85
+ failure: string,
86
+ ): { reason: string } | null {
87
+ if (!isBlockCapable(platform, event)) return null;
88
+ const silenced = concerns.filter(_is_fail_closed_blocking).map((c) => String(c["name"]));
89
+ if (silenced.length === 0) return null;
90
+ return {
91
+ reason:
92
+ `agent-config: refusing this action — the hook payload could not be read ` +
93
+ `(${failure}), so ${silenced.join(", ")} evaluated nothing. ` +
94
+ `A fail-closed guard on a block-capable slot must not pass by default. Retry.`,
95
+ };
96
+ }
@@ -35,8 +35,9 @@
35
35
  * (`Explore` / `general-purpose` / `production-validator` / …), the same
36
36
  * single host string `orchestration_record_hook` already records.
37
37
  * - `last_assistant_message` is free model prose and is NEVER recorded, in
38
- * any form — not truncated, not hashed, not summarised. Only the three-way
39
- * parse VERDICT (`ok` / `fail` / `absent`) and a validator error COUNT
38
+ * any form — not truncated, not hashed, not summarised. Only the four-way
39
+ * parse VERDICT (`ok` / `fail` / `no_message` / `no_envelope`) and a
40
+ * validator error COUNT
40
41
  * leave this function. A record type with no field able to hold prose has
41
42
  * no scrubber that can fail.
42
43
  *
@@ -196,7 +197,20 @@ function extractParentId(payload: JsonObject, ownId: string | null): string | nu
196
197
  return null;
197
198
  }
198
199
 
199
- export type EnvelopeParse = 'ok' | 'fail' | 'absent';
200
+ export type EnvelopeParse = 'ok' | 'fail' | 'no_message' | 'no_envelope';
201
+
202
+ /**
203
+ * The retired fifth value. Records written before 2026-08-20 carry `absent`,
204
+ * which collapsed `no_message` and `no_envelope` into one bucket.
205
+ *
206
+ * It is deliberately NOT in the union. Nothing reads `envelope_parse` back
207
+ * programmatically — the only writer is this file and the only reader is a
208
+ * human or a `grep` — so there is no consumer to keep compatible, and leaving
209
+ * the value writable would let the collapse reappear. A reader of the
210
+ * historical window must treat `absent` as "one of the two, unknown which":
211
+ * the measurement it supports starts at the split, not before it.
212
+ */
213
+ export const RETIRED_ENVELOPE_PARSE = 'absent';
200
214
 
201
215
  export interface ParseVerdict {
202
216
  verdict: EnvelopeParse;
@@ -207,18 +221,33 @@ export interface ParseVerdict {
207
221
  /**
208
222
  * Classify a subagent's final message against the response-envelope contract.
209
223
  *
210
- * Three outcomes, and the distinction between the last two is the whole point
211
- * of the measurement: `absent` means no envelope was found to judge, `fail`
212
- * means one was found and did not satisfy `validateResponse`. Collapsing them
213
- * would make the Phase-1 baseline unable to separate "the worker never
214
- * returned a structured result" from "it returned a malformed one" — two
215
- * different defects with two different fixes.
224
+ * Four outcomes, and every boundary between them separates two defects with
225
+ * two different fixes:
226
+ *
227
+ * - `no_message` — the host delivered no `last_assistant_message` at all
228
+ * (null, or whitespace only). This is the return-channel loss #58109
229
+ * describes, and it is the ONLY verdict a disk-envelope fallback may key
230
+ * on.
231
+ * - `no_envelope` — a message arrived and carried no JSON object. This is
232
+ * the overwhelmingly common case: subagents answer in prose. It is not a
233
+ * channel failure, and a fallback keyed on it would fire on nearly every
234
+ * dispatch.
235
+ * - `fail` — an object arrived and did not satisfy `validateResponse`.
236
+ * - `ok` — an object arrived and validated.
237
+ *
238
+ * The first two used to be one value, `absent`. Over the first measured window
239
+ * that collapse read 25 of 25 `absent` — including the #58109 control arm that
240
+ * returned a complete report — so a rate computed off it measured the answer
241
+ * format rather than the channel
242
+ * (`agents/evidence/investigations/subagent-lifecycle-phase0-return-channel.md`
243
+ * § F2). Splitting them is what makes the Phase-1 baseline's return-rate
244
+ * column, and Phase 2's fallback condition, expressible at all.
216
245
  */
217
246
  export function classifyEnvelope(message: string | null): ParseVerdict {
218
- if (message === null || !message.trim()) return { verdict: 'absent', error_count: 0 };
247
+ if (message === null || !message.trim()) return { verdict: 'no_message', error_count: 0 };
219
248
 
220
249
  const candidates = _jsonObjectCandidates(message);
221
- if (candidates.length === 0) return { verdict: 'absent', error_count: 0 };
250
+ if (candidates.length === 0) return { verdict: 'no_envelope', error_count: 0 };
222
251
 
223
252
  // A message may carry several object-shaped spans. The envelope is the one
224
253
  // that validates, so a `fail` is only reported once every candidate has
@@ -229,7 +258,7 @@ export function classifyEnvelope(message: string | null): ParseVerdict {
229
258
  if (result.valid) return { verdict: 'ok', error_count: 0 };
230
259
  if (best === null) best = { verdict: 'fail', error_count: result.errors.length };
231
260
  }
232
- return best ?? { verdict: 'absent', error_count: 0 };
261
+ return best ?? { verdict: 'no_envelope', error_count: 0 };
233
262
  }
234
263
 
235
264
  /**
@@ -239,9 +268,9 @@ export function classifyEnvelope(message: string | null): ParseVerdict {
239
268
  * R2 finding 4: the previous implementation took the span from the first `{`
240
269
  * to the LAST `}`, so a valid envelope followed by any later brace in prose
241
270
  * ("… — done. See {done}.") produced unparseable text and was reported
242
- * `absent` — routing an extraction failure into the "never returned anything"
243
- * bucket that the whole three-way verdict exists to keep separate, and biasing
244
- * the Phase-1 return-rate falsifier downward.
271
+ * `no_envelope` — routing an extraction failure into the "answered in prose"
272
+ * bucket that the verdict split exists to keep separate, and biasing the
273
+ * Phase-1 return-rate falsifier downward.
245
274
  *
246
275
  * R2 finding 11: fenced blocks are now matched with an anchored, global scan
247
276
  * rather than a first-match-anywhere regex, which used to be able to match at
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Session-end Class-A flush (road-to-org-telemetry Phase 2, step 2.1).
4
+ *
5
+ * WHAT THIS CONCERN DOES, IN ONE SENTENCE: it checks whether the outbound
6
+ * spool has bytes and, if so, spawns a detached sender and returns. It makes
7
+ * no network call of its own, in this process, ever.
8
+ *
9
+ * THAT SHAPE IS MEASURED, NOT PREFERRED. Phase 0's second spike
10
+ * pre-registered "added latency at or below one second at p95" for an INLINE
11
+ * session-end flush and measured it failing: 0.4 ms against a healthy sink,
12
+ * 0.3 ms against a refused connection, and **1002 ms against a blackhole** —
13
+ * a socket that accepts and never answers, which is the shape a wedged sink
14
+ * actually takes. Against the same blackhole the detached spool measured
15
+ * 20.5 ms p95, dominated by process spawn. The step's own text defers to that
16
+ * result, so this concern is the spool half of it.
17
+ *
18
+ * DEFAULT-OFF, AND `enabled` IS NOT THE SWITCH. `read_remote_settings`
19
+ * resolves `active` only when `enabled` is true AND the org pack supplied an
20
+ * endpoint, an org id and a salt — none of which has a default and none of
21
+ * which this public repository ships a value for. A clone therefore cannot
22
+ * reach the spawn path. `flush: never` (the value an org sets to keep records
23
+ * local) short-circuits before the spool is even stat-ed.
24
+ *
25
+ * ZERO FILE OPERATIONS WHEN INACTIVE, and the spool `stat` is the only one
26
+ * when active-but-idle: most sessions invoke no skill, so there is no spool
27
+ * to drain and the concern costs one `stat` and returns.
28
+ *
29
+ * EXIT CODE IS ALWAYS 0 and stdout is always empty. A hook `warn` (exit 2) is
30
+ * read as a hard BLOCK on the verified host, and a telemetry concern has
31
+ * nothing to say to the model — least of all at session end, where a message
32
+ * would arrive after the last thing the user reads.
33
+ */
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import { fileURLToPath, pathToFileURL } from 'node:url';
37
+
38
+ import {
39
+ DEFAULT_FLUSH_TIMEOUT_MS,
40
+ FLUSH_SESSION_END,
41
+ spawn_detached_sender,
42
+ spool_has_work,
43
+ spool_path_for,
44
+ } from '../../agent-src/templates/scripts/telemetry/transport.js';
45
+ import { is_replay_mode } from './state_io.js';
46
+ import { readHookStdin } from './hook_stdin.js';
47
+ import { _resolveRoot, readSettingsFor } from './telemetry_usage_hook.js';
48
+
49
+ const EXIT_ALLOW = 0;
50
+
51
+ /**
52
+ * Where `flush_sender.mjs` lives, relative to this module.
53
+ *
54
+ * Resolved from `import.meta.url` and then existence-checked, rather than
55
+ * assumed: under an esbuild bundle every module shares the bundle's own URL,
56
+ * so this path can be wrong. A wrong path must degrade to "no flush this
57
+ * session" (the records stay spooled and go out next time) and never to a
58
+ * spawn of something that is not the sender.
59
+ */
60
+ export const SENDER_REL = '../../agent-src/templates/scripts/telemetry/flush_sender.mjs';
61
+
62
+ export function resolveSenderScript(): string | null {
63
+ try {
64
+ const here = path.dirname(fileURLToPath(import.meta.url));
65
+ const candidate = path.resolve(here, SENDER_REL);
66
+ return fs.statSync(candidate).isFile() ? candidate : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export interface FlushOutcome {
73
+ /** `spawned` is the only outcome that starts a process. */
74
+ readonly result: 'inactive' | 'flush-never' | 'empty-spool' | 'no-sender' | 'replay' | 'spawned';
75
+ }
76
+
77
+ export function flushFor(consumer_root: string, sender: string | null = resolveSenderScript()): FlushOutcome {
78
+ try {
79
+ const { settings, root } = readSettingsFor(consumer_root);
80
+ if (!settings.active) return { result: 'inactive' };
81
+ if (settings.flush !== FLUSH_SESSION_END) return { result: 'flush-never' };
82
+ if (is_replay_mode()) return { result: 'replay' };
83
+
84
+ // Relative to the PROJECT root, matching the appender exactly — a
85
+ // flush that resolved the log against the session cwd would drain a
86
+ // spool nobody writes.
87
+ const logPath = path.isAbsolute(settings.log_path)
88
+ ? settings.log_path
89
+ : path.join(root, settings.log_path);
90
+ const spool = spool_path_for(logPath);
91
+ if (!spool_has_work(spool)) return { result: 'empty-spool' };
92
+ if (sender === null) return { result: 'no-sender' };
93
+
94
+ const ok = spawn_detached_sender({
95
+ sender_script: sender,
96
+ spool_path: spool,
97
+ endpoint: settings.endpoint,
98
+ timeout_ms: DEFAULT_FLUSH_TIMEOUT_MS,
99
+ });
100
+ return { result: ok ? 'spawned' : 'no-sender' };
101
+ } catch {
102
+ return { result: 'inactive' };
103
+ }
104
+ }
105
+
106
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
107
+ flushFor(options.consumer_root);
108
+ return EXIT_ALLOW;
109
+ }
110
+
111
+ export function main(): number {
112
+ const raw = readHookStdin();
113
+ let envelope: unknown = {};
114
+ try {
115
+ envelope = raw.trim() ? JSON.parse(raw) : {};
116
+ } catch {
117
+ envelope = {};
118
+ }
119
+ flushFor(_resolveRoot(envelope as never));
120
+ return EXIT_ALLOW;
121
+ }
122
+
123
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
124
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
125
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
126
+ function _isCliEntry(): boolean {
127
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
128
+ return false;
129
+ }
130
+ if (process.argv[1] === undefined) return false;
131
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
132
+ if (import.meta.url === argvUrl) return true;
133
+ try {
134
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+ if (_isCliEntry()) process.exit(main());
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The Class-A self-repair shadow (road-to-org-telemetry Phase 5, step 5.1).
3
+ *
4
+ * One function, called from `self_repair_hook.ts` immediately after a defect
5
+ * is queued. It emits a structural record saying that a defect of a named
6
+ * class occurred — never what happened.
7
+ *
8
+ * WHY THIS IS NOT A CONCERN OF ITS OWN. The event it reacts to is not a host
9
+ * event; it is "the self-repair store just accepted a record". Binding a
10
+ * second concern to the same two slots would re-run the detectors to find out
11
+ * whether the first one wrote anything, so the emission belongs at the seam
12
+ * where the answer is already in hand.
13
+ *
14
+ * WHAT IT CANNOT LEAK, BY CONSTRUCTION. `ClassADefectRecord` has no field able
15
+ * to hold free-form text. `DefectFinding.evidence` (a quoted span of the
16
+ * offending text) and `suggested_surface` (a free sentence) are the Class-B
17
+ * payload and are not passed to the builder at all — not sanitized, not
18
+ * truncated, not optional. There is no scrubber here because there is nothing
19
+ * for a scrubber to fail on.
20
+ *
21
+ * DEFAULT-OFF on the same terms as every other telemetry writer: `active`
22
+ * needs `enabled` AND an endpoint AND an org id AND a salt, none of which this
23
+ * public repository ships a value for. Inactive means zero file operations.
24
+ *
25
+ * NEVER THROWS. The caller wraps it, and the store write has already
26
+ * succeeded by the time it runs; a telemetry failure must not cost the user
27
+ * their turn or their queued defect.
28
+ */
29
+ import * as os from 'node:os';
30
+ import * as path from 'node:path';
31
+
32
+ import {
33
+ append_class_a_record,
34
+ build_class_a_defect_record,
35
+ } from '../../agent-src/templates/scripts/telemetry/remote.js';
36
+ import {
37
+ FLUSH_SESSION_END,
38
+ spool_path_for,
39
+ } from '../../agent-src/templates/scripts/telemetry/transport.js';
40
+ import { DEFECT_CLASSES, type DefectRecord } from '../_lib/self_repair.js';
41
+ import { is_replay_mode } from './state_io.js';
42
+ import {
43
+ extractDisciplineProfile,
44
+ extractPackageVersion,
45
+ readSettingsFor,
46
+ } from './telemetry_usage_hook.js';
47
+
48
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
49
+ type JsonObject = { [k: string]: JsonValue };
50
+
51
+ function str(v: JsonValue | undefined): string {
52
+ return typeof v === 'string' ? v : '';
53
+ }
54
+
55
+ /** Host id off the dispatcher envelope, or `null` when it carried none. */
56
+ export function hostOf(env: JsonObject): string | null {
57
+ const v = str(env['platform']) || str(env['host']);
58
+ return v === '' ? null : v;
59
+ }
60
+
61
+ /** The host session id, hashed downstream and never recorded raw. */
62
+ export function sessionIdOf(env: JsonObject): string {
63
+ return str(env['session_id']) || str(env['sessionId']);
64
+ }
65
+
66
+ export type ShadowOutcome =
67
+ | 'inactive'
68
+ | 'replay'
69
+ | 'unknown-class'
70
+ | 'written'
71
+ | 'failed';
72
+
73
+ /**
74
+ * Emit the shadow for one queued defect. Returns what it did, for tests —
75
+ * the caller ignores it.
76
+ */
77
+ export function emitDefectShadow(
78
+ root: string,
79
+ rec: DefectRecord,
80
+ env: JsonObject,
81
+ ): ShadowOutcome {
82
+ try {
83
+ const { settings, text, root: projectRoot } = readSettingsFor(root);
84
+ if (!settings.active) return 'inactive';
85
+ if (is_replay_mode()) return 'replay';
86
+
87
+ // The vocabulary is pinned in `self_repair.ts` and mirrored into the
88
+ // upstream issue form by test. A class outside it means a detector was
89
+ // added without the rest of its registration; recording it would put
90
+ // an unpinned value into an org's record set, so it is dropped and the
91
+ // local queue keeps the defect regardless.
92
+ if (!(DEFECT_CLASSES as readonly string[]).includes(rec.defect_class)) {
93
+ return 'unknown-class';
94
+ }
95
+
96
+ const record = build_class_a_defect_record({
97
+ defect_class: rec.defect_class,
98
+ defect_source: rec.source,
99
+ occurrences: rec.occurrences,
100
+ host: hostOf(env),
101
+ org_id: settings.org_id,
102
+ salt: settings.salt,
103
+ hostname: os.hostname(),
104
+ username: os.userInfo().username,
105
+ session_id: sessionIdOf(env),
106
+ package_version: extractPackageVersion(process.env),
107
+ discipline_profile: extractDisciplineProfile(text),
108
+ });
109
+
110
+ const logPath = path.isAbsolute(settings.log_path)
111
+ ? settings.log_path
112
+ : path.join(projectRoot, settings.log_path);
113
+ const spool = settings.flush === FLUSH_SESSION_END ? spool_path_for(logPath) : null;
114
+ append_class_a_record(
115
+ logPath,
116
+ record,
117
+ {
118
+ max_age_days: settings.retention_max_age_days,
119
+ max_bytes: settings.retention_max_bytes,
120
+ },
121
+ new Date(),
122
+ spool,
123
+ );
124
+ return 'written';
125
+ } catch {
126
+ return 'failed';
127
+ }
128
+ }
@@ -51,6 +51,10 @@ import {
51
51
  build_class_a_record,
52
52
  profile_from_legacy_tier,
53
53
  } from '../../agent-src/templates/scripts/telemetry/remote.js';
54
+ import {
55
+ FLUSH_SESSION_END,
56
+ spool_path_for,
57
+ } from '../../agent-src/templates/scripts/telemetry/transport.js';
54
58
  import { is_replay_mode } from './state_io.js';
55
59
  import { readHookStdin } from './hook_stdin.js';
56
60
 
@@ -271,14 +275,28 @@ function processEnvelope(envelope: JsonValue, consumer_root: string): number {
271
275
  const logPath = path.isAbsolute(settings.log_path)
272
276
  ? settings.log_path
273
277
  : path.join(root, settings.log_path);
278
+ // The outbound spool (Phase 2, step 2.1) is written HERE, by the same
279
+ // call that logs the record, and only when the install declared a
280
+ // flush. Under `flush: never` there is no transport at all, so a spool
281
+ // would be a file that grows and is never drained. The path is derived
282
+ // rather than configured so an install cannot point the spool
283
+ // somewhere the appender does not write — a failure whose symptom
284
+ // (records logged locally, nothing ever sent) is invisible.
285
+ const spool = settings.flush === FLUSH_SESSION_END ? spool_path_for(logPath) : null;
274
286
  // The growth budget travels from the settings the install actually
275
287
  // declared, not from the appender's own defaults — a retention key
276
288
  // the write path never reads would be a setting that decorates
277
289
  // rather than decides.
278
- append_class_a_record(logPath, record, {
279
- max_age_days: settings.retention_max_age_days,
280
- max_bytes: settings.retention_max_bytes,
281
- });
290
+ append_class_a_record(
291
+ logPath,
292
+ record,
293
+ {
294
+ max_age_days: settings.retention_max_age_days,
295
+ max_bytes: settings.retention_max_bytes,
296
+ },
297
+ new Date(),
298
+ spool,
299
+ );
282
300
  } catch {
283
301
  // Malformed payload, unreadable disk, a rejected id — never block
284
302
  // the tool call, and never write a degraded record.
@@ -146,17 +146,25 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
146
146
 
147
147
  import {
148
148
  classify,
149
+ hasStableSessionId,
149
150
  isSyntheticPrompt,
150
- STATE_FILE as LANGUAGE_STATE_FILE,
151
+ statePathFor as languageStatePathFor,
151
152
  type Verdict,
152
153
  } from '../language_mirror_hook.js';
153
- // Round 7 § Phase 1 — the CI-settle producer. Imported for its STATE_FILE only,
154
- // so the consumer cannot read a path the producer does not write.
155
- import { STATE_FILE as CI_STATE_FILE } from '../before_complete_hook.js';
154
+ // Round 7 § Phase 1 — the CI-settle producer. Imported for its path BUILDER, not
155
+ // for a path constant: "the consumer cannot read a path the producer does not
156
+ // write" is only true while the two agree, and importing a constant made that
157
+ // agreement invisible when the producer's layout moved. A builder makes the move
158
+ // a type error.
159
+ import { statePathFor as ciStatePathFor } from '../before_complete_hook.js';
156
160
  import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
157
161
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
158
162
  import { readHookStdin } from './hook_stdin.js';
159
- import { atomic_write_json, is_replay_mode } from './state_io.js';
163
+ import {
164
+ atomic_write_json,
165
+ is_replay_mode,
166
+ owns_session_state as ownsSessionState,
167
+ } from './state_io.js';
160
168
  import { openRecordStats } from './subagent_ledger_hook.js';
161
169
  import {
162
170
  deriveSessionKey,
@@ -400,7 +408,17 @@ export interface LanguagePin {
400
408
  *
401
409
  * So the producer is `before_complete_hook`, which already sees tool output on
402
410
  * `post_tool_use` and already owns the `pendingCount` predicate. It writes
403
- * `ci_last` into `agents/state/verify-before-complete.json` and this reads it.
411
+ * `ci_last` into its own per-session state file under
412
+ * `agents/state/verify-before-complete/` and this reads it — via the producer's
413
+ * `statePathFor`, never a path literal, so the next move of that layout is a
414
+ * compile error instead of a detector that silently reads nothing. (That is not
415
+ * hypothetical: the sibling language pin moved exactly this way and its
416
+ * consumer here kept importing the abandoned constant, which turned a blocking
417
+ * detector off without a single failing test.)
418
+ *
419
+ * KEYED ON `session_id`, because the CI witness is a fact about ONE run.
420
+ * A no-id envelope reads as "no CI observed" — the producer persists nothing in
421
+ * that case, and the refusal direction that follows is the safe one.
404
422
  *
405
423
  * NO network call, deliberately. Asking `gh pr checks` here would put a network
406
424
  * round-trip on every turn-end; `road-to-hook-latency-repair` exists because that
@@ -412,10 +430,20 @@ export interface LanguagePin {
412
430
  * never saw. A stale NEGATIVE ("CI was not settled") only ever refuses more often.
413
431
  * Same freshness invariant, opposite failure direction.
414
432
  */
415
- export function readCiSettled(workspaceRoot: string): { seen: boolean; settled: boolean } {
433
+ export function readCiSettled(
434
+ workspaceRoot: string,
435
+ session_id: string,
436
+ ): { seen: boolean; settled: boolean } {
437
+ if (!hasStableSessionId(session_id)) return { seen: false, settled: false };
416
438
  try {
417
- const raw = fs.readFileSync(path.join(workspaceRoot, CI_STATE_FILE), 'utf-8');
439
+ const raw = fs.readFileSync(path.join(workspaceRoot, ciStatePathFor(session_id)), 'utf-8');
418
440
  const decoded: unknown = JSON.parse(raw);
441
+ // Same ownership check as the pin, and here it is the sharper of the two:
442
+ // a FOREIGN `ci_last: {settled: true}` vouches for a CI run this session
443
+ // never made, which is exactly the premature completion claim detector D
444
+ // exists to refuse. Refusing an unowned file returns "no CI observed",
445
+ // which never refuses a session for someone else's run.
446
+ if (!ownsSessionState(decoded, session_id)) return { seen: false, settled: false };
419
447
  if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
420
448
  const ci = (decoded as Record<string, unknown>)['ci_last'];
421
449
  if (typeof ci === 'object' && ci !== null && !Array.isArray(ci)) {
@@ -496,11 +524,43 @@ export function detectCompletionClaim(
496
524
  *
497
525
  * (R2 finding 8: this line was left 73 lines above, where inserting `readCiSettled`
498
526
  * had stranded it — so it documented the wrong function and this one had none.)
527
+ *
528
+ * KEYED ON `session_id`, because the producer is. The import above says
529
+ * "imported for its STATE_FILE only, so the consumer cannot read a path the
530
+ * producer does not write" — and that discipline failed the moment the producer
531
+ * MOVED: `language_mirror_hook` split its state per session
532
+ * (`agents/state/language-mirror/<digest>.json`) and `_pruneLegacyState` now
533
+ * deletes the single file this function used to read. Importing the old symbol
534
+ * still compiled, so nothing broke loudly: `readLanguagePin` returned `und` for
535
+ * every turn, `detectLanguage` returned `null` for every turn, and detector B of
536
+ * a blocking gate stopped checking anything. Importing the path-BUILDER instead
537
+ * of a path constant is what makes the next such move a type error rather than a
538
+ * silent dead detector.
539
+ *
540
+ * NO LEGACY FALLBACK, deliberately. Reading `STATE_FILE` when the per-session
541
+ * file is absent would restore exactly the cross-session read the split closed:
542
+ * that file is shared by every session under one project root, so a neighbouring
543
+ * English session's pin would become this German session's obligation. A missing
544
+ * pin is the safe answer; a foreign pin is the defect.
545
+ *
546
+ * No stable id ⇒ `und`. The producer runs stateless in that case and persists
547
+ * nothing, so there is no pin to read — not "no obligation by accident", but the
548
+ * same degradation the producer documents, in the same direction (under-refuse).
499
549
  */
500
- export function readLanguagePin(workspaceRoot: string): Verdict {
550
+ export function readLanguagePin(workspaceRoot: string, session_id: string): Verdict {
551
+ if (!hasStableSessionId(session_id)) return 'und';
501
552
  try {
502
- const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
553
+ const raw = fs.readFileSync(
554
+ path.join(workspaceRoot, languageStatePathFor(session_id)),
555
+ 'utf-8',
556
+ );
503
557
  const decoded: unknown = JSON.parse(raw);
558
+ // The digest path is not the whole guarantee — see `owns_session_state`.
559
+ // A file that reached this pathname by a copy, a restore, or a buggy
560
+ // writer still carries its real owner, and consuming it hands one
561
+ // session's pin to another. Refusing it costs a turn's obligation;
562
+ // accepting it is the cross-session read the split closed.
563
+ if (!ownsSessionState(decoded, session_id)) return 'und';
504
564
  if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
505
565
  const lang = (decoded as Record<string, unknown>)['language'];
506
566
  if (lang === 'de' || lang === 'en') return lang;
@@ -1019,9 +1079,13 @@ export function main(): number {
1019
1079
  // than colliding, and this one collides. It is named here rather than
1020
1080
  // papered over — the degradation is toward UNDER-refusing, which is the safe
1021
1081
  // direction, and layer 1 still covers the host that sends the flag.
1022
- const sessionKey = deriveSessionKey(
1023
- str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
1024
- );
1082
+ // RAW id, kept beside the derived key rather than replaced by it:
1083
+ // `readLanguagePin` addresses the producer's own per-session file, and the
1084
+ // producer keys that on the raw `session_id`. Passing `sessionKey` here would
1085
+ // read a path nothing writes — the same shape as the STATE_FILE break this
1086
+ // parameter exists to close.
1087
+ const rawSessionId = str(envelope['session_id'] as JsonValue | undefined) || '';
1088
+ const sessionKey = deriveSessionKey(rawSessionId || 'unknown-session');
1025
1089
  if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
1026
1090
 
1027
1091
  // B and C run on every turn-end; A and D run only when no dispatch is open
@@ -1042,7 +1106,7 @@ export function main(): number {
1042
1106
  // (Phase 3 Step 2, narrowed by R2 round 2 finding 2). B and C are not
1043
1107
  // excused by anything about a dispatch and run unchanged.
1044
1108
  dispatchOpen ? null : detectPromissory(lastAssistant),
1045
- detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
1109
+ detectLanguage(lastAssistant, readLanguagePin(workspaceRoot, rawSessionId)),
1046
1110
  detectUnverifiedEdit(toolCalls),
1047
1111
  // Round 7 § Phase 1 — detector D. It is NOT unconditional, and this
1048
1112
  // comment said it was while sitting one line above the `dispatchOpen`
@@ -1054,7 +1118,9 @@ export function main(): number {
1054
1118
  // gating is where the comment above says gating belongs — inside the
1055
1119
  // detector: no CI observed, or a settled read, or no completion claim
1056
1120
  // ⇒ no finding.
1057
- dispatchOpen ? null : detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot)),
1121
+ dispatchOpen
1122
+ ? null
1123
+ : detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot, rawSessionId)),
1058
1124
  ]) {
1059
1125
  if (f) findings.push(f);
1060
1126
  }