muse-crew 0.1.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 (42) hide show
  1. package/AGENTS.md +19 -0
  2. package/API.md +213 -0
  3. package/README.md +68 -0
  4. package/docs/AGENTS.md +3 -0
  5. package/docs/guide.md +333 -0
  6. package/identities/AGENTS.md +7 -0
  7. package/identities/cass.md +36 -0
  8. package/identities/hazel.md +35 -0
  9. package/identities/mara.md +31 -0
  10. package/identities/personality-notes.md +81 -0
  11. package/identities/sage.md +31 -0
  12. package/identities/tate.md +35 -0
  13. package/identities/wren.md +36 -0
  14. package/lib/AGENTS.md +8 -0
  15. package/lib/crew-release.sh +188 -0
  16. package/lib/merge-lock.sh +89 -0
  17. package/lib/orphan-sweep.sh +95 -0
  18. package/lib/worktree-lifecycle.sh +313 -0
  19. package/package.json +29 -0
  20. package/personas/AGENTS.md +5 -0
  21. package/personas/beginner.md +24 -0
  22. package/personas/completionist.md +24 -0
  23. package/personas/designer.md +26 -0
  24. package/personas/financial-stakeholder.md +25 -0
  25. package/personas/power-user.md +26 -0
  26. package/seed/AGENTS.md +8 -0
  27. package/seed/cron-body-template.md +20 -0
  28. package/seed/feedback/AGENTS.md +3 -0
  29. package/seed/feedback/README.md +40 -0
  30. package/seed/posture.md +8 -0
  31. package/seed/workflows/AGENTS.md +3 -0
  32. package/seed/workflows/bugfix.md +39 -0
  33. package/seed/workflows/chore.md +29 -0
  34. package/seed/workflows/docs.md +17 -0
  35. package/seed/workflows/standard.md +34 -0
  36. package/workflows/AGENTS.md +12 -0
  37. package/workflows/bugfix.js +341 -0
  38. package/workflows/chore.js +294 -0
  39. package/workflows/crew-dispatch.js +315 -0
  40. package/workflows/crew-init.js +254 -0
  41. package/workflows/docs.js +141 -0
  42. package/workflows/standard.js +350 -0
@@ -0,0 +1,294 @@
1
+ export const meta = {
2
+ name: "crew-chore",
3
+ description: "Chore workflow: Triage → Map → Build → Review → Integrate → Deploy",
4
+ phases: ["Triage", "Map", "Build", "Review", "Integrate", "Deploy"],
5
+ steps: [
6
+ { name: "Triage", identity: "sage" },
7
+ { name: "Map", identity: "mara" },
8
+ { name: "Build", identity: "wren" },
9
+ { name: "Review", identity: "cass" },
10
+ { name: "Integrate", identity: "wren" },
11
+ { name: "Deploy", identity: "wren" }
12
+ ],
13
+ reworkTarget: "Build"
14
+ };
15
+
16
+ const inputs = args ?? {};
17
+ const taskId = inputs.task_id;
18
+ const taskTitle = inputs.task_title || "";
19
+ const taskDescription = inputs.task_description || "";
20
+ const firstSessionId = inputs.session_id || null;
21
+ const startStepIndex = inputs.start_step_index || 0;
22
+
23
+ // Config from args — backward-compatible fallbacks for manual launches
24
+ const DASHBOARD_SLUG = inputs.dashboardSlug || "orchestra-dashboard";
25
+ const crewHome = inputs.crewHome || "~/workspace/.jarvis";
26
+ const ORCH_PATH = crewHome + "/.orchestration";
27
+ // Pin lifecycle scripts to this run
28
+ const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
29
+ const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
30
+ const RUN_LIB = "/tmp/crew-lib-" + taskId;
31
+ const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
32
+ const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
33
+
34
+ // Project config — passed by dispatcher, falls back to dashboard defaults
35
+ const projectConfig = inputs.project_config || {};
36
+ const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-dashboard";
37
+ const DEPLOY_TYPE = projectConfig.deploy_type || "artifact";
38
+ const DEPLOY_SLUG = projectConfig.deploy_slug || DASHBOARD_SLUG;
39
+ const PROJECT_DESC = projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)";
40
+ const RELEASE_SCRIPT = crewHome + "/crew-release.sh";
41
+
42
+ if (!taskId) {
43
+ throw new Error("task_id is required in args");
44
+ }
45
+
46
+ // Work-agent result schema. Runtime retries on non-JSON (structured outputs).
47
+ // Workflow wraps in try/catch so exhausted retries block instead of crashing.
48
+ const WORK_SCHEMA = {
49
+ type: "object",
50
+ properties: { passed: { type: "boolean" }, summary: { type: "string" } },
51
+ required: ["passed", "summary"]
52
+ };
53
+
54
+ // STEPS inline — export const meta is parsed as metadata, not a runtime binding
55
+ const STEPS = [
56
+ { name: "Triage", identity: "sage" },
57
+ { name: "Map", identity: "mara" },
58
+ { name: "Build", identity: "wren" },
59
+ { name: "Review", identity: "cass" },
60
+ { name: "Integrate", identity: "wren" },
61
+ { name: "Deploy", identity: "wren" }
62
+ ];
63
+ const BUILD_INDEX = STEPS.findIndex(s => s.name === 'Build');
64
+ if (BUILD_INDEX < 0) throw new Error("STEPS missing 'Build' step");
65
+ const MAX_REWORK = 2;
66
+ let reworkCount = 0;
67
+ let rejectionNotes = inputs.rejection_notes || "";
68
+ let mapperSpec = "";
69
+ let i = startStepIndex;
70
+
71
+ // Pin lifecycle scripts
72
+ await agent(
73
+ "Snapshot lifecycle scripts for version pinning.\n" +
74
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK,
75
+ { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
76
+ );
77
+
78
+ while (i < STEPS.length) {
79
+ const step = STEPS[i];
80
+ const isFirstClaim = (i === startStepIndex && reworkCount === 0);
81
+
82
+ phase(step.name);
83
+ log(step.name + " step (" + step.identity + ") for task " + taskId);
84
+
85
+ let activeSessionId;
86
+ if (isFirstClaim && firstSessionId) {
87
+ activeSessionId = firstSessionId;
88
+ } else {
89
+ const claimResult = await agent(
90
+ "Claim a session for this task step.\n" +
91
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
92
+ "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started" + (reworkCount > 0 ? " (rework #" + reworkCount + ")" : "") + "\" }.\n" +
93
+ "Return the session_id from the response.",
94
+ {
95
+ key: "claim-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
96
+ label: "Claiming " + step.name,
97
+ schema: {
98
+ type: "object",
99
+ properties: { session_id: { type: "string" } },
100
+ required: ["session_id"]
101
+ }
102
+ }
103
+ );
104
+ activeSessionId = claimResult.session_id;
105
+ }
106
+
107
+ var safeTitle = taskTitle.replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'");
108
+ var instructions = "";
109
+
110
+ if (step.name === "Triage") {
111
+ instructions = "Validate the task, check clarity, note dependencies, confirm the chore workflow assignment.\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"your assessment\", \"passed\": true }. No prose, no markdown, just the JSON object.";
112
+
113
+ } else if (step.name === "Map") {
114
+ instructions = "Research options, pick the path, write a clear spec for the builder.\nThe builder will edit source files in a git worktree of the project at " + REPO_PATH + ".\nProject: " + PROJECT_DESC + "\nTo understand the current code, read source files directly using the read tool. Do NOT use artifact_inspect — it is async and will not return in time.\nIdentify the exact files and changes needed. Be specific: file paths, what to add or change.\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"what you specified\", \"passed\": true }. No prose, no markdown, just the JSON object.";
115
+
116
+ } else if (step.name === "Build") {
117
+ instructions = "STEP 1: Prepare your worktree.\n" +
118
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " prepare " + taskId + "\n" +
119
+ "If the output says CREATED or REUSED, proceed. If it says ERROR, stop and set passed to false.\n\n" +
120
+ "STEP 2: Edit source files to implement the mapper's spec below.\n" +
121
+ (mapperSpec ? "MAPPER'S SPEC (implement exactly this):\n" + mapperSpec + "\n\n" : "") +
122
+ "Your working directory: " + REPO_PATH + "/.worktrees/" + taskId + "/\n" +
123
+ "This is the project source: " + PROJECT_DESC + "\n" +
124
+ "Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Deploy phase.\n" +
125
+ "Do not add unrequested features.\n\n" +
126
+ "STEP 3: Commit your changes.\n" +
127
+ "cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
128
+ "git add -A\n" +
129
+ "git commit -m \"chore: " + safeTitle + "\"\n\n" +
130
+ (rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
131
+ "Your final response MUST be valid JSON and nothing else: { \"summary\": \"what you built\", \"passed\": true }. No prose, no markdown, just the JSON object.";
132
+
133
+ } else if (step.name === "Review") {
134
+ instructions = "Review independently and cold. No prior context from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
135
+ (mapperSpec ? "MAPPER'S SPEC (the builder was asked to implement exactly this):\n" + mapperSpec + "\n\n" : "Read the spec from the task description.\n\n") +
136
+ "Examine the code changes by running:\n" +
137
+ "CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " inspect " + taskId + "\n\n" +
138
+ "You can also read specific files in the worktree at:\n" +
139
+ REPO_PATH + "/.worktrees/" + taskId + "/\n\n" +
140
+ "Check quality, correctness, spec compliance.\n" +
141
+ "If it passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
142
+ "If it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\n" +
143
+ "No prose, no markdown, just the JSON object.";
144
+
145
+ } else if (step.name === "Integrate") {
146
+ instructions = "Merge the approved task branch into main.\n\n" +
147
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
148
+ "Read the output:\n" +
149
+ "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
150
+ "- If it contains LOCK_HELD, another task is deploying. Set passed to false.\n" +
151
+ "- If it contains CONFLICT, a merge conflict occurred. Set passed to false with details.\n" +
152
+ "- If it contains ERROR, something else failed. Set passed to false.\n\n" +
153
+ "Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true/false }. No prose, no markdown, just the JSON object.";
154
+
155
+ } else if (step.name === "Deploy") {
156
+ if (DEPLOY_TYPE === "repo") {
157
+ // Repo projects: install immutable release and atomically activate
158
+ instructions = "Deploy repo changes via the immutable release system.\n\n" +
159
+ "STEP 0: Refresh the merge lock to prevent stale-lock breaking during deploy.\n" +
160
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
161
+ "STEP 1: Install and activate the new release.\n" +
162
+ "Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
163
+ "Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
164
+ "STEP 2: Finalize.\n" +
165
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
166
+ "If the output contains DEPLOYED, finalization is complete.\n\n" +
167
+ "Your final response MUST be valid JSON and nothing else: { \"summary\": \"release activated and finalized\", \"passed\": true }.\n" +
168
+ "No prose, no markdown, just the JSON object.";
169
+ } else {
170
+ instructions = "Deploy the merged code to the live artifact.\n\n" +
171
+ "STEP 0: Refresh the merge lock to prevent stale-lock breaking during deploy.\n" +
172
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
173
+ "STEP 1: Deploy to the live artifact.\n" +
174
+ "Get the change summary: cd " + REPO_PATH + " && git log -1 --stat\n" +
175
+ "Then call artifact_edit with slug \"" + DEPLOY_SLUG + "\" and verbatim_request:\n" +
176
+ "'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
177
+ "Wait for the build to complete by polling artifact_status until it is no longer running.\n\n" +
178
+ "STEP 2: Finalize.\n" +
179
+ "Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
180
+ "If the output contains DEPLOYED, deployment is complete.\n\n" +
181
+ "If artifact_edit failed, still run post-deploy to release the merge lock and clean up.\n" +
182
+ "Report the failure: { \"passed\": false, \"summary\": \"artifact deployment failed: [details]\" }.\n\n" +
183
+ "Your final response MUST be valid JSON and nothing else: { \"summary\": \"deployed changes\", \"passed\": true }.\n" +
184
+ "No prose, no markdown, just the JSON object.";
185
+ }
186
+ }
187
+
188
+ // Task event history — all phases except Review see the comment log
189
+ var eventPreamble = "";
190
+ if (step.name !== "Review") {
191
+ eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
192
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"limit\": 100 }.\n" +
193
+ "Look through returned events for entries matching task_id \"" + taskId + "\". They contain notes and decisions from prior phases.\n\n";
194
+ }
195
+
196
+ var stepResult;
197
+ try {
198
+ stepResult = await agent(
199
+ "Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
200
+ "## Your Assignment\n\n" +
201
+ "Task: " + taskTitle + "\nTask ID: " + taskId + "\nDescription: " + taskDescription + "\nStep: " + step.name + "\n" +
202
+ (step.name !== "Review" ? "Dashboard slug: " + DASHBOARD_SLUG + "\n" : "") +
203
+ "\n## Instructions\n\n" + eventPreamble + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. Do the work thoroughly.",
204
+ {
205
+ key: "work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
206
+ label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"",
207
+ timeoutMs: 3600000,
208
+ schema: WORK_SCHEMA
209
+ }
210
+ );
211
+ } catch (e) {
212
+ log(step.name + " agent failed: " + (e.message || String(e)).slice(0, 500));
213
+ stepResult = null;
214
+ }
215
+
216
+ // If schema retries were exhausted, block gracefully
217
+ if (!stepResult || typeof stepResult.summary !== "string") {
218
+ log(step.name + " closeout unrecoverable — blocking");
219
+ await agent(
220
+ "Record closeout failure.\n" +
221
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
222
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"blocked\", \"notes\": \"Agent failed to return structured result after retries\" }.\n" +
223
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
224
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"blocked\", \"message\": \"" + step.name + " agent failed after retries — workflow blocked\" }.",
225
+ { key: "record-block-" + step.name, label: "Recording closeout failure", schema: { type: "object" } }
226
+ );
227
+ return {
228
+ __hatchWorkflowControl: "blocked",
229
+ result: {
230
+ blocked_reason: step.name + " agent failed after retries",
231
+ message: "The " + step.identity + " agent's work may be valid — structured output failed.",
232
+ task_id: taskId
233
+ }
234
+ };
235
+ }
236
+
237
+ const summary = (stepResult.summary || "Step completed").slice(0, 2000);
238
+ const passed = stepResult.passed !== false;
239
+ const status = passed ? "completed" : "rejected";
240
+
241
+ // Capture mapper's spec for Build and Review
242
+ if (step.name === "Map" && passed) {
243
+ mapperSpec = summary;
244
+ }
245
+
246
+ await agent(
247
+ "Update session and log event.\n" +
248
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
249
+ "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"" + status + "\", \"notes\": " + JSON.stringify(summary) + " }.\n" +
250
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
251
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"" + status + "\", \"identity\": \"" + step.identity + "\", \"message\": \"" + step.name + " " + status + " by " + step.identity + "\" }.",
252
+ {
253
+ key: "record-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
254
+ label: "Recording " + step.name + " result",
255
+ schema: { type: "object" }
256
+ }
257
+ );
258
+
259
+ if (!passed && step.name === "Review") {
260
+ reworkCount++;
261
+ if (reworkCount > MAX_REWORK) {
262
+ log("Max rework attempts reached for task " + taskId + " — worktree preserved at .worktrees/" + taskId + " for manual inspection");
263
+ return { status: "blocked", task_id: taskId, reason: "Exceeded " + MAX_REWORK + " rework attempts after Review rejection. Worktree preserved." };
264
+ }
265
+ rejectionNotes = summary;
266
+ i = BUILD_INDEX;
267
+ log("Review rejected — bouncing to Build (rework #" + reworkCount + ")");
268
+ continue;
269
+ }
270
+
271
+ if (!passed && step.name === "Integrate") {
272
+ log("Integration failed for task " + taskId + ": " + summary);
273
+ return { status: "blocked", task_id: taskId, reason: "Integration failed: " + summary };
274
+ }
275
+
276
+ if (!passed && step.name === "Deploy") {
277
+ log("Deploy failed for task " + taskId + ": " + summary);
278
+ return { status: "blocked", task_id: taskId, reason: "Deploy failed: " + summary };
279
+ }
280
+
281
+ i++;
282
+ }
283
+
284
+ await agent(
285
+ "Mark this task as done.\n" +
286
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"done\" }.\n" +
287
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
288
+ "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"message\": \"All chore workflow steps complete.\" }.",
289
+ { key: "task-done", label: "Completing task: " + taskTitle, schema: { type: "object" } }
290
+ );
291
+
292
+ log("Chore workflow complete for task " + taskId);
293
+ await agent("Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB, { key: "cleanup-pins", label: "Cleaning pinned scripts", schema: { type: "object" } });
294
+ return { status: "ok", task_id: taskId, message: "Chore workflow complete for " + taskTitle };
@@ -0,0 +1,315 @@
1
+ export const meta = {
2
+ name: "crew-dispatch",
3
+ description: "Thin Muse Crew dispatcher — reads the board, claims eligible tasks, launches the right workflow script per task.",
4
+ phases: ["dispatch"]
5
+ };
6
+
7
+ // Config from args — no instance constants
8
+ const inputs = args ?? {};
9
+ const DASHBOARD_SLUG = inputs.dashboardSlug;
10
+ const crewHome = inputs.crewHome;
11
+
12
+ if (!DASHBOARD_SLUG) throw new Error("dashboardSlug is required");
13
+ if (!crewHome) throw new Error("crewHome is required");
14
+ const WORKFLOW_DIR = crewHome + "/workflows";
15
+
16
+ // Project config is built from dashboard project records (getdispatchstate)
17
+ // — no separate registry file needed.
18
+
19
+ // Load canonical step definitions from workflow files (single source of truth)
20
+ const registryResult = await agent(
21
+ "Read the following 4 workflow files using the read tool and extract the `steps` array and `reworkTarget` string from each file's `export const meta` block at the top of the file.\n\n" +
22
+ "Files:\n" +
23
+ "1. " + WORKFLOW_DIR + "/standard.js\n" +
24
+ "2. " + WORKFLOW_DIR + "/bugfix.js\n" +
25
+ "3. " + WORKFLOW_DIR + "/chore.js\n" +
26
+ "4. " + WORKFLOW_DIR + "/docs.js\n\n" +
27
+ "Return an object with keys: standard, bugfix, chore, docs. Each value has { steps: [{name, identity}], reworkTarget: string }.",
28
+ {
29
+ key: "load-registry",
30
+ label: "Loading workflow step registry",
31
+ schema: {
32
+ type: "object",
33
+ properties: {
34
+ standard: { type: "object" },
35
+ bugfix: { type: "object" },
36
+ chore: { type: "object" },
37
+ docs: { type: "object" }
38
+ },
39
+ required: ["standard", "bugfix", "chore", "docs"]
40
+ }
41
+ }
42
+ );
43
+
44
+ // Derive lookup tables from canonical definitions
45
+ const WORKFLOWS = {};
46
+ const STEP_IDENTITY = {};
47
+ const BUILD_STEPS = {};
48
+ var wfNames = ["standard", "bugfix", "chore", "docs"];
49
+ for (var wi = 0; wi < wfNames.length; wi++) {
50
+ var wfName = wfNames[wi];
51
+ var wfData = registryResult[wfName];
52
+ WORKFLOWS[wfName] = wfData.steps.map(function(s) { return s.name; });
53
+ BUILD_STEPS[wfName] = wfData.reworkTarget;
54
+ for (var si = 0; si < wfData.steps.length; si++) {
55
+ STEP_IDENTITY[wfData.steps[si].name] = wfData.steps[si].identity;
56
+ }
57
+ }
58
+
59
+ phase("dispatch");
60
+
61
+ // ── 1. Read the board ────────────────────────────────────────────────
62
+ const boardResult = await agent(
63
+ "Read the dispatch state.\n" +
64
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getdispatchstate\", args {}.\n" +
65
+ "Return the full JSON response — ready_tasks array, projects array, config object, counts object.",
66
+ {
67
+ key: "read-board",
68
+ label: "Reading board state",
69
+ schema: {
70
+ type: "object",
71
+ properties: {
72
+ ready_tasks: { type: "array" },
73
+ projects: { type: "array" },
74
+ config: { type: "object" },
75
+ counts: { type: "object" }
76
+ },
77
+ required: ["ready_tasks"]
78
+ }
79
+ }
80
+ );
81
+
82
+ const allTasks = boardResult.ready_tasks || [];
83
+ const config = boardResult.config || {};
84
+ const projects = boardResult.projects || [];
85
+
86
+ // Default project: explicit arg, or first registered project
87
+ const DEFAULT_PROJECT = inputs.defaultProject || (projects.length > 0 ? projects[0].id : "");
88
+
89
+ // Per-project quiesce: build a set of quiesced project IDs
90
+ const quiescedProjects = {};
91
+ for (var qi = 0; qi < projects.length; qi++) {
92
+ if (projects[qi].quiesced) {
93
+ quiescedProjects[projects[qi].id] = true;
94
+ }
95
+ }
96
+
97
+ // Per-project simultaneity: build a map, fall back to global config
98
+ // Explicit 0 means "paused" — do not destroy it with || fallback
99
+ const projectSimultaneity = {};
100
+ for (var si = 0; si < projects.length; si++) {
101
+ var rawSim = Number(projects[si].simultaneity);
102
+ projectSimultaneity[projects[si].id] = isNaN(rawSim) ? 2 : rawSim;
103
+ }
104
+ var rawGlobal = Number(config.simultaneity);
105
+ const globalSimultaneity = isNaN(rawGlobal) ? 1 : rawGlobal;
106
+
107
+ // Build project config map from dashboard records (single source of truth)
108
+ var PROJECTS = {};
109
+ for (var pi = 0; pi < projects.length; pi++) {
110
+ var proj = projects[pi];
111
+ PROJECTS[proj.id] = {
112
+ repo_path: proj.repo_path,
113
+ deploy_type: proj.deploy_type,
114
+ deploy_slug: proj.deploy_slug,
115
+ description: proj.description
116
+ };
117
+ }
118
+
119
+ log("Board: " + allTasks.length + " active tasks, quiesced=" + Object.keys(quiescedProjects).join(","));
120
+
121
+ // ── 2. Determine eligible tasks ──────────────────────────────────────
122
+ const eligible = [];
123
+
124
+ for (var t = 0; t < allTasks.length; t++) {
125
+ var task = allTasks[t];
126
+ if (task.blocked) continue;
127
+
128
+ // Skip tasks from quiesced projects
129
+ var taskProject = task.project || DEFAULT_PROJECT;
130
+ if (quiescedProjects[taskProject]) {
131
+ log("Skipped \"" + task.title + "\" — project " + taskProject + " is quiesced");
132
+ continue;
133
+ }
134
+
135
+ var latest = task.latest_session;
136
+ var workflow = task.workflow || "standard";
137
+ var steps = WORKFLOWS[workflow] || WORKFLOWS.standard;
138
+
139
+ if (task.state === "todo") {
140
+ eligible.push({ task: task, startStep: 0, reason: "new", workflow: workflow });
141
+ continue;
142
+ }
143
+
144
+ if (task.state !== "in_progress") continue;
145
+
146
+ if (!latest) {
147
+ eligible.push({ task: task, startStep: 0, reason: "no_session", workflow: workflow });
148
+ continue;
149
+ }
150
+
151
+ if (latest.status === "running") continue; // work in flight
152
+
153
+ if (latest.status === "completed") {
154
+ var stepName = latest.step || "";
155
+ var stepIndex = steps.indexOf(stepName);
156
+ var nextIndex = stepIndex + 1;
157
+
158
+ if (nextIndex >= steps.length) {
159
+ eligible.push({ task: task, action: "complete", workflow: workflow });
160
+ } else {
161
+ eligible.push({ task: task, startStep: nextIndex, reason: "next_step", workflow: workflow });
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (latest.status === "rejected") {
167
+ var buildStepName = BUILD_STEPS[workflow] || "Build";
168
+ var buildIdx = steps.indexOf(buildStepName);
169
+ if (buildIdx >= 0) {
170
+ eligible.push({ task: task, startStep: buildIdx, reason: "rework", workflow: workflow, rejectionNotes: latest.notes || "" });
171
+ }
172
+ continue;
173
+ }
174
+
175
+ if (latest.status === "failed" || latest.status === "timed_out") {
176
+ var failedStep = latest.step || "";
177
+ var retryIdx = steps.indexOf(failedStep);
178
+ eligible.push({ task: task, startStep: retryIdx >= 0 ? retryIdx : 0, reason: "retry", workflow: workflow });
179
+ continue;
180
+ }
181
+ }
182
+
183
+ log("Eligible: " + eligible.length + " tasks");
184
+
185
+ if (eligible.length === 0) {
186
+ await agent(
187
+ "Acknowledge the poll.\nCall artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"acknowledge_poll\", args {}.",
188
+ { key: "ack-empty", label: "Acknowledging poll (nothing to do)", schema: { type: "object" } }
189
+ );
190
+ return { status: "ok", message: "No tasks ready." };
191
+ }
192
+
193
+ // ── 3. Process eligible tasks, respecting per-project simultaneity ───
194
+ // Count in-flight tasks per project to enforce per-project limits
195
+ var inFlightByProject = {};
196
+ for (var ct = 0; ct < allTasks.length; ct++) {
197
+ var ctask = allTasks[ct];
198
+ if (ctask.state === "in_progress" && ctask.latest_session && ctask.latest_session.status === "running") {
199
+ var cp = ctask.project || DEFAULT_PROJECT;
200
+ inFlightByProject[cp] = (inFlightByProject[cp] || 0) + 1;
201
+ }
202
+ }
203
+
204
+ var toProcess = [];
205
+ for (var ei = 0; ei < eligible.length; ei++) {
206
+ var eitem = eligible[ei];
207
+ var ep = eitem.task.project || DEFAULT_PROJECT;
208
+ var limit = (ep in projectSimultaneity) ? projectSimultaneity[ep] : globalSimultaneity;
209
+ var current = inFlightByProject[ep] || 0;
210
+ if (current < limit) {
211
+ toProcess.push(eitem);
212
+ inFlightByProject[ep] = current + 1;
213
+ } else {
214
+ log("Skipped \"" + eitem.task.title + "\" — project " + ep + " at simultaneity limit (" + limit + ")");
215
+ }
216
+ }
217
+ var results = [];
218
+
219
+ for (var p = 0; p < toProcess.length; p++) {
220
+ var item = toProcess[p];
221
+ var itask = item.task;
222
+ var iworkflow = item.workflow;
223
+ var isteps = WORKFLOWS[iworkflow] || WORKFLOWS.standard;
224
+
225
+ if (item.action === "complete") {
226
+ await agent(
227
+ "Mark task done and log completion.\n" +
228
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + itask.id + "\", \"state\": \"done\" }.\n" +
229
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args: { \"task_id\": \"" + itask.id + "\", \"type\": \"completed\", \"message\": \"All workflow steps complete.\" }.",
230
+ { key: "done-" + itask.id, label: "Completing: " + itask.title, schema: { type: "object" } }
231
+ );
232
+ results.push({ task_id: itask.id, action: "completed" });
233
+ continue;
234
+ }
235
+
236
+ var nextStepName = isteps[item.startStep];
237
+ var identity = STEP_IDENTITY[nextStepName] || "sage";
238
+
239
+ // Set to in_progress if todo
240
+ if (itask.state === "todo") {
241
+ await agent(
242
+ "Set task to in_progress.\n" +
243
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + itask.id + "\", \"state\": \"in_progress\" }.",
244
+ { key: "activate-" + itask.id, label: "Activating: " + itask.title, schema: { type: "object" } }
245
+ );
246
+ }
247
+
248
+ // Atomic claim — check response before proceeding
249
+ var claimResult = await agent(
250
+ "Claim the task for the " + nextStepName + " step.\n" +
251
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
252
+ "{ \"task_id\": \"" + itask.id + "\", \"identity\": \"" + identity + "\", \"step\": \"" + nextStepName + "\", \"notes\": \"" + nextStepName + " step started\" }.\n" +
253
+ "Check the response. If the task was successfully claimed (a new session was created), return { claimed: true, session_id: \"<the new session id>\" }.\n" +
254
+ "If the task was already claimed by another tick (already has a running session), return { claimed: false, session_id: \"\" }.",
255
+ {
256
+ key: "claim-" + itask.id,
257
+ label: "Claiming " + nextStepName + " for: " + itask.title,
258
+ schema: {
259
+ type: "object",
260
+ properties: {
261
+ claimed: { type: "boolean" },
262
+ session_id: { type: "string" }
263
+ },
264
+ required: ["claimed", "session_id"]
265
+ }
266
+ }
267
+ );
268
+
269
+ if (!claimResult.claimed) {
270
+ log("Skipped \"" + itask.title + "\" — already claimed by another tick");
271
+ results.push({ task_id: itask.id, action: "skipped" });
272
+ continue;
273
+ }
274
+
275
+ // Return claim for the caller to launch (workflows can't launch workflows)
276
+ var scriptPath = WORKFLOW_DIR + "/" + iworkflow + ".js";
277
+ var taskProject = itask.project || DEFAULT_PROJECT;
278
+ var projectCfg = PROJECTS[taskProject] || PROJECTS[DEFAULT_PROJECT];
279
+ var launchArgs = {
280
+ task_id: itask.id,
281
+ task_title: itask.title || "",
282
+ task_description: itask.description || "",
283
+ session_id: claimResult.session_id,
284
+ start_step_index: item.startStep,
285
+ rejection_notes: item.rejectionNotes || "",
286
+ project_config: projectCfg,
287
+ dashboardSlug: DASHBOARD_SLUG,
288
+ crewHome: crewHome
289
+ };
290
+
291
+ log("Claimed " + iworkflow + " for \"" + itask.title + "\" at step " + nextStepName);
292
+ results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "claimed", scriptPath: scriptPath, args: launchArgs });
293
+ }
294
+
295
+ // ── 4. Acknowledge ───────────────────────────────────────────────────
296
+ await agent(
297
+ "Acknowledge the poll.\nCall artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"acknowledge_poll\", args {}.",
298
+ { key: "ack-final", label: "Acknowledging poll", schema: { type: "object" } }
299
+ );
300
+
301
+ var claimed = results.filter(function(r) { return r.action === "claimed"; });
302
+ var completed = results.filter(function(r) { return r.action === "completed"; });
303
+ var msg = "Dispatch complete.";
304
+ if (claimed.length > 0) {
305
+ msg += " Claimed: " + claimed.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
306
+ }
307
+ if (completed.length > 0) {
308
+ msg += " Completed: " + completed.map(function(r) { return r.task_id; }).join(", ") + ".";
309
+ }
310
+ var skipped = results.filter(function(r) { return r.action === "skipped"; });
311
+ if (skipped.length > 0) {
312
+ msg += " Skipped (already claimed): " + skipped.length + ".";
313
+ }
314
+
315
+ return { status: "ok", message: msg, claims: claimed };