role-os 2.0.0 → 2.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.
package/src/dispatch.mjs CHANGED
@@ -1,310 +1,339 @@
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 };
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
+ // Brainstorm
70
+ "Context Scout": ["Read", "Glob", "Grep"],
71
+ "User Value Scout": ["Read", "Glob", "Grep"],
72
+ "Creative Leap Scout": ["Read", "Glob", "Grep"],
73
+ "Normalizer": ["Read", "Glob", "Grep"],
74
+ "Synthesizer": ["Read", "Glob", "Grep", "Write"],
75
+ "Product Expander": ["Read", "Glob", "Grep", "Write"],
76
+ "Judge": ["Read", "Glob", "Grep"],
77
+ "Mechanics Scout": ["Read", "Glob", "Grep"],
78
+ "Market Scout": ["Read", "Glob", "Grep"],
79
+ "Contrarian Scout": ["Read", "Glob", "Grep"],
80
+ "Feasibility Scout": ["Read", "Glob", "Grep"],
81
+ "Quality Bar Scout": ["Read", "Glob", "Grep"],
82
+ "Scenario Expander": ["Read", "Glob", "Grep", "Write"],
83
+ "Moat Expander": ["Read", "Glob", "Grep", "Write"],
84
+
85
+ // Brainstorm v0.3 analysts
86
+ "Context Analyst": ["Read", "Glob", "Grep"],
87
+ "User Value Analyst": ["Read", "Glob", "Grep"],
88
+ "Mechanics Analyst": ["Read", "Glob", "Grep"],
89
+ "Positioning Analyst": ["Read", "Glob", "Grep"],
90
+ "Contrarian Analyst": ["Read", "Glob", "Grep"],
91
+
92
+ // Deep Audit
93
+ "Component Auditor": ["Read", "Glob", "Grep"],
94
+ "Seam Auditor": ["Read", "Glob", "Grep"],
95
+ "Test Truth Auditor": ["Read", "Glob", "Grep"],
96
+ "Audit Synthesizer": ["Read", "Glob", "Grep", "Write"],
97
+ };
98
+
99
+ // ── Default role config ─────────────────────────────────────────────────────
100
+
101
+ const DEFAULTS = {
102
+ model: "sonnet",
103
+ maxTurns: 30,
104
+ maxBudgetUsd: 5.0,
105
+ timeoutMs: 10 * 60 * 1000, // 10 minutes
106
+ };
107
+
108
+ // ── Execution states ──────────────────────────────────────────────────────────
109
+
110
+ export const EXEC_STATES = [
111
+ "queued", // in chain, not yet launched
112
+ "running", // role session active
113
+ "waiting", // waiting for upstream handoff
114
+ "blocked", // hit a block condition
115
+ "needs-review", // completed work, awaiting verdict
116
+ "completed", // approved and done
117
+ "failed", // session error or timeout
118
+ "interrupted", // manually stopped
119
+ ];
120
+
121
+ // ── System prompt builder ─────────────────────────────────────────────────────
122
+
123
+ function buildRolePrompt(roleName, packetContent, chainContext) {
124
+ return `You are operating as ${roleName} in a Role-OS managed chain.
125
+
126
+ ## Your Role Contract
127
+ Follow the ${roleName} contract exactly. Your quality bar, inputs, outputs, and escalation rules are defined in .claude/agents/.
128
+
129
+ ## Current Packet
130
+ ${packetContent}
131
+
132
+ ## Chain Context
133
+ You are step ${chainContext.stepNumber} of ${chainContext.totalSteps} in this chain.
134
+ ${chainContext.previousRole ? `Previous role: ${chainContext.previousRole} (${chainContext.previousStatus})` : "You are the first role in this chain."}
135
+ ${chainContext.nextRole ? `Next role: ${chainContext.nextRole}` : "You are the last role before Critic review."}
136
+
137
+ ## Handoff Requirements
138
+ When you finish, produce a structured handoff:
139
+ 1. Summary of what you did
140
+ 2. Artifacts produced (files, changes, docs)
141
+ 3. Open questions or risks for the next role
142
+ 4. Evidence items for your verdict (kind, reference, claim, status)
143
+
144
+ ## Stop Conditions
145
+ - If you cannot proceed: output a BLOCKED status with a clear reason
146
+ - If the upstream handoff is insufficient: output a BLOCKED status explaining what's missing
147
+ - Do not silently skip work surface every gap explicitly
148
+ `;
149
+ }
150
+
151
+ // ── Dispatch manifest builder ─────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Build a dispatch manifest from a routed chain.
155
+ * This is the contract between Role-OS (planning) and multi-claude (execution).
156
+ *
157
+ * @param {Object} options
158
+ * @param {string} options.packetFile - Path to the packet markdown
159
+ * @param {string} options.packetContent - Packet markdown content
160
+ * @param {Array<{role: {name: string, pack: string}}>} options.chainRoles - Assembled chain
161
+ * @param {string} options.cwd - Working directory for execution
162
+ * @param {Object} [options.overrides] - Per-role config overrides
163
+ * @returns {DispatchManifest}
164
+ */
165
+ export function buildDispatchManifest({ packetFile, packetContent, chainRoles, cwd, overrides = {} }) {
166
+ const runId = `run-${Date.now()}`;
167
+ const steps = [];
168
+
169
+ for (let i = 0; i < chainRoles.length; i++) {
170
+ const { role } = chainRoles[i];
171
+ const roleOverrides = overrides[role.name] || {};
172
+
173
+ const chainContext = {
174
+ stepNumber: i + 1,
175
+ totalSteps: chainRoles.length,
176
+ previousRole: i > 0 ? chainRoles[i - 1].role.name : null,
177
+ previousStatus: i > 0 ? "pending" : null,
178
+ nextRole: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
179
+ };
180
+
181
+ steps.push({
182
+ stepIndex: i,
183
+ packetId: `${runId}-step-${i}`,
184
+ role: role.name,
185
+ pack: role.pack,
186
+ tools: TOOL_PROFILES[role.name] || ["Read", "Glob", "Grep"],
187
+ systemPrompt: buildRolePrompt(role.name, packetContent, chainContext),
188
+ model: roleOverrides.model || DEFAULTS.model,
189
+ maxTurns: roleOverrides.maxTurns || DEFAULTS.maxTurns,
190
+ maxBudgetUsd: roleOverrides.maxBudgetUsd || DEFAULTS.maxBudgetUsd,
191
+ timeoutMs: roleOverrides.timeoutMs || DEFAULTS.timeoutMs,
192
+ state: "queued",
193
+ dependsOn: i > 0 ? [`${runId}-step-${i - 1}`] : [],
194
+ handoff: {
195
+ from: i > 0 ? chainRoles[i - 1].role.name : null,
196
+ to: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
197
+ },
198
+ });
199
+ }
200
+
201
+ return {
202
+ version: 1,
203
+ runId,
204
+ createdAt: new Date().toISOString(),
205
+ packetFile: resolve(packetFile),
206
+ cwd: resolve(cwd),
207
+ totalSteps: steps.length,
208
+ steps,
209
+ };
210
+ }
211
+
212
+ // ── Execution state tracker ───────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Update a step's execution state in the manifest.
216
+ *
217
+ * @param {DispatchManifest} manifest
218
+ * @param {number} stepIndex
219
+ * @param {string} newState - One of EXEC_STATES
220
+ * @param {Object} [result] - Execution result data
221
+ * @returns {DispatchManifest} Updated manifest (mutated in place)
222
+ */
223
+ export function updateStepState(manifest, stepIndex, newState, result = {}) {
224
+ const step = manifest.steps[stepIndex];
225
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
226
+ if (!EXEC_STATES.includes(newState)) throw new Error(`Invalid state: ${newState}`);
227
+
228
+ step.state = newState;
229
+ step.result = {
230
+ ...step.result,
231
+ ...result,
232
+ updatedAt: new Date().toISOString(),
233
+ };
234
+
235
+ // Auto-advance: if this step completed, mark next step as ready
236
+ if (newState === "completed" && stepIndex < manifest.steps.length - 1) {
237
+ const nextStep = manifest.steps[stepIndex + 1];
238
+ if (nextStep.state === "queued") {
239
+ nextStep.state = "waiting"; // ready to launch
240
+ }
241
+ }
242
+
243
+ return manifest;
244
+ }
245
+
246
+ /**
247
+ * Get the chain's overall status from individual step states.
248
+ *
249
+ * @param {DispatchManifest} manifest
250
+ * @returns {{status: string, completedSteps: number, totalSteps: number, currentStep: Object|null, blockedSteps: Object[]}}
251
+ */
252
+ export function getChainStatus(manifest) {
253
+ const completed = manifest.steps.filter(s => s.state === "completed").length;
254
+ const blocked = manifest.steps.filter(s => s.state === "blocked");
255
+ const failed = manifest.steps.filter(s => s.state === "failed");
256
+ const running = manifest.steps.find(s => s.state === "running");
257
+ const waiting = manifest.steps.find(s => s.state === "waiting");
258
+
259
+ let status;
260
+ if (failed.length > 0) status = "failed";
261
+ else if (blocked.length > 0) status = "blocked";
262
+ else if (completed === manifest.steps.length) status = "completed";
263
+ else if (running) status = "running";
264
+ else if (waiting) status = "ready";
265
+ else status = "queued";
266
+
267
+ return {
268
+ status,
269
+ completedSteps: completed,
270
+ totalSteps: manifest.steps.length,
271
+ currentStep: running || waiting || null,
272
+ blockedSteps: blocked,
273
+ failedSteps: failed,
274
+ };
275
+ }
276
+
277
+ // ── Escalation packet generator ───────────────────────────────────────────────
278
+
279
+ /**
280
+ * Generate an escalation packet when a step is blocked or rejected.
281
+ *
282
+ * @param {Object} step - The blocked/rejected step
283
+ * @param {string} reason - Block/rejection reason
284
+ * @param {string} verdict - "blocked" or "reject"
285
+ * @returns {Object} Escalation packet ready for routing
286
+ */
287
+ export function generateEscalationPacket(step, reason, verdict) {
288
+ const escalation = verdict === "blocked"
289
+ ? resolveBlocked(reason)
290
+ : resolveRejected(reason, step.role);
291
+
292
+ return {
293
+ type: "escalation",
294
+ sourceStep: step.packetId,
295
+ sourceRole: step.role,
296
+ verdict,
297
+ reason,
298
+ escalation,
299
+ packet: {
300
+ title: `Escalation: ${step.role} ${verdict} — ${reason.slice(0, 80)}`,
301
+ assignedRole: escalation.targetRole,
302
+ recovery: escalation.recovery,
303
+ requiredArtifact: escalation.requiredArtifact,
304
+ context: escalation.handoffContext || escalation.reason,
305
+ sourcePacketFile: step.packetId,
306
+ },
307
+ generatedAt: new Date().toISOString(),
308
+ };
309
+ }
310
+
311
+ // ── Manifest persistence ──────────────────────────────────────────────────────
312
+
313
+ /**
314
+ * Save a dispatch manifest to disk.
315
+ *
316
+ * @param {DispatchManifest} manifest
317
+ * @param {string} outputDir - Directory to write to
318
+ * @returns {string} Path to the saved manifest
319
+ */
320
+ export function saveManifest(manifest, outputDir) {
321
+ mkdirSync(outputDir, { recursive: true });
322
+ const path = join(outputDir, `${manifest.runId}.dispatch.json`);
323
+ writeFileSync(path, JSON.stringify(manifest, null, 2));
324
+ return path;
325
+ }
326
+
327
+ /**
328
+ * Load a dispatch manifest from disk.
329
+ *
330
+ * @param {string} path
331
+ * @returns {DispatchManifest}
332
+ */
333
+ export function loadManifest(path) {
334
+ return JSON.parse(readFileSync(path, "utf-8"));
335
+ }
336
+
337
+ // ── Exports for integration ───────────────────────────────────────────────────
338
+
339
+ export { TOOL_PROFILES, DEFAULTS };