@event4u/agent-config 9.28.0 → 9.30.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 (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +172 -5
  4. package/README.md +2 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/explain-run.md +31 -1
  9. package/dist/agent-src/commands/team/adversarial.md +8 -7
  10. package/dist/agent-src/commands/team/delegate.md +14 -12
  11. package/dist/agent-src/commands/team/review.md +9 -8
  12. package/dist/agent-src/commands/team/status.md +9 -8
  13. package/dist/agent-src/commands/team.md +29 -19
  14. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  15. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  17. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  19. package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
  20. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  21. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  22. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  23. package/dist/agent-src/rules/delegation-policy.md +11 -7
  24. package/dist/agent-src/rules/preservation-guard.md +9 -0
  25. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  26. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  27. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  28. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  29. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  30. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  31. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  32. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
  34. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  35. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  36. package/dist/agent-src/templates/AGENTS.md +4 -4
  37. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  38. package/dist/agent-src/templates/roadmaps.md +23 -0
  39. package/dist/cli/registry.js +2 -0
  40. package/dist/cli/registry.js.map +1 -1
  41. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  42. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
  43. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
  44. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
  45. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
  46. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  47. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  48. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  49. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  50. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  51. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  52. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  53. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  54. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  55. package/dist/cli-delegate/cmd_conformance.js +2803 -137
  56. package/dist/cli-delegate/cmd_doctor.js +10 -5
  57. package/dist/cli-delegate/cmd_explain.js +2 -2
  58. package/dist/cli-delegate/cmd_export.js +4 -4
  59. package/dist/cli-delegate/cmd_handoff.js +88 -8
  60. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  61. package/dist/cli-delegate/cmd_migrate.js +2 -2
  62. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  63. package/dist/cli-delegate/cmd_prune.js +4 -4
  64. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  65. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  66. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  67. package/dist/cli-delegate/cmd_settings_get.js +2 -8
  68. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  69. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +4 -4
  71. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  72. package/dist/cli-delegate/cmd_update.js +1 -1
  73. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  74. package/dist/cli-delegate/cmd_validate.js +4 -4
  75. package/dist/cli-delegate/cmd_versions.js +1 -1
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +72 -23
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -2
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +39 -2
  82. package/dist/discovery/trust-report.md +2 -2
  83. package/dist/discovery/workspaces.json +12 -4
  84. package/dist/hooks/dispatch.js +2278 -611
  85. package/dist/install/install.mjs +56 -17
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +4122 -699
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +54 -0
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/dist/scripts/condense.js +147 -2
  93. package/dist/scripts/condense.js.map +1 -1
  94. package/dist/server/schemas/settings.js +12 -7
  95. package/dist/server/schemas/settings.js.map +1 -1
  96. package/dist/shared/settingsCarveOut.js +0 -9
  97. package/dist/shared/settingsCarveOut.js.map +1 -1
  98. package/dist/shared/settingsClasses.js +11 -9
  99. package/dist/shared/settingsClasses.js.map +1 -1
  100. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  101. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  102. package/dist/ui/index.html +1 -1
  103. package/docs/contracts/settings-classes.md +58 -12
  104. package/package.json +1 -1
  105. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  107. package/src/agent-src/templates/AGENTS.md +4 -4
  108. package/src/config/agent-settings.template.yml +88 -66
  109. package/src/config/discovery/packs.yml +11 -0
  110. package/src/config/discovery/workspaces.yml +1 -1
  111. package/src/config/dispatch-economy-metrics.json +61 -0
  112. package/src/config/evaluator-budgets.json +4 -4
  113. package/src/config/gate-violation-baselines.json +3 -3
  114. package/src/config/hook-token-budget.json +91 -0
  115. package/src/config/recycle-threshold-budget.json +12 -0
  116. package/src/config/rule-enforcement-baseline.json +86 -0
  117. package/src/scripts/_cli/cmd_conformance.ts +31 -44
  118. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  119. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  120. package/src/scripts/_dispatch.bash +23 -0
  121. package/src/scripts/_lib/agent_settings.ts +58 -0
  122. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  123. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  124. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  125. package/src/scripts/_lib/host_capability.ts +63 -6
  126. package/src/scripts/_lib/judgment_ladder.ts +634 -0
  127. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  128. package/src/scripts/_lib/orchestration_record.ts +78 -10
  129. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  130. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  131. package/src/scripts/_lib/self_repair.ts +354 -13
  132. package/src/scripts/_lib/self_repair_store.ts +28 -0
  133. package/src/scripts/_lib/session_eol.ts +230 -0
  134. package/src/scripts/_lib/session_role.ts +42 -0
  135. package/src/scripts/_lib/subagent_capsule.ts +341 -0
  136. package/src/scripts/_lib/subagent_response.ts +94 -0
  137. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  138. package/src/scripts/_lib/subagent_steering.ts +9 -4
  139. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  140. package/src/scripts/_lib/worker_budget.ts +37 -2
  141. package/src/scripts/ai_council/clients.ts +126 -55
  142. package/src/scripts/ai_council/config.ts +94 -6
  143. package/src/scripts/ai_council/handoff.ts +113 -0
  144. package/src/scripts/ai_council/modes.ts +19 -4
  145. package/src/scripts/ai_council/orchestrator.ts +105 -0
  146. package/src/scripts/ai_council/quorum.ts +84 -0
  147. package/src/scripts/ai_council/session.ts +44 -0
  148. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  149. package/src/scripts/ai_team/availability.ts +110 -0
  150. package/src/scripts/ai_team/config.ts +34 -14
  151. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  152. package/src/scripts/ask_transport.ts +221 -0
  153. package/src/scripts/bench_critic_protocol.ts +396 -0
  154. package/src/scripts/bench_hook_injection.ts +234 -0
  155. package/src/scripts/check_adr_frontmatter.ts +84 -40
  156. package/src/scripts/check_branch_freshness.ts +1 -0
  157. package/src/scripts/check_corpus_staleness.ts +18 -4
  158. package/src/scripts/check_detector_corpus.ts +219 -0
  159. package/src/scripts/check_gitignore_freshness.ts +42 -8
  160. package/src/scripts/check_hook_bundle_freshness.ts +24 -1
  161. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  162. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  163. package/src/scripts/check_static_layer_stability.ts +154 -0
  164. package/src/scripts/check_suppression_hygiene.ts +48 -0
  165. package/src/scripts/condense.ts +162 -2
  166. package/src/scripts/council_cli.ts +401 -25
  167. package/src/scripts/dispatch_economy_report.ts +448 -0
  168. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  169. package/src/scripts/explain_run.ts +206 -4
  170. package/src/scripts/forensics_report.ts +333 -0
  171. package/src/scripts/generate_settings_reference.ts +1 -0
  172. package/src/scripts/handoff_context_hook.ts +112 -4
  173. package/src/scripts/hook_manifest.yaml +117 -10
  174. package/src/scripts/hooks/concern_registry.ts +8 -0
  175. package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
  176. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  177. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  178. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  179. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  180. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  181. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  182. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  183. package/src/scripts/install-hooks.sh +1 -1
  184. package/src/scripts/language_mirror_hook.ts +89 -0
  185. package/src/scripts/lint_decision_impact.ts +14 -1
  186. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  187. package/src/scripts/lint_hook_manifest.ts +82 -0
  188. package/src/scripts/lint_never_silent.ts +276 -0
  189. package/src/scripts/lint_no_activation_gates.ts +271 -0
  190. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  191. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  192. package/src/scripts/measure_turn_end_gate.ts +194 -0
  193. package/src/scripts/orchestration_record.ts +12 -1
  194. package/src/scripts/rebuild_hook_bundle.ts +178 -0
  195. package/src/scripts/release.ts +74 -2
  196. package/src/scripts/report_conformance_funnel.ts +284 -0
  197. package/src/scripts/report_imperative_density.ts +1 -0
  198. package/src/scripts/routing_doctor.ts +118 -36
  199. package/src/scripts/score_skill_selection.ts +19 -5
  200. package/src/scripts/self_repair_cli.ts +337 -59
  201. package/src/scripts/session_eol_report.ts +261 -0
  202. package/src/scripts/team_events_spike.ts +271 -0
  203. package/src/scripts/team_review_gate_hook.ts +31 -9
  204. package/src/server/schemas/settings.ts +24 -17
  205. package/src/shared/settingsCarveOut.ts +0 -10
  206. package/src/shared/settingsClasses.ts +11 -9
  207. package/src/templates/compact-instructions.md +40 -0
  208. package/dist/ui/assets/index-DmR0mbk4.js +0 -26
  209. package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
@@ -19,8 +19,10 @@
19
19
  import type { LookupClass } from './auto_dispatch.js';
20
20
 
21
21
  export type DispatchOutcome = 'DONE' | 'DONE_WITH_CONCERNS' | 'NEEDS_CONTEXT' | 'BLOCKED' | 'killed';
22
- /** Which route the lookup-class rung took (lean-init L0). */
23
- export type RouteTaken = 'primitive' | 'subagent';
22
+ /** Which route the rung took: deterministic primitive (lean-init L0),
23
+ * single-completion ask (token-economy-dispatch rung 0.5), or a full
24
+ * subagent spawn. */
25
+ export type RouteTaken = 'primitive' | 'subagent' | 'ask';
24
26
  export type VerifyMode = 'deterministic' | 'judge' | 'none';
25
27
  export type Provenance = 'measured' | 'estimated';
26
28
  export type TierChosen = 'lite' | 'medium' | 'high';
@@ -28,6 +30,8 @@ export type TierSource = 'static' | 'inferred' | 'inherit';
28
30
  export type Band = 'low' | 'medium' | 'high';
29
31
  export type LinePhase = 'refine' | 'memory' | 'analyze' | 'plan' | 'implement' | 'test' | 'verify' | 'report';
30
32
  export type LineOutcome = 'success' | 'blocked' | 'skipped' | 'error';
33
+ /** Which capsule-emission trigger arm fired first (Phase 1 shadow comparison). */
34
+ export type TriggerArm = 'watermark' | 'saturation' | 'tie';
31
35
  /** The orchestration form the form-gate selected (road-to-opt-subagent-harvest P2). */
32
36
  export type DispatchModeId =
33
37
  | 'do-and-judge'
@@ -96,6 +100,37 @@ export interface RecordInput {
96
100
  /** Rules the worker actually applied/cited (count of the envelope's
97
101
  * `rules_applied`-equivalent on the worker side). Counts only. */
98
102
  rules_used?: number | null | undefined;
103
+ // ── dispatch-economy additive fields (road-to-token-economy-dispatch
104
+ // Phase 1.1). `init_tokens` above (lean-init) is the spawn-payload
105
+ // half; `work_tokens` is the delta from first worker turn to envelope
106
+ // close. Both are counts; the pair's provenance rides
107
+ // `floor_provenance` exactly like `token_delta_provenance` tags
108
+ // `token_delta`. ──
109
+ /** Tokens the worker consumed AFTER init (delta to envelope close). */
110
+ work_tokens?: number | null | undefined;
111
+ /** Chars the dispatch RETURNED into the orchestrator context (serialized
112
+ * tool-result length on sync completions) — the Phase 6.3 detector for
113
+ * "isolation win refunded through the return channel". A count, never
114
+ * content. */
115
+ return_channel_chars?: number | null | undefined;
116
+ /** Provenance of the init/work pair: 'measured' (transcript ledger via
117
+ * cc_transcript) or 'estimated'. Defaults to 'estimated' when either
118
+ * field is present without a tag. */
119
+ floor_provenance?: Provenance | null | undefined;
120
+ // ── capsule shadow-measurement fields (road-to-worker-generation-recycling
121
+ // Phase 1.2). SHADOW ONLY: the worker still runs to stop-loss, nothing
122
+ // reads a capsule. All counts / enums — a capsule's CONTENT never
123
+ // reaches telemetry, only its shape. ──
124
+ /** A CHECKPOINT capsule was emitted at the watermark. */
125
+ capsule_emitted?: boolean | undefined;
126
+ /** Total entries across the capsule's arrays — a size proxy, never content. */
127
+ capsule_entries?: number | null | undefined;
128
+ /** 1-based step at which the token-watermark arm fired (null = never). */
129
+ watermark_step?: number | null | undefined;
130
+ /** 1-based step at which the novelty-saturation arm fired (null = never). */
131
+ saturation_step?: number | null | undefined;
132
+ /** Which arm fired first on this dispatch — the paired-comparison datum. */
133
+ trigger_arm_earlier?: TriggerArm | null | undefined;
99
134
  // Audit-log envelope (sensible defaults for a dispatch record)
100
135
  phase?: LinePhase | undefined;
101
136
  outcome?: LineOutcome | undefined;
@@ -122,7 +157,8 @@ const TIER_SOURCES: readonly TierSource[] = ['static', 'inferred', 'inherit'];
122
157
  const BANDS: readonly Band[] = ['low', 'medium', 'high'];
123
158
  const PHASES: readonly LinePhase[] = ['refine', 'memory', 'analyze', 'plan', 'implement', 'test', 'verify', 'report'];
124
159
  const LOOKUP_CLASSES: readonly LookupClass[] = ['definition', 'references', 'string-existence', 'report-run'];
125
- const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent'];
160
+ const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent', 'ask'];
161
+ const TRIGGER_ARMS: readonly TriggerArm[] = ['watermark', 'saturation', 'tie'];
126
162
  /** Hex-only payload hash — a hash can never smuggle content (privacy by construction). */
127
163
  const PAYLOAD_HASH_RE = /^[a-f0-9]{8,64}$/i;
128
164
  /** Id-shaped origin tag — enum-ish, never free-form prose. */
@@ -147,13 +183,15 @@ function isInt(n: unknown): n is number {
147
183
  export function buildOrchestrationLine(input: RecordInput): BuiltLine {
148
184
  const errors: string[] = [];
149
185
 
150
- // A lookup-class primitive route is the ONE recordable zero-spawn event
151
- // (lean-init L0): the routing decision itself is the datum the
152
- // cost-reduction claim reads. Everything else with spawn_count 0 stays
153
- // unrecordable in-session work.
154
- const zeroSpawnPrimitive = input.route_taken === 'primitive' && input.spawn_count === 0;
155
- if (!zeroSpawnPrimitive && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
156
- errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive lookup routes record with spawn_count 0)');
186
+ // A lookup-class primitive route (lean-init L0) and a rung-0.5 ask route
187
+ // (token-economy-dispatch Phase 4) are the TWO recordable zero-spawn
188
+ // events: the routing decision itself is the datum the cost-reduction
189
+ // claim reads. Everything else with spawn_count 0 stays unrecordable
190
+ // in-session work.
191
+ const zeroSpawnRoute =
192
+ (input.route_taken === 'primitive' || input.route_taken === 'ask') && input.spawn_count === 0;
193
+ if (!zeroSpawnRoute && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
194
+ errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive|ask routes record with spawn_count 0)');
157
195
  }
158
196
  if (!isInt(input.token_delta)) errors.push('token_delta must be an integer (negative = net saved vs the in-session baseline)');
159
197
  if (input.task_size_estimate !== undefined && (!isInt(input.task_size_estimate) || input.task_size_estimate < 0)) {
@@ -222,9 +260,14 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
222
260
  for (const [key, v] of [
223
261
  ['rules_carried', input.rules_carried],
224
262
  ['rules_used', input.rules_used],
263
+ ['work_tokens', input.work_tokens],
264
+ ['return_channel_chars', input.return_channel_chars],
225
265
  ] as const) {
226
266
  if (v != null && (!isInt(v) || v < 0)) errors.push(`${key} must be a non-negative integer count`);
227
267
  }
268
+ if (input.floor_provenance != null && !PROVENANCES.includes(input.floor_provenance)) {
269
+ errors.push(`floor_provenance must be one of ${PROVENANCES.join(' | ')} (or omitted)`);
270
+ }
228
271
  if (
229
272
  input.rules_carried != null &&
230
273
  input.rules_used != null &&
@@ -233,6 +276,20 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
233
276
  errors.push('rules_used cannot exceed rules_carried (a worker cannot apply a rule it was not given)');
234
277
  }
235
278
 
279
+ if (input.capsule_emitted !== undefined && typeof input.capsule_emitted !== 'boolean') {
280
+ errors.push('capsule_emitted must be a boolean or omitted');
281
+ }
282
+ for (const [key, v] of [
283
+ ['capsule_entries', input.capsule_entries],
284
+ ['watermark_step', input.watermark_step],
285
+ ['saturation_step', input.saturation_step],
286
+ ] as const) {
287
+ if (v != null && (!isInt(v) || v < 0)) errors.push(`${key} must be a non-negative integer count`);
288
+ }
289
+ if (input.trigger_arm_earlier != null && !TRIGGER_ARMS.includes(input.trigger_arm_earlier)) {
290
+ errors.push(`trigger_arm_earlier must be one of ${TRIGGER_ARMS.join(' | ')} (or omitted / null when neither arm fired)`);
291
+ }
292
+
236
293
  if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
237
294
  if (!input.id) errors.push('id (ULID or content hash) is required');
238
295
 
@@ -267,6 +324,17 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
267
324
  origin: input.origin ?? null,
268
325
  rules_carried: input.rules_carried ?? null,
269
326
  rules_used: input.rules_used ?? null,
327
+ // dispatch-economy additive fields — readers ignore unknowns per audit-log-v1
328
+ work_tokens: input.work_tokens ?? null,
329
+ return_channel_chars: input.return_channel_chars ?? null,
330
+ floor_provenance:
331
+ input.floor_provenance ?? (input.init_tokens != null || input.work_tokens != null ? 'estimated' : null),
332
+ // capsule shadow-measurement — readers ignore unknowns per audit-log-v1
333
+ capsule_emitted: input.capsule_emitted ?? null,
334
+ capsule_entries: input.capsule_entries ?? null,
335
+ watermark_step: input.watermark_step ?? null,
336
+ saturation_step: input.saturation_step ?? null,
337
+ trigger_arm_earlier: input.trigger_arm_earlier ?? null,
270
338
  };
271
339
 
272
340
  const line: Record<string, unknown> = {
@@ -212,7 +212,7 @@ export function aggregateOrchestrationSavings(
212
212
 
213
213
  // Honest caveats — always attached so a consumer never over-reads the number.
214
214
  if (report.dispatches === 0) {
215
- report.notes.push('No orchestration telemetry yet (0 dispatches). Data accrues as `subagents.auto: on` delegates real slices (recorded via orchestration_record).');
215
+ report.notes.push('No orchestration telemetry yet (0 dispatches). Data accrues as delegable slices dispatch (always-on orchestration; recorded via orchestration_record).');
216
216
  } else {
217
217
  report.notes.push('Token counts are MEASURED but blind to the downshift rate win (same tokens on a cheaper tier ≈ same count). The MODELED cost-% below captures that axis.');
218
218
  if (report.by_provenance.estimated.dispatches > 0) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Recycle-envelope filesystem contract (road-to-token-economy-recycling
3
+ * Phase 2.2/2.3) — the ONE place the producer (`session:recycle`) and the
4
+ * consumer (`handoff_context_hook`) agree on where the envelope lives and
5
+ * when it is stale. A second copy of any of these values is the drift seam
6
+ * Risk 6 warns about.
7
+ */
8
+ import * as path from 'node:path';
9
+
10
+ /** Where `session:recycle` writes the pending envelope (gitignored runtime state). */
11
+ export const RECYCLE_ENVELOPE_REL = path.join('agents', 'runtime', 'state', 'recycle-envelope.json');
12
+
13
+ /**
14
+ * Where the consumer MOVES the envelope on consumption — moved, not copied,
15
+ * so a consumed or rejected envelope can never leak into a later session,
16
+ * while the last one stays inspectable for debugging.
17
+ */
18
+ export const RECYCLE_CONSUMED_REL = path.join(
19
+ 'agents',
20
+ 'runtime',
21
+ 'state',
22
+ 'recycle-envelope.consumed.json',
23
+ );
24
+
25
+ /** Staleness guard — mirrors the handoff file's 48 h discipline. */
26
+ export const RECYCLE_MAX_AGE_HOURS = 48;
27
+
28
+ /**
29
+ * Producer-side size cap on the serialized envelope. The schema's per-field
30
+ * caps make a transcript-shaped envelope invalid; this cap keeps even a
31
+ * legal envelope inside the `handoff-context` injection budget
32
+ * (`src/config/hook-token-budget.json`: 8192 B for the concern) with room
33
+ * for the wrapper block and a co-pending handoff.
34
+ */
35
+ export const RECYCLE_ENVELOPE_MAX_BYTES = 6144;
@@ -28,6 +28,7 @@
28
28
  import { createHash } from 'node:crypto';
29
29
 
30
30
  import { redact_low_impact_entry } from '../ai_council/redact_low_impact_entry.js';
31
+ import { is_kernel_rule } from './kernel_rules.js';
31
32
 
32
33
  /** Where a record came from. */
33
34
  export type DefectSource = 'user-reported' | 'self-detected';
@@ -38,6 +39,18 @@ export type DefectClass =
38
39
  | 'council-availability-claim'
39
40
  | 'language-mirror';
40
41
 
42
+ /**
43
+ * Runtime mirror of `DefectClass`, in declaration order. The upstream issue
44
+ * form's dropdown (`.github/ISSUE_TEMPLATE/self_repair_report.yml`) is pinned
45
+ * against this list by test, so adding a detector class without extending the
46
+ * form is a red test, not silent drift.
47
+ */
48
+ export const DEFECT_CLASSES: readonly DefectClass[] = [
49
+ 'user-reported',
50
+ 'council-availability-claim',
51
+ 'language-mirror',
52
+ ];
53
+
41
54
  /** How far a record may travel outward. */
42
55
  export type EgressRoute = 'pull-request' | 'issue' | 'local-only';
43
56
 
@@ -56,6 +69,12 @@ export interface DefectRecord extends DefectFinding {
56
69
  last_seen: string;
57
70
  occurrences: number;
58
71
  status: 'open' | 'released';
72
+ /**
73
+ * Failed egress attempts from the most recent `release` call, sanitized at
74
+ * write time. Local-only bookkeeping: `renderReport` never includes them,
75
+ * so they cannot widen what leaves the machine.
76
+ */
77
+ release_errors?: string[];
59
78
  }
60
79
 
61
80
  /** One finished turn, as much of it as a hook can observe. */
@@ -110,12 +129,208 @@ export function egressBlockedReason(record: DefectRecord, repoRoot?: string | nu
110
129
  return `privacy floor refused the evidence (${kinds}) — record stays local`;
111
130
  }
112
131
 
132
+ // ── may-not-modify deny-list ───────────────────────────────────────
133
+
134
+ /**
135
+ * Surfaces self-repair may never target with a PATCH. Detection and reporting
136
+ * stay untouched — for a record targeting one of these, egress degrades to
137
+ * report-only: an issue (or the local record) is still allowed, the
138
+ * pull-request path refuses with the named reason.
139
+ *
140
+ * The point is structural: the loop that fixes the agent's own defects must
141
+ * not be a channel through which the agent loosens the floors it is bound by.
142
+ * Same posture as `block_kernel_rule_writes` (tool-call-time) and the settings
143
+ * class-C fence (write-time), applied to the one outward vehicle this loop
144
+ * owns. Nothing auto-applies a patch today; this keeps that true for the
145
+ * denied surfaces even when the PR path is otherwise available.
146
+ */
147
+ export type DeniedSurface =
148
+ | 'kernel-rule'
149
+ | 'safety-floor-rule'
150
+ | 'settings-class-c'
151
+ | 'ci-enforcement'
152
+ | 'self-repair-policy';
153
+
154
+ export interface PatchDenial {
155
+ surface: DeniedSurface;
156
+ /** The token in the record that matched. */
157
+ match: string;
158
+ }
159
+
160
+ /**
161
+ * Characters that decorate a token but never appear inside a real path, rule
162
+ * id, or settings key: markdown emphasis / strikethrough, autolink angle
163
+ * brackets, table pipes. They are removed ANYWHERE in the token, not just at
164
+ * its edges, so `src/rules/**scope-control**.md` reduces like `**…**` does.
165
+ */
166
+ const DECORATION_RE = /[*~<>|]/g;
167
+
168
+ /**
169
+ * Edge punctuation a sentence leaves on a token — sentence-final `.`, a `:`
170
+ * label separator, list/reference brackets, and underscore emphasis (`_` is
171
+ * stripped at the EDGES only: it is load-bearing inside `self_repair.ts`,
172
+ * `hook_manifest.yaml`, and every snake_case settings key).
173
+ */
174
+ const EDGE_TRIM_RE = /^[.:[\]_]+|[.:[\]_]+$/g;
175
+
176
+ /**
177
+ * Word-ish / path-ish tokens of a record's text, punctuation-stripped.
178
+ *
179
+ * `suggested_surface` is free-text the agent writes, and this repo's prose is
180
+ * saturated with `**bold**` file paths — so a tokenizer that only splits on
181
+ * whitespace-and-quotes lets ordinary markdown emphasis smuggle a denied
182
+ * surface past the scan (`**src/rules/scope-control.md**` read as ALLOWED
183
+ * while the same path bare read as DENIED). Both the raw token and its
184
+ * un-decorated form are emitted, and the edge trim runs AFTER the decoration
185
+ * strip so an unwrapped `**.github/workflows/ci.yml**` cannot keep a leading
186
+ * dot in front of the anchored `github/workflows/` match.
187
+ */
188
+ function denyTokens(text: string): string[] {
189
+ const out: string[] = [];
190
+ for (const raw of text.split(/[\s,;()!?"'`]+/)) {
191
+ if (raw.length === 0) {
192
+ continue;
193
+ }
194
+ for (const candidate of new Set([raw, raw.replace(DECORATION_RE, '')])) {
195
+ const token = candidate.replace(EDGE_TRIM_RE, '');
196
+ if (token.length > 0) {
197
+ out.push(token);
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /** Basename stem (no `.md`) of a path-or-id token. */
205
+ function stemOf(token: string): string {
206
+ const base = token.replace(/\\/g, '/').split('/').pop() ?? token;
207
+ return base.replace(/\.md$/, '');
208
+ }
209
+
210
+ /** Dotted settings-key shape — `personal.autonomy`, `tokens.rich_skills`, … */
211
+ const DOTTED_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/;
212
+
213
+ /**
214
+ * The surfaces through which enforcement is actually armed or disarmed. A
215
+ * workflow file is only ONE of them, and in this repo it is not even the usual
216
+ * one — a new gate is registered in the taskfiles the workflow calls, and a
217
+ * tool-call-time guard is armed in the hook manifest. Each entry, and why it
218
+ * arms enforcement:
219
+ *
220
+ * - `.github/workflows/` — the CI job definitions; deleting a step removes
221
+ * the gate from every PR. (The leading dot is stripped by `denyTokens`, so
222
+ * the match is on the segment pair, not the dotfile spelling.)
223
+ * - `Taskfile.yml` / `taskfiles/*.yml` — the task graph the workflows invoke
224
+ * (`task ci`, `task ci-fast`). A gate that is not listed here does not run,
225
+ * however green the workflow looks.
226
+ * - `src/scripts/hook_manifest.yaml` — the concern↔slot bindings. Unbinding
227
+ * a concern disarms a PreToolUse guard without touching the guard's code.
228
+ * - `src/scripts/hooks/` — the guard implementations themselves
229
+ * (`block_no_verify`, `block_kernel_rule_writes`, `block_config_weakening`).
230
+ * - `src/scripts/check_*.ts` / `src/scripts/lint_*.ts` — the gate scripts. A
231
+ * loosened threshold or a narrowed scan root disarms the gate in place,
232
+ * which is exactly the "gates that scan nothing exit green" failure.
233
+ *
234
+ * Deliberately NOT the whole of `src/scripts/`: that would deny nearly every
235
+ * legitimate repair this loop exists to file.
236
+ */
237
+ function isCiEnforcementPath(token: string): boolean {
238
+ const t = token.replace(/\\/g, '/');
239
+ if (/(^|\/)github\/workflows\//.test(t)) {
240
+ return true;
241
+ }
242
+ if (/(^|\/)Taskfile(\.[a-z0-9_-]+)?\.ya?ml$/i.test(t)) {
243
+ return true;
244
+ }
245
+ if (/(^|\/)taskfiles\//.test(t)) {
246
+ return true;
247
+ }
248
+ if (/(^|\/)hook_manifest\.ya?ml$/.test(t)) {
249
+ return true;
250
+ }
251
+ if (/(^|\/)scripts\/hooks\//.test(t)) {
252
+ return true;
253
+ }
254
+ return /(^|\/)scripts\/(check|lint)_[a-z0-9_]+\.ts$/.test(t);
255
+ }
256
+
257
+ /**
258
+ * Is this record's fix aimed at a denied surface? Scans the two fields that
259
+ * name what the fix should change (`suggested_surface`) and what was observed
260
+ * (`evidence`). A match errs toward denial on purpose — the cost of a false
261
+ * positive is a report instead of a patch, still egress; the cost of a false
262
+ * negative is a patch channel into a safety floor.
263
+ *
264
+ * The kernel set resolves from `kernel_rules.ts` — the same canonical source
265
+ * `block_kernel_rule_writes` uses — never a copy that can drift. Settings
266
+ * class-C keys are matched against `classCKeys` when the caller can supply
267
+ * them (parsed from `docs/contracts/settings-classes.md`); the contract file
268
+ * itself is denied by path regardless, so an unreadable contract narrows the
269
+ * dynamic check without opening the fence's own definition to patching.
270
+ */
271
+ export function patchDeniedSurface(
272
+ record: DefectFinding,
273
+ opts?: { classCKeys?: ReadonlySet<string> | null },
274
+ ): PatchDenial | null {
275
+ const classCKeys = opts?.classCKeys ?? null;
276
+ for (const token of denyTokens(`${record.suggested_surface}\n${record.evidence}`)) {
277
+ // Checked before `ci-enforcement` so a self-repair file that also lives
278
+ // under an enforcement path (`src/scripts/hooks/self_repair_hook.ts`)
279
+ // reports the more specific surface. Either way it is denied.
280
+ if (/self[-_]repair/i.test(token)) {
281
+ return { surface: 'self-repair-policy', match: token };
282
+ }
283
+ if (isCiEnforcementPath(token)) {
284
+ return { surface: 'ci-enforcement', match: token };
285
+ }
286
+ if (token.includes('settings-classes.md')) {
287
+ return { surface: 'settings-class-c', match: token };
288
+ }
289
+ if (is_kernel_rule(token)) {
290
+ return { surface: 'kernel-rule', match: token };
291
+ }
292
+ if (stemOf(token).endsWith('-safety-floor')) {
293
+ return { surface: 'safety-floor-rule', match: token };
294
+ }
295
+ if (classCKeys !== null && DOTTED_KEY_RE.test(token)) {
296
+ // Exact key first, then every classified ancestor down to the root
297
+ // segment — a class-C key whose value is a MAP has children that
298
+ // never appear under the key's own name.
299
+ const parts = token.split('.');
300
+ for (let end = parts.length; end > 0; end--) {
301
+ if (classCKeys.has(parts.slice(0, end).join('.'))) {
302
+ return { surface: 'settings-class-c', match: token };
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** The named refusal the patch/PR path reports. Null = patch path allowed. */
311
+ export function patchDeniedReason(
312
+ record: DefectFinding,
313
+ opts?: { classCKeys?: ReadonlySet<string> | null },
314
+ ): string | null {
315
+ const denial = patchDeniedSurface(record, opts);
316
+ if (denial === null) {
317
+ return null;
318
+ }
319
+ return (
320
+ `self-repair may not patch a ${denial.surface} surface (matched: ${denial.match}) — ` +
321
+ 'the pull-request path is refused; the record egresses report-only (issue / local record)'
322
+ );
323
+ }
324
+
113
325
  // ── detectors ──────────────────────────────────────────────────────
114
326
 
115
327
  // An agent-directed complaint, not a complaint about the code. Deliberately
116
328
  // narrow: a spurious record becomes a spurious PR, so precision beats recall.
117
329
  const COMPLAINT_PATTERNS: readonly RegExp[] = [
118
- /\bdu hast\b[^.!?]{0,60}\b(nicht|falsch|vergessen|ignoriert|übersehen)\b/i,
330
+ // No \b before the alternation: JS \b is ASCII-only, so a leading umlaut
331
+ // ("übersehen") never sits on a word boundary — the corpus gate's
332
+ // near-miss class caught that as a dead alternation branch.
333
+ /\bdu hast\b[^.!?]{0,60}(nicht|falsch|vergessen|ignoriert|übersehen)\b/i,
119
334
  /\bdu (ignorierst|übersiehst)\b/i,
120
335
  /\b(das|es) (war|ist) (aber )?falsch\b/i,
121
336
  /\bhat nicht (richtig|korrekt) (gearbeitet|funktioniert)\b/i,
@@ -125,11 +340,53 @@ const COMPLAINT_PATTERNS: readonly RegExp[] = [
125
340
  /\byou (worked|did (it|that)) (wrong|incorrectly)\b/i,
126
341
  ];
127
342
 
343
+ // Phrasings that MATCH a complaint pattern but are not complaints — a curious
344
+ // question or a reassurance. Scoped to the matched span (± a small margin),
345
+ // never the whole prompt: a pleasantry in one clause must not mute a genuine
346
+ // complaint in another (R2 finding #2). These feed the must-not-fire class of
347
+ // the detector corpus gate.
348
+ const COMPLAINT_EXONERATIONS: readonly RegExp[] = [
349
+ /\bdu hast nicht zufällig\b/i,
350
+ /\bhast du nicht zufällig\b/i,
351
+ /\byou didn'?t (need|have) to\b/i,
352
+ /\bit'?s fine\b/i,
353
+ /\b(kein problem|passt schon|alles gut)\b/i,
354
+ // Explicit absolution INSIDE the complaint span, which is why it has to be
355
+ // its own entry rather than relying on a neighbouring pleasantry: in
356
+ // "das ist fine, du hast nichts falsch gemacht" the matched span is
357
+ // `du hast nichts falsch`, and the `it's fine` above sits outside the
358
+ // ± EXONERATION_MARGIN window — correctly, since that margin exists to stop
359
+ // a pleasantry in one clause muting a complaint in another. `nichts falsch`
360
+ // is not a neighbouring pleasantry: it is the negation of the complaint
361
+ // itself. Caught by the detector-corpus gate, which flagged this exact
362
+ // string as a must-not-fire fixture that fired (PR #1231).
363
+ /\bnichts falsch\b/i,
364
+ /\bkein fehler\b/i,
365
+ ];
366
+
367
+ /** How far (chars) an exoneration may sit from the complaint span it excuses. */
368
+ const EXONERATION_MARGIN = 12;
369
+
370
+ function spanExonerated(prompt: string, start: number, end: number): boolean {
371
+ for (const ex of COMPLAINT_EXONERATIONS) {
372
+ const g = new RegExp(ex.source, ex.flags.includes('g') ? ex.flags : `${ex.flags}g`);
373
+ let m: RegExpExecArray | null;
374
+ while ((m = g.exec(prompt)) !== null) {
375
+ const s = m.index;
376
+ const e = m.index + m[0].length;
377
+ if (e >= start - EXONERATION_MARGIN && s <= end + EXONERATION_MARGIN) {
378
+ return true;
379
+ }
380
+ }
381
+ }
382
+ return false;
383
+ }
384
+
128
385
  /** Intake path 1 — the user says the agent worked wrongly. */
129
386
  export function detectUserReport(prompt: string): DefectFinding | null {
130
387
  for (const re of COMPLAINT_PATTERNS) {
131
388
  const m = re.exec(prompt);
132
- if (m !== null) {
389
+ if (m !== null && !spanExonerated(prompt, m.index, m.index + m[0].length)) {
133
390
  return {
134
391
  defect_class: 'user-reported',
135
392
  source: 'user-reported',
@@ -222,11 +479,38 @@ export function detectLanguageMirror(turn: TurnSnapshot): DefectFinding | null {
222
479
  };
223
480
  }
224
481
 
482
+ /** One registered detector — the unit the corpus gate enumerates. */
483
+ export interface RegisteredDetector {
484
+ defect_class: DefectClass;
485
+ source: DefectSource;
486
+ detect: (turn: TurnSnapshot) => DefectFinding | null;
487
+ }
488
+
489
+ /**
490
+ * The detector registry — the single list both `runDetectors` and the corpus
491
+ * gate (`tests/scripts/self_repair_detector_corpus.test.ts`) iterate. A
492
+ * detector added here without its three fixture classes (fire /
493
+ * near-miss-fire / must-not-fire) is a red test, not a silently untested
494
+ * code path.
495
+ */
496
+ export const DETECTOR_REGISTRY: readonly RegisteredDetector[] = [
497
+ {
498
+ defect_class: 'user-reported',
499
+ source: 'user-reported',
500
+ detect: (turn) => detectUserReport(turn.prompt),
501
+ },
502
+ { defect_class: 'council-availability-claim', source: 'self-detected', detect: detectCouncilClaim },
503
+ { defect_class: 'language-mirror', source: 'self-detected', detect: detectLanguageMirror },
504
+ ];
505
+
225
506
  /** Run every self-detected detector over a finished turn. */
226
507
  export function runDetectors(turn: TurnSnapshot): DefectFinding[] {
227
508
  const out: DefectFinding[] = [];
228
- for (const d of [detectCouncilClaim, detectLanguageMirror]) {
229
- const f = d(turn);
509
+ for (const d of DETECTOR_REGISTRY) {
510
+ if (d.source !== 'self-detected') {
511
+ continue;
512
+ }
513
+ const f = d.detect(turn);
230
514
  if (f !== null) {
231
515
  out.push(f);
232
516
  }
@@ -286,23 +570,43 @@ export interface EgressCapability {
286
570
  hasAgentConfigCheckout: boolean;
287
571
  /** `gh` is installed AND authenticated. */
288
572
  ghAuthenticated: boolean;
289
- /** The checkout can push a branch to the remote. */
290
- canPush: boolean;
573
+ /**
574
+ * The authenticated user has push permission on the UPSTREAM repo — a
575
+ * pre-flight probe of actual rights (`gh api repos/<repo>` →
576
+ * `.permissions.push`), never an inference from `git remote` existing.
577
+ * A consumer who cloned the public repo has a remote and no write access;
578
+ * the old heuristic classified exactly that machine as push-capable.
579
+ */
580
+ canPushUpstream: boolean;
581
+ /** The upstream repo allows forking and the user is authenticated. */
582
+ canFork: boolean;
583
+ }
584
+
585
+ /** Where a pull-request route pushes its branch. */
586
+ export type PushVia = 'upstream' | 'fork';
587
+
588
+ export interface EgressChoice {
589
+ route: EgressRoute;
590
+ /** Set only when `route === 'pull-request'`. */
591
+ pushVia: PushVia | null;
291
592
  }
292
593
 
293
594
  /**
294
595
  * Route selection, exactly as specified: a pull request when the fix can be
295
- * authored and pushed, an issue when it cannot, and a local record when
296
- * nothing can leave the machine.
596
+ * authored and pushed (directly, or via fork → cross-repo PR), an issue when
597
+ * it cannot, and a local record when nothing can leave the machine.
297
598
  */
298
- export function chooseEgressRoute(cap: EgressCapability): EgressRoute {
299
- if (cap.hasAgentConfigCheckout && cap.ghAuthenticated && cap.canPush) {
300
- return 'pull-request';
599
+ export function chooseEgress(cap: EgressCapability): EgressChoice {
600
+ if (cap.hasAgentConfigCheckout && cap.ghAuthenticated && cap.canPushUpstream) {
601
+ return { route: 'pull-request', pushVia: 'upstream' };
602
+ }
603
+ if (cap.hasAgentConfigCheckout && cap.ghAuthenticated && cap.canFork) {
604
+ return { route: 'pull-request', pushVia: 'fork' };
301
605
  }
302
606
  if (cap.ghAuthenticated) {
303
- return 'issue';
607
+ return { route: 'issue', pushVia: null };
304
608
  }
305
- return 'local-only';
609
+ return { route: 'local-only', pushVia: null };
306
610
  }
307
611
 
308
612
  /** The PR / issue body. Deterministic — same record, same bytes. */
@@ -340,3 +644,40 @@ export function renderReport(record: DefectRecord, route: EgressRoute): string {
340
644
  'construction — the record type has no field that can carry them.',
341
645
  ].join('\n');
342
646
  }
647
+
648
+ /** The structured fields the upstream intake form collects. */
649
+ export interface ParsedReport {
650
+ defect_class: string;
651
+ fingerprint: string;
652
+ occurrences: number;
653
+ evidence: string;
654
+ suggested_surface: string;
655
+ }
656
+
657
+ /**
658
+ * Inverse of `renderReport` for the structured fields — the round-trip that
659
+ * pins the rendered body to the upstream issue form: every field the form
660
+ * collects is recoverable from a rendered report, so a `release` body and a
661
+ * manually-filed form carry the same clustering keys (fingerprint above all).
662
+ */
663
+ export function parseReport(body: string): ParsedReport | null {
664
+ const cls = /^## Self-repair report — `([^`]+)`$/m.exec(body)?.[1];
665
+ const fp = /^- \*\*Fingerprint:\*\* `([^`]+)`$/m.exec(body)?.[1];
666
+ const seen = /^- \*\*Seen:\*\* (.+)$/m.exec(body)?.[1];
667
+ const evidence = /^### Evidence\n\n> (.*)$/m.exec(body)?.[1];
668
+ const surface = /### What the fix should address\n\n([\s\S]*?)\n\n---/.exec(body)?.[1];
669
+ if (!cls || !fp || !seen || evidence === undefined || !surface) {
670
+ return null;
671
+ }
672
+ const occurrences = seen.startsWith('once') ? 1 : Number(/^(\d+)×/.exec(seen)?.[1] ?? Number.NaN);
673
+ if (!Number.isFinite(occurrences)) {
674
+ return null;
675
+ }
676
+ return {
677
+ defect_class: cls,
678
+ fingerprint: fp,
679
+ occurrences,
680
+ evidence,
681
+ suggested_surface: surface.trim(),
682
+ };
683
+ }
@@ -94,7 +94,35 @@ export function markReleased(root: string, fp: string, now: string): DefectRecor
94
94
  if (rec === null) {
95
95
  return null;
96
96
  }
97
+ // A successful release supersedes any earlier failed attempts.
97
98
  const next: DefectRecord = { ...rec, status: 'released', last_seen: now };
99
+ delete next.release_errors;
100
+ writeRecord(root, next);
101
+ return next;
102
+ }
103
+
104
+ /**
105
+ * A release attempt exhausted the whole egress ladder: keep the record open
106
+ * and attach every failed step so the next `self-repair:status` shows what
107
+ * went wrong. Errors arrive pre-sanitized (the CLI runs them through
108
+ * `sanitizeEvidence` — command output can carry local paths).
109
+ */
110
+ export function attachReleaseErrors(
111
+ root: string,
112
+ fp: string,
113
+ errors: string[],
114
+ now: string,
115
+ ): DefectRecord | null {
116
+ const rec = readRecord(root, fp);
117
+ if (rec === null) {
118
+ return null;
119
+ }
120
+ const next: DefectRecord = {
121
+ ...rec,
122
+ status: 'open',
123
+ last_seen: now,
124
+ release_errors: errors,
125
+ };
98
126
  writeRecord(root, next);
99
127
  return next;
100
128
  }