@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
@@ -12,9 +12,14 @@
12
12
  * instruction lifts that floor. Running this command IS the user's word.
13
13
  *
14
14
  * Route selection follows the spec exactly: a pull request when the fix can be
15
- * authored and pushed, an issue when it cannot, and a local record when nothing
16
- * can leave the machine. `--dry-run` prints the resolved route and the rendered
17
- * body and touches no network — that is the path the tests drive.
15
+ * authored and pushed — directly when the pre-flight probe shows upstream push
16
+ * rights, via fork → cross-repo PR when it does not — an issue when it cannot,
17
+ * and a local record when nothing can leave the machine. Once publishing was
18
+ * authorized, failures degrade down the ladder WITHIN the same call (push/PR
19
+ * failure or 30 s timeout → issue; issue failure → the record stays open with
20
+ * the errors attached) — degrading never widens what leaves the machine, only
21
+ * the vehicle. `--dry-run` prints the resolved route and the rendered body and
22
+ * touches no network — that is the path the tests drive.
18
23
  *
19
24
  * Fail-closed on privacy: the record's own text goes through the audited
20
25
  * privacy floor before anything leaves the machine, and a refusal downgrades
@@ -26,17 +31,34 @@ import * as path from 'node:path';
26
31
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
32
 
28
33
  import {
29
- chooseEgressRoute,
34
+ chooseEgress,
30
35
  type DefectRecord,
31
36
  type EgressCapability,
37
+ type EgressChoice,
32
38
  type EgressRoute,
33
39
  egressBlockedReason,
40
+ patchDeniedReason,
41
+ type PushVia,
34
42
  renderReport,
43
+ sanitizeEvidence,
35
44
  } from './_lib/self_repair.js';
36
- import { listRecords, markReleased, openRecords, readRecord } from './_lib/self_repair_store.js';
45
+ import { buildSettingsClassIndex, parseSettingsClassRows } from '../shared/settingsClasses.js';
46
+ import {
47
+ attachReleaseErrors,
48
+ listRecords,
49
+ markReleased,
50
+ openRecords,
51
+ readRecord,
52
+ } from './_lib/self_repair_store.js';
37
53
 
38
54
  export const UPSTREAM_REPO = 'event4u-app/agent-config';
39
55
 
56
+ /**
57
+ * Per-step ceiling for every network-touching egress step. A hung `git push`
58
+ * or `gh` call degrades down the ladder instead of hanging the release.
59
+ */
60
+ export const EGRESS_STEP_TIMEOUT_MS = 30_000;
61
+
40
62
  const EXIT_OK = 0;
41
63
  const EXIT_FAIL = 1;
42
64
 
@@ -44,12 +66,30 @@ const EXIT_FAIL = 1;
44
66
  export interface Probe {
45
67
  agentConfigCheckout: string | null;
46
68
  ghAuthenticated: boolean;
47
- canPush: boolean;
69
+ canPushUpstream: boolean;
70
+ canFork: boolean;
48
71
  }
49
72
 
50
- function run(cmd: string, args: string[], cwd?: string): { ok: boolean; out: string } {
51
- const r = spawnSync(cmd, args, { cwd, encoding: 'utf-8' });
52
- return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
73
+ export interface RunResult {
74
+ ok: boolean;
75
+ out: string;
76
+ timedOut: boolean;
77
+ }
78
+
79
+ /** Injectable command runner — tests script it, production shells out. */
80
+ export type Runner = (cmd: string, args: string[], cwd?: string) => RunResult;
81
+
82
+ function run(cmd: string, args: string[], cwd?: string): RunResult {
83
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf-8', timeout: EGRESS_STEP_TIMEOUT_MS });
84
+ // Only spawnSync's own ETIMEDOUT counts: a child killed externally by
85
+ // SIGTERM is a failure, not a timeout, and recording it as "timed out"
86
+ // points the next debugging session at the wrong cause (R2 finding #3).
87
+ const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT';
88
+ return {
89
+ ok: r.status === 0 && !timedOut,
90
+ out: `${r.stdout ?? ''}${r.stderr ?? ''}`,
91
+ timedOut,
92
+ };
53
93
  }
54
94
 
55
95
  /**
@@ -71,18 +111,63 @@ export function findAgentConfigCheckout(env: NodeJS.ProcessEnv, cwd: string): st
71
111
  return null;
72
112
  }
73
113
 
74
- export function probeMachine(env: NodeJS.ProcessEnv, cwd: string): Probe {
114
+ /**
115
+ * Pre-flight probe of ACTUAL push rights, not remote existence. `git remote`
116
+ * returning a name proves only that the repo was cloned — every consumer of
117
+ * the public repo has that and no write access. `gh api repos/<repo>` returns
118
+ * the authenticated viewer's `permissions.push` and the repo's `allow_forking`,
119
+ * which are the two facts the route actually depends on. An unreachable or
120
+ * unauthenticated API answers `false` for both — the ladder then degrades to
121
+ * an issue rather than failing at push time.
122
+ */
123
+ export function probeUpstreamRights(
124
+ runner: Runner,
125
+ repo: string,
126
+ ): { canPushUpstream: boolean; canFork: boolean } {
127
+ const r = runner('gh', ['api', `repos/${repo}`]);
128
+ if (!r.ok) {
129
+ return { canPushUpstream: false, canFork: false };
130
+ }
131
+ try {
132
+ const parsed: unknown = JSON.parse(r.out);
133
+ const meta = parsed as {
134
+ permissions?: { push?: boolean };
135
+ allow_forking?: boolean;
136
+ };
137
+ return {
138
+ canPushUpstream: meta.permissions?.push === true,
139
+ canFork: meta.allow_forking !== false,
140
+ };
141
+ } catch {
142
+ return { canPushUpstream: false, canFork: false };
143
+ }
144
+ }
145
+
146
+ export function probeMachine(
147
+ env: NodeJS.ProcessEnv,
148
+ cwd: string,
149
+ runner: Runner = run,
150
+ repo: string = UPSTREAM_REPO,
151
+ ): Probe {
75
152
  const checkout = findAgentConfigCheckout(env, cwd);
76
- const gh = run('gh', ['auth', 'status']);
77
- const canPush = checkout !== null && run('git', ['remote'], checkout).out.trim().length > 0;
78
- return { agentConfigCheckout: checkout, ghAuthenticated: gh.ok, canPush };
153
+ const gh = runner('gh', ['auth', 'status']);
154
+ const rights = gh.ok
155
+ ? probeUpstreamRights(runner, repo)
156
+ : { canPushUpstream: false, canFork: false };
157
+ return {
158
+ agentConfigCheckout: checkout,
159
+ ghAuthenticated: gh.ok,
160
+ canPushUpstream: rights.canPushUpstream,
161
+ canFork: gh.ok && rights.canFork,
162
+ };
79
163
  }
80
164
 
81
165
  export function capabilityOf(probe: Probe): EgressCapability {
82
166
  return {
83
167
  hasAgentConfigCheckout: probe.agentConfigCheckout !== null,
84
168
  ghAuthenticated: probe.ghAuthenticated,
85
- canPush: probe.canPush,
169
+ canPushUpstream: probe.canPushUpstream,
170
+ canFork: probe.canFork,
86
171
  };
87
172
  }
88
173
 
@@ -93,17 +178,226 @@ export function titleFor(record: DefectRecord): string {
93
178
 
94
179
  export interface ReleasePlan {
95
180
  route: EgressRoute;
181
+ pushVia: PushVia | null;
96
182
  blocked: string | null;
183
+ /**
184
+ * Non-null when the record targets a may-not-modify surface (kernel rules,
185
+ * safety floors, settings class C, CI enforcement, self-repair's own
186
+ * policy/code): the patch/PR path is refused with this reason and the
187
+ * route degrades to report-only. Detection and the issue path stay open.
188
+ */
189
+ patch_denied: string | null;
97
190
  title: string;
98
191
  body: string;
99
192
  }
100
193
 
194
+ /**
195
+ * Class-C settings keys from the contract in the agent-config checkout, for
196
+ * the deny-list's dynamic key match. `null` when there is no checkout or the
197
+ * contract is unreadable — the deny-list's static path matches still apply.
198
+ */
199
+ export function readClassCKeys(checkout: string | null): ReadonlySet<string> | null {
200
+ if (checkout === null) {
201
+ return null;
202
+ }
203
+ try {
204
+ const md = fs.readFileSync(
205
+ path.join(checkout, 'docs', 'contracts', 'settings-classes.md'),
206
+ 'utf-8',
207
+ );
208
+ const index = buildSettingsClassIndex(parseSettingsClassRows(md));
209
+ return new Set([...index].filter(([, cls]) => cls === 'C').map(([key]) => key));
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
101
215
  /** Pure: everything `release` decides before it touches the network. */
102
- export function planRelease(record: DefectRecord, probe: Probe, repoRoot: string): ReleasePlan {
216
+ export function planRelease(
217
+ record: DefectRecord,
218
+ probe: Probe,
219
+ repoRoot: string,
220
+ opts?: { classCKeys?: ReadonlySet<string> | null },
221
+ ): ReleasePlan {
103
222
  const blocked = egressBlockedReason(record, repoRoot);
104
- const route: EgressRoute =
105
- blocked !== null ? 'local-only' : chooseEgressRoute(capabilityOf(probe));
106
- return { route, blocked, title: titleFor(record), body: renderReport(record, route) };
223
+ const patch_denied = blocked === null ? patchDeniedReason(record, opts) : null;
224
+ let choice: EgressChoice =
225
+ blocked !== null
226
+ ? { route: 'local-only' as const, pushVia: null }
227
+ : chooseEgress(capabilityOf(probe));
228
+ if (patch_denied !== null && choice.route === 'pull-request') {
229
+ // chooseEgress only returns pull-request when gh is authenticated, so
230
+ // report-only degrades to an issue, never to a dropped record.
231
+ choice = { route: 'issue', pushVia: null };
232
+ }
233
+ return {
234
+ route: choice.route,
235
+ pushVia: choice.pushVia,
236
+ blocked,
237
+ patch_denied,
238
+ title: titleFor(record),
239
+ body: renderReport(record, choice.route),
240
+ };
241
+ }
242
+
243
+ // ── egress ladder execution ────────────────────────────────────────
244
+
245
+ /** One egress step's outcome — kept for the record when the ladder exhausts. */
246
+ export interface EgressAttempt {
247
+ step: string;
248
+ ok: boolean;
249
+ detail: string;
250
+ }
251
+
252
+ export interface ReleaseOutcome {
253
+ /** What actually got published, or null when the whole ladder failed. */
254
+ published: 'pull-request' | 'issue' | null;
255
+ attempts: EgressAttempt[];
256
+ }
257
+
258
+ export interface ReleaseDeps {
259
+ runner: Runner;
260
+ now: () => string;
261
+ }
262
+
263
+ const FORK_REMOTE = 'self-repair-fork';
264
+
265
+ function failDetail(step: string, r: RunResult): string {
266
+ return r.timedOut
267
+ ? `${step}: timed out after ${EGRESS_STEP_TIMEOUT_MS / 1000}s`
268
+ : `${step}: ${r.out.trim() || 'failed'}`;
269
+ }
270
+
271
+ /**
272
+ * Push the fix branch — directly, or via fork → cross-repo PR — then open the
273
+ * PR. Returns null on success; on any step failure or timeout returns the
274
+ * failed attempt so the caller degrades down the ladder.
275
+ */
276
+ function tryPullRequest(
277
+ plan: ReleasePlan,
278
+ probe: Probe,
279
+ repo: string,
280
+ branch: string,
281
+ via: PushVia,
282
+ deps: ReleaseDeps,
283
+ ): EgressAttempt | null {
284
+ const checkout = probe.agentConfigCheckout!;
285
+ let prHead: string | null = null;
286
+
287
+ if (via === 'upstream') {
288
+ const pushed = deps.runner('git', ['push', '-u', 'origin', branch], checkout);
289
+ if (!pushed.ok) {
290
+ return { step: 'push-upstream', ok: false, detail: failDetail('push-upstream', pushed) };
291
+ }
292
+ } else {
293
+ const forked = deps.runner('gh', ['repo', 'fork', repo, '--clone=false']);
294
+ if (!forked.ok) {
295
+ return { step: 'fork-ensure', ok: false, detail: failDetail('fork-ensure', forked) };
296
+ }
297
+ const who = deps.runner('gh', ['api', 'user', '--jq', '.login']);
298
+ if (!who.ok || who.out.trim().length === 0) {
299
+ return { step: 'whoami', ok: false, detail: failDetail('whoami', who) };
300
+ }
301
+ const login = who.out.trim();
302
+ const repoName = repo.split('/')[1] ?? repo;
303
+ const hasRemote = deps.runner('git', ['remote', 'get-url', FORK_REMOTE], checkout);
304
+ if (!hasRemote.ok) {
305
+ const added = deps.runner(
306
+ 'git',
307
+ ['remote', 'add', FORK_REMOTE, `https://github.com/${login}/${repoName}.git`],
308
+ checkout,
309
+ );
310
+ if (!added.ok) {
311
+ return { step: 'fork-remote', ok: false, detail: failDetail('fork-remote', added) };
312
+ }
313
+ }
314
+ const pushed = deps.runner('git', ['push', '-u', FORK_REMOTE, branch], checkout);
315
+ if (!pushed.ok) {
316
+ return { step: 'push-fork', ok: false, detail: failDetail('push-fork', pushed) };
317
+ }
318
+ prHead = `${login}:${branch}`;
319
+ }
320
+
321
+ const prArgs = ['pr', 'create', '--repo', repo, '--title', plan.title, '--body', plan.body];
322
+ if (prHead !== null) {
323
+ prArgs.push('--head', prHead);
324
+ }
325
+ const pr = deps.runner('gh', prArgs, checkout);
326
+ if (!pr.ok) {
327
+ return { step: 'pr-create', ok: false, detail: failDetail('pr-create', pr) };
328
+ }
329
+ process.stdout.write(`${pr.out}`);
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * The egress ladder, executed within ONE `release` call: direct push → fork
335
+ * push → issue → open-with-errors. Every rung is separately timed out; a rung
336
+ * that fails degrades to the next instead of aborting the release. When the
337
+ * whole ladder fails, the record stays `open` with every failed attempt
338
+ * attached (sanitized — command output can carry local paths).
339
+ */
340
+ export function executeRelease(
341
+ record: DefectRecord,
342
+ plan: ReleasePlan,
343
+ probe: Probe,
344
+ repo: string,
345
+ deps: ReleaseDeps,
346
+ ): ReleaseOutcome {
347
+ const attempts: EgressAttempt[] = [];
348
+
349
+ if (plan.route === 'pull-request') {
350
+ const checkout = probe.agentConfigCheckout!;
351
+ const branch = deps.runner('git', ['branch', '--show-current'], checkout).out.trim();
352
+ if (!branch || branch === 'main') {
353
+ // Precondition, not an egress failure: the authored fix exists and
354
+ // must ride its own branch — an issue instead would drop the fix.
355
+ attempts.push({
356
+ step: 'branch-check',
357
+ ok: false,
358
+ detail:
359
+ 'the fix must sit on its own branch in the agent-config checkout ' +
360
+ `(currently: ${branch || 'detached'})`,
361
+ });
362
+ return { published: null, attempts };
363
+ }
364
+ const ladder: PushVia[] =
365
+ plan.pushVia === 'upstream' && probe.canFork
366
+ ? ['upstream', 'fork']
367
+ : [plan.pushVia ?? 'upstream'];
368
+ for (const via of ladder) {
369
+ const failed = tryPullRequest(plan, probe, repo, branch, via, deps);
370
+ if (failed === null) {
371
+ return { published: 'pull-request', attempts };
372
+ }
373
+ attempts.push(failed);
374
+ }
375
+ }
376
+
377
+ if (plan.route === 'pull-request' || plan.route === 'issue') {
378
+ // Re-render for the rung actually taken: a plan that degraded from
379
+ // pull-request must not file an issue whose body claims
380
+ // `Route: pull-request` (R2 finding #4).
381
+ const issueBody =
382
+ plan.route === 'issue' ? plan.body : renderReport(record, 'issue');
383
+ const issue = deps.runner('gh', [
384
+ 'issue',
385
+ 'create',
386
+ '--repo',
387
+ repo,
388
+ '--title',
389
+ plan.title,
390
+ '--body',
391
+ issueBody,
392
+ ]);
393
+ if (issue.ok) {
394
+ process.stdout.write(`${issue.out}`);
395
+ return { published: 'issue', attempts };
396
+ }
397
+ attempts.push({ step: 'issue-create', ok: false, detail: failDetail('issue-create', issue) });
398
+ }
399
+
400
+ return { published: null, attempts };
107
401
  }
108
402
 
109
403
  function statusCmd(root: string): number {
@@ -140,12 +434,18 @@ function releaseCmd(root: string, argv: string[]): number {
140
434
  const repoArg = argv.indexOf('--repo');
141
435
  const repo = repoArg >= 0 ? (argv[repoArg + 1] ?? UPSTREAM_REPO) : UPSTREAM_REPO;
142
436
  const probe = probeMachine(process.env, process.cwd());
143
- const plan = planRelease(record, probe, root);
437
+ const plan = planRelease(record, probe, root, {
438
+ classCKeys: readClassCKeys(probe.agentConfigCheckout),
439
+ });
144
440
 
145
- process.stdout.write(`self-repair:release ${fp} -> route=${plan.route}\n`);
441
+ const via = plan.pushVia !== null ? ` via=${plan.pushVia}` : '';
442
+ process.stdout.write(`self-repair:release ${fp} -> route=${plan.route}${via}\n`);
146
443
  if (plan.blocked !== null) {
147
444
  process.stdout.write(` WARN ${plan.blocked}\n`);
148
445
  }
446
+ if (plan.patch_denied !== null) {
447
+ process.stdout.write(` DENY ${plan.patch_denied}\n`);
448
+ }
149
449
  if (dryRun) {
150
450
  process.stdout.write(`\n--- ${plan.title} ---\n${plan.body}\n`);
151
451
  return EXIT_OK;
@@ -156,47 +456,25 @@ function releaseCmd(root: string, argv: string[]): number {
156
456
  );
157
457
  return EXIT_OK;
158
458
  }
159
- if (plan.route === 'issue') {
160
- const r = run('gh', [
161
- 'issue',
162
- 'create',
163
- '--repo',
164
- repo,
165
- '--title',
166
- plan.title,
167
- '--body',
168
- plan.body,
169
- ]);
170
- process.stdout.write(`${r.out}\n`);
171
- if (!r.ok) {
172
- return EXIT_FAIL;
173
- }
174
- } else {
175
- const checkout = probe.agentConfigCheckout!;
176
- const branch = run('git', ['branch', '--show-current'], checkout).out.trim();
177
- if (!branch || branch === 'main') {
178
- process.stderr.write(
179
- ' The fix must sit on its own branch in the agent-config checkout ' +
180
- `(currently: ${branch || 'detached'}).\n`,
181
- );
182
- return EXIT_FAIL;
183
- }
184
- const pushed = run('git', ['push', '-u', 'origin', branch], checkout);
185
- if (!pushed.ok) {
186
- process.stderr.write(`${pushed.out}\n`);
187
- return EXIT_FAIL;
188
- }
189
- const pr = run(
190
- 'gh',
191
- ['pr', 'create', '--repo', repo, '--title', plan.title, '--body', plan.body],
192
- checkout,
459
+
460
+ const deps: ReleaseDeps = { runner: run, now: () => new Date().toISOString() };
461
+ const outcome = executeRelease(record, plan, probe, repo, deps);
462
+
463
+ for (const a of outcome.attempts) {
464
+ process.stderr.write(` FAIL ${a.detail}\n`);
465
+ }
466
+ if (outcome.published === null) {
467
+ const errors = outcome.attempts.map((a) => sanitizeEvidence(a.detail));
468
+ attachReleaseErrors(root, fp, errors, deps.now());
469
+ process.stderr.write(
470
+ ' The egress ladder is exhausted; the record stays open with the errors attached.\n',
193
471
  );
194
- process.stdout.write(`${pr.out}\n`);
195
- if (!pr.ok) {
196
- return EXIT_FAIL;
197
- }
472
+ return EXIT_FAIL;
473
+ }
474
+ if (outcome.published !== plan.route) {
475
+ process.stdout.write(` Degraded to ${outcome.published} after failed attempt(s).\n`);
198
476
  }
199
- markReleased(root, fp, new Date().toISOString());
477
+ markReleased(root, fp, deps.now());
200
478
  return EXIT_OK;
201
479
  }
202
480