role-os 2.3.1 → 2.6.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,265 +1,265 @@
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
- import { TOOL_PROFILES } from "./tool-profiles.mjs";
20
- import { renderKnowledgeBlock, knowledgeManifestSummary } from "./knowledge/render-knowledge-block.mjs";
21
-
22
- // ── Default role config ─────────────────────────────────────────────────────
23
-
24
- const DEFAULTS = {
25
- model: "sonnet",
26
- maxTurns: 30,
27
- maxBudgetUsd: 5.0,
28
- timeoutMs: 10 * 60 * 1000, // 10 minutes
29
- };
30
-
31
- // ── Execution states ──────────────────────────────────────────────────────────
32
-
33
- export const EXEC_STATES = [
34
- "queued", // in chain, not yet launched
35
- "running", // role session active
36
- "waiting", // waiting for upstream handoff
37
- "blocked", // hit a block condition
38
- "needs-review", // completed work, awaiting verdict
39
- "completed", // approved and done
40
- "failed", // session error or timeout
41
- "interrupted", // manually stopped
42
- ];
43
-
44
- // ── System prompt builder ─────────────────────────────────────────────────────
45
-
46
- function buildRolePrompt(roleName, packetContent, chainContext, packetKnowledge) {
47
- const knowledgeBlock = renderKnowledgeBlock(packetKnowledge);
48
-
49
- return `You are operating as ${roleName} in a Role-OS managed chain.
50
-
51
- ## Your Role Contract
52
- Follow the ${roleName} contract exactly. Your quality bar, inputs, outputs, and escalation rules are defined in .claude/agents/.
53
-
54
- ## Current Packet
55
- ${packetContent}
56
-
57
- ## Chain Context
58
- You are step ${chainContext.stepNumber} of ${chainContext.totalSteps} in this chain.
59
- ${chainContext.previousRole ? `Previous role: ${chainContext.previousRole} (${chainContext.previousStatus})` : "You are the first role in this chain."}
60
- ${chainContext.nextRole ? `Next role: ${chainContext.nextRole}` : "You are the last role before Critic review."}
61
- ${knowledgeBlock ? `\n${knowledgeBlock}\n` : ""}
62
- ## Handoff Requirements
63
- When you finish, produce a structured handoff:
64
- 1. Summary of what you did
65
- 2. Artifacts produced (files, changes, docs)
66
- 3. Open questions or risks for the next role
67
- 4. Evidence items for your verdict (kind, reference, claim, status)
68
-
69
- ## Stop Conditions
70
- - If you cannot proceed: output a BLOCKED status with a clear reason
71
- - If the upstream handoff is insufficient: output a BLOCKED status explaining what's missing
72
- - Do not silently skip work — surface every gap explicitly
73
- `;
74
- }
75
-
76
- // ── Dispatch manifest builder ─────────────────────────────────────────────────
77
-
78
- /**
79
- * Build a dispatch manifest from a routed chain.
80
- * This is the contract between Role-OS (planning) and multi-claude (execution).
81
- *
82
- * @param {Object} options
83
- * @param {string} options.packetFile - Path to the packet markdown
84
- * @param {string} options.packetContent - Packet markdown content
85
- * @param {Array<{role: {name: string, pack: string}}>} options.chainRoles - Assembled chain
86
- * @param {string} options.cwd - Working directory for execution
87
- * @param {Object} [options.overrides] - Per-role config overrides
88
- * @returns {DispatchManifest}
89
- */
90
- export function buildDispatchManifest({ packetFile, packetContent, chainRoles, cwd, overrides = {}, packetKnowledge = null }) {
91
- const runId = `run-${Date.now()}`;
92
- const steps = [];
93
-
94
- for (let i = 0; i < chainRoles.length; i++) {
95
- const { role } = chainRoles[i];
96
- const roleOverrides = overrides[role.name] || {};
97
-
98
- const chainContext = {
99
- stepNumber: i + 1,
100
- totalSteps: chainRoles.length,
101
- previousRole: i > 0 ? chainRoles[i - 1].role.name : null,
102
- previousStatus: i > 0 ? "pending" : null,
103
- nextRole: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
104
- };
105
-
106
- steps.push({
107
- stepIndex: i,
108
- packetId: `${runId}-step-${i}`,
109
- role: role.name,
110
- pack: role.pack,
111
- tools: TOOL_PROFILES[role.name] || ["Read", "Glob", "Grep"],
112
- systemPrompt: buildRolePrompt(role.name, packetContent, chainContext, packetKnowledge),
113
- model: roleOverrides.model || DEFAULTS.model,
114
- maxTurns: roleOverrides.maxTurns || DEFAULTS.maxTurns,
115
- maxBudgetUsd: roleOverrides.maxBudgetUsd || DEFAULTS.maxBudgetUsd,
116
- timeoutMs: roleOverrides.timeoutMs || DEFAULTS.timeoutMs,
117
- state: "queued",
118
- dependsOn: i > 0 ? [`${runId}-step-${i - 1}`] : [],
119
- handoff: {
120
- from: i > 0 ? chainRoles[i - 1].role.name : null,
121
- to: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
122
- },
123
- knowledge: knowledgeManifestSummary(packetKnowledge),
124
- });
125
- }
126
-
127
- return {
128
- version: 1,
129
- runId,
130
- createdAt: new Date().toISOString(),
131
- packetFile: resolve(packetFile),
132
- cwd: resolve(cwd),
133
- totalSteps: steps.length,
134
- steps,
135
- };
136
- }
137
-
138
- // ── Execution state tracker ───────────────────────────────────────────────────
139
-
140
- /**
141
- * Update a step's execution state in the manifest.
142
- *
143
- * @param {DispatchManifest} manifest
144
- * @param {number} stepIndex
145
- * @param {string} newState - One of EXEC_STATES
146
- * @param {Object} [result] - Execution result data
147
- * @returns {DispatchManifest} Updated manifest (mutated in place)
148
- */
149
- export function updateStepState(manifest, stepIndex, newState, result = {}) {
150
- const step = manifest.steps[stepIndex];
151
- if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
152
- if (!EXEC_STATES.includes(newState)) throw new Error(`Invalid state: ${newState}`);
153
-
154
- step.state = newState;
155
- step.result = {
156
- ...step.result,
157
- ...result,
158
- updatedAt: new Date().toISOString(),
159
- };
160
-
161
- // Auto-advance: if this step completed, mark next step as ready
162
- if (newState === "completed" && stepIndex < manifest.steps.length - 1) {
163
- const nextStep = manifest.steps[stepIndex + 1];
164
- if (nextStep.state === "queued") {
165
- nextStep.state = "waiting"; // ready to launch
166
- }
167
- }
168
-
169
- return manifest;
170
- }
171
-
172
- /**
173
- * Get the chain's overall status from individual step states.
174
- *
175
- * @param {DispatchManifest} manifest
176
- * @returns {{status: string, completedSteps: number, totalSteps: number, currentStep: Object|null, blockedSteps: Object[]}}
177
- */
178
- export function getChainStatus(manifest) {
179
- const completed = manifest.steps.filter(s => s.state === "completed").length;
180
- const blocked = manifest.steps.filter(s => s.state === "blocked");
181
- const failed = manifest.steps.filter(s => s.state === "failed");
182
- const running = manifest.steps.find(s => s.state === "running");
183
- const waiting = manifest.steps.find(s => s.state === "waiting");
184
-
185
- let status;
186
- if (failed.length > 0) status = "failed";
187
- else if (blocked.length > 0) status = "blocked";
188
- else if (completed === manifest.steps.length) status = "completed";
189
- else if (running) status = "running";
190
- else if (waiting) status = "ready";
191
- else status = "queued";
192
-
193
- return {
194
- status,
195
- completedSteps: completed,
196
- totalSteps: manifest.steps.length,
197
- currentStep: running || waiting || null,
198
- blockedSteps: blocked,
199
- failedSteps: failed,
200
- };
201
- }
202
-
203
- // ── Escalation packet generator ───────────────────────────────────────────────
204
-
205
- /**
206
- * Generate an escalation packet when a step is blocked or rejected.
207
- *
208
- * @param {Object} step - The blocked/rejected step
209
- * @param {string} reason - Block/rejection reason
210
- * @param {string} verdict - "blocked" or "reject"
211
- * @returns {Object} Escalation packet ready for routing
212
- */
213
- export function generateEscalationPacket(step, reason, verdict) {
214
- const escalation = verdict === "blocked"
215
- ? resolveBlocked(reason)
216
- : resolveRejected(reason, step.role);
217
-
218
- return {
219
- type: "escalation",
220
- sourceStep: step.packetId,
221
- sourceRole: step.role,
222
- verdict,
223
- reason,
224
- escalation,
225
- packet: {
226
- title: `Escalation: ${step.role} ${verdict} — ${reason.slice(0, 80)}`,
227
- assignedRole: escalation.targetRole,
228
- recovery: escalation.recovery,
229
- requiredArtifact: escalation.requiredArtifact,
230
- context: escalation.handoffContext || escalation.reason,
231
- sourcePacketFile: step.packetId,
232
- },
233
- generatedAt: new Date().toISOString(),
234
- };
235
- }
236
-
237
- // ── Manifest persistence ──────────────────────────────────────────────────────
238
-
239
- /**
240
- * Save a dispatch manifest to disk.
241
- *
242
- * @param {DispatchManifest} manifest
243
- * @param {string} outputDir - Directory to write to
244
- * @returns {string} Path to the saved manifest
245
- */
246
- export function saveManifest(manifest, outputDir) {
247
- mkdirSync(outputDir, { recursive: true });
248
- const path = join(outputDir, `${manifest.runId}.dispatch.json`);
249
- writeFileSync(path, JSON.stringify(manifest, null, 2));
250
- return path;
251
- }
252
-
253
- /**
254
- * Load a dispatch manifest from disk.
255
- *
256
- * @param {string} path
257
- * @returns {DispatchManifest}
258
- */
259
- export function loadManifest(path) {
260
- return JSON.parse(readFileSync(path, "utf-8"));
261
- }
262
-
263
- // ── Exports for integration ───────────────────────────────────────────────────
264
-
265
- 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
+ import { TOOL_PROFILES } from "./tool-profiles.mjs";
20
+ import { renderKnowledgeBlock, knowledgeManifestSummary } from "./knowledge/render-knowledge-block.mjs";
21
+
22
+ // ── Default role config ─────────────────────────────────────────────────────
23
+
24
+ const DEFAULTS = {
25
+ model: "sonnet",
26
+ maxTurns: 30,
27
+ maxBudgetUsd: 5.0,
28
+ timeoutMs: 10 * 60 * 1000, // 10 minutes
29
+ };
30
+
31
+ // ── Execution states ──────────────────────────────────────────────────────────
32
+
33
+ export const EXEC_STATES = [
34
+ "queued", // in chain, not yet launched
35
+ "running", // role session active
36
+ "waiting", // waiting for upstream handoff
37
+ "blocked", // hit a block condition
38
+ "needs-review", // completed work, awaiting verdict
39
+ "completed", // approved and done
40
+ "failed", // session error or timeout
41
+ "interrupted", // manually stopped
42
+ ];
43
+
44
+ // ── System prompt builder ─────────────────────────────────────────────────────
45
+
46
+ function buildRolePrompt(roleName, packetContent, chainContext, packetKnowledge) {
47
+ const knowledgeBlock = renderKnowledgeBlock(packetKnowledge);
48
+
49
+ return `You are operating as ${roleName} in a Role-OS managed chain.
50
+
51
+ ## Your Role Contract
52
+ Follow the ${roleName} contract exactly. Your quality bar, inputs, outputs, and escalation rules are defined in .claude/agents/.
53
+
54
+ ## Current Packet
55
+ ${packetContent}
56
+
57
+ ## Chain Context
58
+ You are step ${chainContext.stepNumber} of ${chainContext.totalSteps} in this chain.
59
+ ${chainContext.previousRole ? `Previous role: ${chainContext.previousRole} (${chainContext.previousStatus})` : "You are the first role in this chain."}
60
+ ${chainContext.nextRole ? `Next role: ${chainContext.nextRole}` : "You are the last role before Critic review."}
61
+ ${knowledgeBlock ? `\n${knowledgeBlock}\n` : ""}
62
+ ## Handoff Requirements
63
+ When you finish, produce a structured handoff:
64
+ 1. Summary of what you did
65
+ 2. Artifacts produced (files, changes, docs)
66
+ 3. Open questions or risks for the next role
67
+ 4. Evidence items for your verdict (kind, reference, claim, status)
68
+
69
+ ## Stop Conditions
70
+ - If you cannot proceed: output a BLOCKED status with a clear reason
71
+ - If the upstream handoff is insufficient: output a BLOCKED status explaining what's missing
72
+ - Do not silently skip work — surface every gap explicitly
73
+ `;
74
+ }
75
+
76
+ // ── Dispatch manifest builder ─────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Build a dispatch manifest from a routed chain.
80
+ * This is the contract between Role-OS (planning) and multi-claude (execution).
81
+ *
82
+ * @param {Object} options
83
+ * @param {string} options.packetFile - Path to the packet markdown
84
+ * @param {string} options.packetContent - Packet markdown content
85
+ * @param {Array<{role: {name: string, pack: string}}>} options.chainRoles - Assembled chain
86
+ * @param {string} options.cwd - Working directory for execution
87
+ * @param {Object} [options.overrides] - Per-role config overrides
88
+ * @returns {DispatchManifest}
89
+ */
90
+ export function buildDispatchManifest({ packetFile, packetContent, chainRoles, cwd, overrides = {}, packetKnowledge = null }) {
91
+ const runId = `run-${Date.now()}`;
92
+ const steps = [];
93
+
94
+ for (let i = 0; i < chainRoles.length; i++) {
95
+ const { role } = chainRoles[i];
96
+ const roleOverrides = overrides[role.name] || {};
97
+
98
+ const chainContext = {
99
+ stepNumber: i + 1,
100
+ totalSteps: chainRoles.length,
101
+ previousRole: i > 0 ? chainRoles[i - 1].role.name : null,
102
+ previousStatus: i > 0 ? "pending" : null,
103
+ nextRole: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
104
+ };
105
+
106
+ steps.push({
107
+ stepIndex: i,
108
+ packetId: `${runId}-step-${i}`,
109
+ role: role.name,
110
+ pack: role.pack,
111
+ tools: TOOL_PROFILES[role.name] || ["Read", "Glob", "Grep"],
112
+ systemPrompt: buildRolePrompt(role.name, packetContent, chainContext, packetKnowledge),
113
+ model: roleOverrides.model || DEFAULTS.model,
114
+ maxTurns: roleOverrides.maxTurns || DEFAULTS.maxTurns,
115
+ maxBudgetUsd: roleOverrides.maxBudgetUsd || DEFAULTS.maxBudgetUsd,
116
+ timeoutMs: roleOverrides.timeoutMs || DEFAULTS.timeoutMs,
117
+ state: "queued",
118
+ dependsOn: i > 0 ? [`${runId}-step-${i - 1}`] : [],
119
+ handoff: {
120
+ from: i > 0 ? chainRoles[i - 1].role.name : null,
121
+ to: i < chainRoles.length - 1 ? chainRoles[i + 1].role.name : null,
122
+ },
123
+ knowledge: knowledgeManifestSummary(packetKnowledge),
124
+ });
125
+ }
126
+
127
+ return {
128
+ version: 1,
129
+ runId,
130
+ createdAt: new Date().toISOString(),
131
+ packetFile: resolve(packetFile),
132
+ cwd: resolve(cwd),
133
+ totalSteps: steps.length,
134
+ steps,
135
+ };
136
+ }
137
+
138
+ // ── Execution state tracker ───────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Update a step's execution state in the manifest.
142
+ *
143
+ * @param {DispatchManifest} manifest
144
+ * @param {number} stepIndex
145
+ * @param {string} newState - One of EXEC_STATES
146
+ * @param {Object} [result] - Execution result data
147
+ * @returns {DispatchManifest} Updated manifest (mutated in place)
148
+ */
149
+ export function updateStepState(manifest, stepIndex, newState, result = {}) {
150
+ const step = manifest.steps[stepIndex];
151
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
152
+ if (!EXEC_STATES.includes(newState)) throw new Error(`Invalid state: ${newState}`);
153
+
154
+ step.state = newState;
155
+ step.result = {
156
+ ...step.result,
157
+ ...result,
158
+ updatedAt: new Date().toISOString(),
159
+ };
160
+
161
+ // Auto-advance: if this step completed, mark next step as ready
162
+ if (newState === "completed" && stepIndex < manifest.steps.length - 1) {
163
+ const nextStep = manifest.steps[stepIndex + 1];
164
+ if (nextStep.state === "queued") {
165
+ nextStep.state = "waiting"; // ready to launch
166
+ }
167
+ }
168
+
169
+ return manifest;
170
+ }
171
+
172
+ /**
173
+ * Get the chain's overall status from individual step states.
174
+ *
175
+ * @param {DispatchManifest} manifest
176
+ * @returns {{status: string, completedSteps: number, totalSteps: number, currentStep: Object|null, blockedSteps: Object[]}}
177
+ */
178
+ export function getChainStatus(manifest) {
179
+ const completed = manifest.steps.filter(s => s.state === "completed").length;
180
+ const blocked = manifest.steps.filter(s => s.state === "blocked");
181
+ const failed = manifest.steps.filter(s => s.state === "failed");
182
+ const running = manifest.steps.find(s => s.state === "running");
183
+ const waiting = manifest.steps.find(s => s.state === "waiting");
184
+
185
+ let status;
186
+ if (failed.length > 0) status = "failed";
187
+ else if (blocked.length > 0) status = "blocked";
188
+ else if (completed === manifest.steps.length) status = "completed";
189
+ else if (running) status = "running";
190
+ else if (waiting) status = "ready";
191
+ else status = "queued";
192
+
193
+ return {
194
+ status,
195
+ completedSteps: completed,
196
+ totalSteps: manifest.steps.length,
197
+ currentStep: running || waiting || null,
198
+ blockedSteps: blocked,
199
+ failedSteps: failed,
200
+ };
201
+ }
202
+
203
+ // ── Escalation packet generator ───────────────────────────────────────────────
204
+
205
+ /**
206
+ * Generate an escalation packet when a step is blocked or rejected.
207
+ *
208
+ * @param {Object} step - The blocked/rejected step
209
+ * @param {string} reason - Block/rejection reason
210
+ * @param {string} verdict - "blocked" or "reject"
211
+ * @returns {Object} Escalation packet ready for routing
212
+ */
213
+ export function generateEscalationPacket(step, reason, verdict) {
214
+ const escalation = verdict === "blocked"
215
+ ? resolveBlocked(reason)
216
+ : resolveRejected(reason, step.role);
217
+
218
+ return {
219
+ type: "escalation",
220
+ sourceStep: step.packetId,
221
+ sourceRole: step.role,
222
+ verdict,
223
+ reason,
224
+ escalation,
225
+ packet: {
226
+ title: `Escalation: ${step.role} ${verdict} — ${reason.slice(0, 80)}`,
227
+ assignedRole: escalation.targetRole,
228
+ recovery: escalation.recovery,
229
+ requiredArtifact: escalation.requiredArtifact,
230
+ context: escalation.handoffContext || escalation.reason,
231
+ sourcePacketFile: step.packetId,
232
+ },
233
+ generatedAt: new Date().toISOString(),
234
+ };
235
+ }
236
+
237
+ // ── Manifest persistence ──────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Save a dispatch manifest to disk.
241
+ *
242
+ * @param {DispatchManifest} manifest
243
+ * @param {string} outputDir - Directory to write to
244
+ * @returns {string} Path to the saved manifest
245
+ */
246
+ export function saveManifest(manifest, outputDir) {
247
+ mkdirSync(outputDir, { recursive: true });
248
+ const path = join(outputDir, `${manifest.runId}.dispatch.json`);
249
+ writeFileSync(path, JSON.stringify(manifest, null, 2));
250
+ return path;
251
+ }
252
+
253
+ /**
254
+ * Load a dispatch manifest from disk.
255
+ *
256
+ * @param {string} path
257
+ * @returns {DispatchManifest}
258
+ */
259
+ export function loadManifest(path) {
260
+ return JSON.parse(readFileSync(path, "utf-8"));
261
+ }
262
+
263
+ // ── Exports for integration ───────────────────────────────────────────────────
264
+
265
+ export { TOOL_PROFILES, DEFAULTS };