role-os 1.0.2 → 1.2.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Runtime dispatch engine.
3
+ *
4
+ * Turns a staffed, validated chain into an executable dispatch manifest
5
+ * that multi-claude can consume. Role-OS owns staffing/routing/evidence;
6
+ * multi-claude owns execution.
7
+ *
8
+ * This module:
9
+ * 1. Maps roles → role configs (tool profiles, system prompts, budgets)
10
+ * 2. Tracks execution state (queued → running → completed/failed/blocked)
11
+ * 3. Collects outputs back into the packet system
12
+ * 4. Generates escalation packets for blocked/rejected work
13
+ * 5. Closes chains to final completion
14
+ */
15
+
16
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { resolveBlocked, resolveRejected } from "./escalation.mjs";
19
+
20
+ // ── Tool profiles per role ────────────────────────────────────────────────────
21
+ // What tools each role is allowed to use. Sandboxing by contract.
22
+
23
+ const TOOL_PROFILES = {
24
+ // Core
25
+ "Orchestrator": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
26
+ "Product Strategist": ["Read", "Glob", "Grep", "Write"],
27
+ "Critic Reviewer": ["Read", "Glob", "Grep", "Bash"],
28
+
29
+ // Design
30
+ "UI Designer": ["Read", "Glob", "Grep", "Write", "Edit"],
31
+ "Brand Guardian": ["Read", "Glob", "Grep"],
32
+
33
+ // Engineering
34
+ "Backend Engineer": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
35
+ "Frontend Developer": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
36
+ "Test Engineer": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
37
+ "Performance Engineer": ["Read", "Glob", "Grep", "Bash"],
38
+ "Refactor Engineer": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
39
+ "Security Reviewer": ["Read", "Glob", "Grep", "Bash"],
40
+ "Dependency Auditor": ["Read", "Glob", "Grep", "Bash"],
41
+
42
+ // Treatment
43
+ "Repo Researcher": ["Read", "Glob", "Grep", "Bash"],
44
+ "Repo Translator": ["Read", "Glob", "Grep", "Write", "Edit"],
45
+ "Docs Architect": ["Read", "Glob", "Grep", "Write", "Edit"],
46
+ "Metadata Curator": ["Read", "Glob", "Grep", "Write", "Edit"],
47
+ "Coverage Auditor": ["Read", "Glob", "Grep", "Bash"],
48
+ "Deployment Verifier": ["Read", "Glob", "Grep", "Bash"],
49
+ "Release Engineer": ["Read", "Glob", "Grep", "Bash", "Write", "Edit"],
50
+
51
+ // Growth / Marketing
52
+ "Launch Strategist": ["Read", "Glob", "Grep", "Write"],
53
+ "Content Strategist": ["Read", "Glob", "Grep", "Write"],
54
+ "Community Manager": ["Read", "Glob", "Grep", "Write"],
55
+ "Support Triage Lead": ["Read", "Glob", "Grep", "Write"],
56
+ "Launch Copywriter": ["Read", "Glob", "Grep", "Write", "Edit"],
57
+
58
+ // Product
59
+ "Feedback Synthesizer": ["Read", "Glob", "Grep"],
60
+ "Roadmap Prioritizer": ["Read", "Glob", "Grep", "Write"],
61
+ "Spec Writer": ["Read", "Glob", "Grep", "Write", "Edit"],
62
+
63
+ // Research
64
+ "UX Researcher": ["Read", "Glob", "Grep"],
65
+ "Competitive Analyst": ["Read", "Glob", "Grep"],
66
+ "Trend Researcher": ["Read", "Glob", "Grep"],
67
+ "User Interview Synthesizer": ["Read", "Glob", "Grep"],
68
+ };
69
+
70
+ // ── Default role config ─────────────────────────────────────────────────────
71
+
72
+ const DEFAULTS = {
73
+ model: "sonnet",
74
+ maxTurns: 30,
75
+ maxBudgetUsd: 5.0,
76
+ timeoutMs: 10 * 60 * 1000, // 10 minutes
77
+ };
78
+
79
+ // ── Execution states ──────────────────────────────────────────────────────────
80
+
81
+ export const EXEC_STATES = [
82
+ "queued", // in chain, not yet launched
83
+ "running", // role session active
84
+ "waiting", // waiting for upstream handoff
85
+ "blocked", // hit a block condition
86
+ "needs-review", // completed work, awaiting verdict
87
+ "completed", // approved and done
88
+ "failed", // session error or timeout
89
+ "interrupted", // manually stopped
90
+ ];
91
+
92
+ // ── System prompt builder ─────────────────────────────────────────────────────
93
+
94
+ function buildRolePrompt(roleName, packetContent, chainContext) {
95
+ return `You are operating as ${roleName} in a Role-OS managed chain.
96
+
97
+ ## Your Role Contract
98
+ Follow the ${roleName} contract exactly. Your quality bar, inputs, outputs, and escalation rules are defined in .claude/agents/.
99
+
100
+ ## Current Packet
101
+ ${packetContent}
102
+
103
+ ## Chain Context
104
+ You are step ${chainContext.stepNumber} of ${chainContext.totalSteps} in this chain.
105
+ ${chainContext.previousRole ? `Previous role: ${chainContext.previousRole} (${chainContext.previousStatus})` : "You are the first role in this chain."}
106
+ ${chainContext.nextRole ? `Next role: ${chainContext.nextRole}` : "You are the last role before Critic review."}
107
+
108
+ ## Handoff Requirements
109
+ When you finish, produce a structured handoff:
110
+ 1. Summary of what you did
111
+ 2. Artifacts produced (files, changes, docs)
112
+ 3. Open questions or risks for the next role
113
+ 4. Evidence items for your verdict (kind, reference, claim, status)
114
+
115
+ ## Stop Conditions
116
+ - If you cannot proceed: output a BLOCKED status with a clear reason
117
+ - If the upstream handoff is insufficient: output a BLOCKED status explaining what's missing
118
+ - Do not silently skip work — surface every gap explicitly
119
+ `;
120
+ }
121
+
122
+ // ── Dispatch manifest builder ─────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Build a dispatch manifest from a routed chain.
126
+ * This is the contract between Role-OS (planning) and multi-claude (execution).
127
+ *
128
+ * @param {Object} options
129
+ * @param {string} options.packetFile - Path to the packet markdown
130
+ * @param {string} options.packetContent - Packet markdown content
131
+ * @param {Array<{role: {name: string, pack: string}}>} options.chainRoles - Assembled chain
132
+ * @param {string} options.cwd - Working directory for execution
133
+ * @param {Object} [options.overrides] - Per-role config overrides
134
+ * @returns {DispatchManifest}
135
+ */
136
+ export function buildDispatchManifest({ packetFile, packetContent, chainRoles, cwd, overrides = {} }) {
137
+ const runId = `run-${Date.now()}`;
138
+ const steps = [];
139
+
140
+ for (let i = 0; i < chainRoles.length; i++) {
141
+ const { role } = chainRoles[i];
142
+ const roleOverrides = overrides[role.name] || {};
143
+
144
+ const chainContext = {
145
+ stepNumber: i + 1,
146
+ totalSteps: chainRoles.length,
147
+ previousRole: i > 0 ? chainRoles[i - 1].role.name : null,
148
+ previousStatus: i > 0 ? "pending" : null,
149
+ nextRole: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
150
+ };
151
+
152
+ steps.push({
153
+ stepIndex: i,
154
+ packetId: `${runId}-step-${i}`,
155
+ role: role.name,
156
+ pack: role.pack,
157
+ tools: TOOL_PROFILES[role.name] || ["Read", "Glob", "Grep"],
158
+ systemPrompt: buildRolePrompt(role.name, packetContent, chainContext),
159
+ model: roleOverrides.model || DEFAULTS.model,
160
+ maxTurns: roleOverrides.maxTurns || DEFAULTS.maxTurns,
161
+ maxBudgetUsd: roleOverrides.maxBudgetUsd || DEFAULTS.maxBudgetUsd,
162
+ timeoutMs: roleOverrides.timeoutMs || DEFAULTS.timeoutMs,
163
+ state: "queued",
164
+ dependsOn: i > 0 ? [`${runId}-step-${i - 1}`] : [],
165
+ handoff: {
166
+ from: i > 0 ? chainRoles[i - 1].role.name : null,
167
+ to: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
168
+ },
169
+ });
170
+ }
171
+
172
+ return {
173
+ version: 1,
174
+ runId,
175
+ createdAt: new Date().toISOString(),
176
+ packetFile: resolve(packetFile),
177
+ cwd: resolve(cwd),
178
+ totalSteps: steps.length,
179
+ steps,
180
+ };
181
+ }
182
+
183
+ // ── Execution state tracker ───────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Update a step's execution state in the manifest.
187
+ *
188
+ * @param {DispatchManifest} manifest
189
+ * @param {number} stepIndex
190
+ * @param {string} newState - One of EXEC_STATES
191
+ * @param {Object} [result] - Execution result data
192
+ * @returns {DispatchManifest} Updated manifest (mutated in place)
193
+ */
194
+ export function updateStepState(manifest, stepIndex, newState, result = {}) {
195
+ const step = manifest.steps[stepIndex];
196
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
197
+ if (!EXEC_STATES.includes(newState)) throw new Error(`Invalid state: ${newState}`);
198
+
199
+ step.state = newState;
200
+ step.result = {
201
+ ...step.result,
202
+ ...result,
203
+ updatedAt: new Date().toISOString(),
204
+ };
205
+
206
+ // Auto-advance: if this step completed, mark next step as ready
207
+ if (newState === "completed" && stepIndex < manifest.steps.length - 1) {
208
+ const nextStep = manifest.steps[stepIndex + 1];
209
+ if (nextStep.state === "queued") {
210
+ nextStep.state = "waiting"; // ready to launch
211
+ }
212
+ }
213
+
214
+ return manifest;
215
+ }
216
+
217
+ /**
218
+ * Get the chain's overall status from individual step states.
219
+ *
220
+ * @param {DispatchManifest} manifest
221
+ * @returns {{status: string, completedSteps: number, totalSteps: number, currentStep: Object|null, blockedSteps: Object[]}}
222
+ */
223
+ export function getChainStatus(manifest) {
224
+ const completed = manifest.steps.filter(s => s.state === "completed").length;
225
+ const blocked = manifest.steps.filter(s => s.state === "blocked");
226
+ const failed = manifest.steps.filter(s => s.state === "failed");
227
+ const running = manifest.steps.find(s => s.state === "running");
228
+ const waiting = manifest.steps.find(s => s.state === "waiting");
229
+
230
+ let status;
231
+ if (failed.length > 0) status = "failed";
232
+ else if (blocked.length > 0) status = "blocked";
233
+ else if (completed === manifest.steps.length) status = "completed";
234
+ else if (running) status = "running";
235
+ else if (waiting) status = "ready";
236
+ else status = "queued";
237
+
238
+ return {
239
+ status,
240
+ completedSteps: completed,
241
+ totalSteps: manifest.steps.length,
242
+ currentStep: running || waiting || null,
243
+ blockedSteps: blocked,
244
+ failedSteps: failed,
245
+ };
246
+ }
247
+
248
+ // ── Escalation packet generator ───────────────────────────────────────────────
249
+
250
+ /**
251
+ * Generate an escalation packet when a step is blocked or rejected.
252
+ *
253
+ * @param {Object} step - The blocked/rejected step
254
+ * @param {string} reason - Block/rejection reason
255
+ * @param {string} verdict - "blocked" or "reject"
256
+ * @returns {Object} Escalation packet ready for routing
257
+ */
258
+ export function generateEscalationPacket(step, reason, verdict) {
259
+ const escalation = verdict === "blocked"
260
+ ? resolveBlocked(reason)
261
+ : resolveRejected(reason, step.role);
262
+
263
+ return {
264
+ type: "escalation",
265
+ sourceStep: step.packetId,
266
+ sourceRole: step.role,
267
+ verdict,
268
+ reason,
269
+ escalation,
270
+ packet: {
271
+ title: `Escalation: ${step.role} ${verdict} — ${reason.slice(0, 80)}`,
272
+ assignedRole: escalation.targetRole,
273
+ recovery: escalation.recovery,
274
+ requiredArtifact: escalation.requiredArtifact,
275
+ context: escalation.handoffContext || escalation.reason,
276
+ sourcePacketFile: step.packetId,
277
+ },
278
+ generatedAt: new Date().toISOString(),
279
+ };
280
+ }
281
+
282
+ // ── Manifest persistence ──────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Save a dispatch manifest to disk.
286
+ *
287
+ * @param {DispatchManifest} manifest
288
+ * @param {string} outputDir - Directory to write to
289
+ * @returns {string} Path to the saved manifest
290
+ */
291
+ export function saveManifest(manifest, outputDir) {
292
+ mkdirSync(outputDir, { recursive: true });
293
+ const path = join(outputDir, `${manifest.runId}.dispatch.json`);
294
+ writeFileSync(path, JSON.stringify(manifest, null, 2));
295
+ return path;
296
+ }
297
+
298
+ /**
299
+ * Load a dispatch manifest from disk.
300
+ *
301
+ * @param {string} path
302
+ * @returns {DispatchManifest}
303
+ */
304
+ export function loadManifest(path) {
305
+ return JSON.parse(readFileSync(path, "utf-8"));
306
+ }
307
+
308
+ // ── Exports for integration ───────────────────────────────────────────────────
309
+
310
+ export { TOOL_PROFILES, DEFAULTS };
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Escalation auto-routing.
3
+ *
4
+ * When a packet hits a stop condition (blocked, rejected, conflict, split-needed),
5
+ * this module routes it to the right resolver with a reason, required artifact,
6
+ * and recovery type.
7
+ *
8
+ * Every escalation produces a clean next-step — not just a destination.
9
+ */
10
+
11
+ // ── Recovery types ────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * @typedef {'retry' | 'revise' | 'review' | 'split' | 'reroute' | 'add-role'} RecoveryType
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} EscalationResult
19
+ * @property {string} targetRole - Who owns the recovery
20
+ * @property {RecoveryType} recovery - What kind of recovery this is
21
+ * @property {string} reason - Why this role was chosen
22
+ * @property {string} requiredArtifact - What they must produce to unblock
23
+ * @property {string} handoffContext - What the target needs to know
24
+ */
25
+
26
+ // ── Blocked work routing ──────────────────────────────────────────────────────
27
+ // Each blocked reason maps to a resolver role + recovery path.
28
+
29
+ const BLOCKED_ROUTES = {
30
+ // Missing information / unclear requirements
31
+ "missing info": {
32
+ targetRole: "Product Strategist",
33
+ recovery: "revise",
34
+ reason: "Blocked on missing information — Product Strategist owns requirement clarification",
35
+ requiredArtifact: "Updated scope doc or clarified requirements",
36
+ handoffContext: "Identify exactly what information is missing and whether it requires user input or can be derived from existing context",
37
+ },
38
+ "unclear requirements": {
39
+ targetRole: "Product Strategist",
40
+ recovery: "revise",
41
+ reason: "Requirements are ambiguous — Product Strategist owns scope clarification",
42
+ requiredArtifact: "Clarified requirements with acceptance criteria",
43
+ handoffContext: "Flag which requirements are ambiguous and what interpretations exist",
44
+ },
45
+ "scope contradiction": {
46
+ targetRole: "Product Strategist",
47
+ recovery: "revise",
48
+ reason: "Scope contains contradictions — Product Strategist must resolve",
49
+ requiredArtifact: "Revised scope with contradictions resolved and tradeoffs documented",
50
+ handoffContext: "List the specific contradictions and what each interpretation implies for delivery",
51
+ },
52
+
53
+ // Dependency unavailable
54
+ "dependency unavailable": {
55
+ targetRole: "Orchestrator",
56
+ recovery: "reroute",
57
+ reason: "Upstream dependency is unavailable — Orchestrator must re-sequence or find alternative",
58
+ requiredArtifact: "Revised execution plan with dependency resolved or worked around",
59
+ handoffContext: "Identify which dependency is blocked, what it provides, and whether alternatives exist",
60
+ },
61
+ "blocked upstream": {
62
+ targetRole: "Orchestrator",
63
+ recovery: "reroute",
64
+ reason: "Upstream work is incomplete — Orchestrator must re-sequence",
65
+ requiredArtifact: "Updated chain with dependency ordering corrected",
66
+ handoffContext: "Identify which upstream deliverable is missing and which role was supposed to produce it",
67
+ },
68
+
69
+ // Technical infeasibility
70
+ "technical infeasibility": {
71
+ targetRole: "Backend Engineer",
72
+ recovery: "revise",
73
+ reason: "Proposed approach is technically infeasible — needs engineering assessment",
74
+ requiredArtifact: "Feasibility analysis with alternative approaches",
75
+ handoffContext: "Document what was attempted, why it failed, and what constraints make the original approach infeasible",
76
+ },
77
+ "architecture mismatch": {
78
+ targetRole: "Orchestrator",
79
+ recovery: "split",
80
+ reason: "Work doesn't fit the current architecture — may need decomposition",
81
+ requiredArtifact: "Architectural assessment and revised packet(s)",
82
+ handoffContext: "Explain the mismatch between the work and the architecture, and what would need to change",
83
+ },
84
+
85
+ // Owner ambiguity
86
+ "owner ambiguity": {
87
+ targetRole: "Orchestrator",
88
+ recovery: "reroute",
89
+ reason: "Unclear which role owns this work — Orchestrator must assign",
90
+ requiredArtifact: "Clear role assignment with justification",
91
+ handoffContext: "Describe what the work is and which roles could plausibly own it",
92
+ },
93
+ };
94
+
95
+ // ── Rejected work routing ─────────────────────────────────────────────────────
96
+ // Rejected work routes backward with intent.
97
+
98
+ const REJECTED_ROUTES = {
99
+ "quality bar miss": {
100
+ recovery: "retry",
101
+ reason: "Output did not meet the role's quality bar — send back to the producing role",
102
+ requiredArtifact: "Revised output addressing quality bar gaps",
103
+ handoffContext: "Cite the specific quality bar items that were not met",
104
+ targetRoleRule: "previous", // route to the role that produced the rejected output
105
+ },
106
+ "wrong artifact type": {
107
+ recovery: "revise",
108
+ reason: "Wrong deliverable type — role may have misunderstood the packet",
109
+ requiredArtifact: "Correct artifact type as specified in the packet",
110
+ handoffContext: "Clarify what artifact was expected vs what was produced",
111
+ targetRoleRule: "previous",
112
+ },
113
+ "incomplete evidence": {
114
+ recovery: "retry",
115
+ reason: "Evidence is incomplete — role must provide missing proof",
116
+ requiredArtifact: "Complete evidence set as required by the review contract",
117
+ handoffContext: "List exactly which evidence items are missing or insufficient",
118
+ targetRoleRule: "previous",
119
+ },
120
+ "role mismatch": {
121
+ recovery: "reroute",
122
+ reason: "This work was assigned to the wrong role — needs re-routing",
123
+ requiredArtifact: "Re-routed packet with correct role assignment",
124
+ handoffContext: "Explain why the current role is wrong and which role should own this",
125
+ targetRoleRule: "orchestrator",
126
+ },
127
+ "chain problem": {
128
+ recovery: "reroute",
129
+ reason: "The chain/ordering is wrong — Orchestrator must restructure",
130
+ requiredArtifact: "Revised chain with corrected ordering or role selection",
131
+ handoffContext: "Describe the chain problem and what the correct structure should be",
132
+ targetRoleRule: "orchestrator",
133
+ },
134
+ };
135
+
136
+ // ── Conflict escalation routing ───────────────────────────────────────────────
137
+
138
+ const CONFLICT_ROUTES = {
139
+ blocking: {
140
+ targetRole: "Orchestrator",
141
+ recovery: "reroute",
142
+ reason: "Hard conflict in chain — cannot execute without restructuring",
143
+ requiredArtifact: "Revised chain with conflict resolved",
144
+ },
145
+ sequence: {
146
+ targetRole: "Orchestrator",
147
+ recovery: "reroute",
148
+ reason: "Sequence conflict — roles are in the wrong order",
149
+ requiredArtifact: "Reordered chain with correct role sequencing",
150
+ },
151
+ redundancy: {
152
+ targetRole: "Orchestrator",
153
+ recovery: "revise",
154
+ reason: "Redundant roles in chain — consider trimming or splitting",
155
+ requiredArtifact: "Trimmed chain or split into sub-packets",
156
+ },
157
+ coverage: {
158
+ targetRole: "Orchestrator",
159
+ recovery: "add-role",
160
+ reason: "Coverage gap — chain is missing a critical role",
161
+ requiredArtifact: "Updated chain with missing role added",
162
+ },
163
+ };
164
+
165
+ // ── Split routing ─────────────────────────────────────────────────────────────
166
+
167
+ const SPLIT_ROUTE = {
168
+ targetRole: "Orchestrator",
169
+ recovery: "split",
170
+ reason: "Chain is too large (>7 roles) — needs decomposition into sub-packets",
171
+ requiredArtifact: "Decomposed packets, each with its own chain and scope",
172
+ handoffContext: "Identify natural seams in the work where splitting reduces complexity without creating integration debt",
173
+ };
174
+
175
+ // ── Resolution engine ─────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Resolve a blocked verdict to an escalation target.
179
+ *
180
+ * @param {string} blockedReason - Why the work is blocked (free text, matched against known patterns)
181
+ * @returns {EscalationResult}
182
+ */
183
+ export function resolveBlocked(blockedReason) {
184
+ const lower = (blockedReason || "").toLowerCase();
185
+
186
+ // Try exact-ish matches first
187
+ for (const [pattern, route] of Object.entries(BLOCKED_ROUTES)) {
188
+ if (lower.includes(pattern)) {
189
+ return { ...route };
190
+ }
191
+ }
192
+
193
+ // Default: Orchestrator owns unknown blocks
194
+ return {
195
+ targetRole: "Orchestrator",
196
+ recovery: "reroute",
197
+ reason: "Blocked for unrecognized reason — Orchestrator must assess and route",
198
+ requiredArtifact: "Assessment of block cause and recovery plan",
199
+ handoffContext: `Original block reason: "${blockedReason}"`,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Resolve a rejected verdict to an escalation target.
205
+ *
206
+ * @param {string} rejectedReason - Why the work was rejected
207
+ * @param {string} previousRole - The role that produced the rejected output
208
+ * @returns {EscalationResult}
209
+ */
210
+ export function resolveRejected(rejectedReason, previousRole) {
211
+ const lower = (rejectedReason || "").toLowerCase();
212
+
213
+ for (const [pattern, route] of Object.entries(REJECTED_ROUTES)) {
214
+ if (lower.includes(pattern)) {
215
+ const target = route.targetRoleRule === "previous"
216
+ ? (previousRole || "Orchestrator")
217
+ : route.targetRoleRule === "orchestrator"
218
+ ? "Orchestrator"
219
+ : route.targetRoleRule;
220
+
221
+ return {
222
+ targetRole: target,
223
+ recovery: route.recovery,
224
+ reason: route.reason,
225
+ requiredArtifact: route.requiredArtifact,
226
+ handoffContext: route.handoffContext,
227
+ };
228
+ }
229
+ }
230
+
231
+ // Default: route back to previous role for retry
232
+ return {
233
+ targetRole: previousRole || "Orchestrator",
234
+ recovery: "retry",
235
+ reason: "Rejected — route back to producing role for revision",
236
+ requiredArtifact: "Revised output addressing rejection reason",
237
+ handoffContext: `Original rejection reason: "${rejectedReason}"`,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Resolve a conflict finding to an escalation target.
243
+ *
244
+ * @param {{type: string, severity: string, roles: string[], message: string, repair: string}} conflict
245
+ * @returns {EscalationResult}
246
+ */
247
+ export function resolveConflict(conflict) {
248
+ const route = CONFLICT_ROUTES[conflict.type] || CONFLICT_ROUTES.blocking;
249
+
250
+ return {
251
+ targetRole: route.targetRole,
252
+ recovery: route.recovery,
253
+ reason: `${route.reason}: ${conflict.message}`,
254
+ requiredArtifact: route.requiredArtifact,
255
+ handoffContext: `Repair suggestion: ${conflict.repair}`,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Resolve a split-needed chain to a decomposition owner.
261
+ *
262
+ * @param {number} chainSize
263
+ * @returns {EscalationResult}
264
+ */
265
+ export function resolveSplit(chainSize) {
266
+ return {
267
+ ...SPLIT_ROUTE,
268
+ handoffContext: `Current chain has ${chainSize} roles. ${SPLIT_ROUTE.handoffContext}`,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Format an escalation result for operator display.
274
+ *
275
+ * @param {EscalationResult} result
276
+ * @returns {string}
277
+ */
278
+ export function formatEscalation(result) {
279
+ const lines = [
280
+ ` → ${result.targetRole} (${result.recovery})`,
281
+ ` why: ${result.reason}`,
282
+ ` must produce: ${result.requiredArtifact}`,
283
+ ];
284
+ if (result.handoffContext) {
285
+ lines.push(` context: ${result.handoffContext}`);
286
+ }
287
+ return lines.join("\n");
288
+ }