muse-crew 0.7.6 → 0.7.8

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/API.md CHANGED
@@ -293,6 +293,11 @@ Returns `{ ok: true, runs: [...], events: [...] }`.
293
293
 
294
294
  The platform records workflow run status in `runtime.workflow_runs` — separate from our telemetry. When a workflow dies on a fatal `agent()` failure (e.g. "subagent bootstrap is no longer authorized"), our telemetry shows a run with no events, but the platform knows why. This monitor correlates platform failures with our tasks.
295
295
 
296
+ Two layers, in order of preference:
297
+
298
+ 1. **Prevention — the launcher stays alive.** Async workflow `agent()` authorization is tied to the launcher's lifetime: if the cron tick ends while a workflow is still running, the workflow's next `agent()` call fails. So the poll tick runs with a 90-minute execution timeout (`timeout_secs: 5400` in `seed/crons.json`) and its Step 5 monitor stays alive until every launched run reaches a terminal state. A launcher that outlives its runs never triggers the failure in the first place.
299
+ 2. **Recovery — per-tick correlation through the durable mapping.** If the launcher dies early anyway (platform kill, cell recycle), the next tick's Step 0 finds the dead run, `record-platform-failure` correlates it via the `platform_run_tasks` mapping, and `retry-platform-failure` requeues the task — at most ~3 minutes later, with the ghost session settled so the dispatcher treats it as a retry candidate immediately.
300
+
296
301
  ### `record-platform-failure`
297
302
 
298
303
  Record a platform workflow run failure. Correlates the task in this order: (1) the durable `platform_run_tasks` mapping written by `acknowledge-dispatch-run`, (2) timestamp proximity (±60s) between `platform_created_at` and crew telemetry launch time (fallback for runs launched before the mapping existed), (3) an explicit `crew_run_id`/`task_id` when provided. A failure that correlates to no task is kept with `task_id: null` and reported as skipped by retry.
@@ -324,6 +329,12 @@ Returns `{ ok: true, failures: [...] }`.
324
329
 
325
330
  Retry a task whose workflow died on a platform failure. Clears the stale reservation, re-queues the task to `todo`, and increments the retry count. If `retry_count` >= `max_retries` (default 3), parks the task instead.
326
331
 
332
+ **Ghost-session settlement:** the dead workflow's `agent_sessions` row still says `running`, and the dispatcher skips any task whose latest session is running ("work in flight"). The retry settles it atomically with the state change, so the task is redispatchable on the very next tick instead of waiting out the 1-hour zombie-session sweep:
333
+ - on `requeued`: running sessions → `stalled` (the dispatcher already treats `stalled` as a retry candidate);
334
+ - on `parked`: running sessions → `failed` (what `recover-task` accepts), same as `park-task`.
335
+
336
+ Returns `{ ok: true, action: "requeued"|"parked"|"skipped", ..., settled_sessions: <n> }`.
337
+
327
338
  **Supersede guard:** if a newer platform run has been linked for this task since this failure's run, the task is already owned by the successor — the retry is skipped instead of clobbering live work (a late detection of an old dead run never resets a redispatched task).
328
339
 
329
340
  | Field | Type | Notes |
package/docs/guide.md CHANGED
@@ -99,6 +99,30 @@ After init completes, it returns a summary:
99
99
 
100
100
  Re-running init against an existing setup returns `"unchanged"` (or `"updated"` with the converged field names) for each cron, with no other mutations.
101
101
 
102
+ ### Uninstall
103
+
104
+ Uninstalling a crew removes its scheduler crons and deletes the crew home — one flow, never a manual two-step. Launch it as a workflow through the agent's workflow tools:
105
+
106
+ ```
107
+ workflow_launch with:
108
+ scriptPath: "<install-path>/workflows/crew-uninstall.js"
109
+ args: {
110
+ crewHome: "~/workspace/.my-crew",
111
+ confirm: true
112
+ }
113
+ ```
114
+
115
+ `crewHome` is required; `confirm` must be exactly `true` or the run blocks. `force` (default `false`) overrides the live-work check described below.
116
+
117
+ The run has four phases:
118
+
119
+ 1. **Gates** — the crew home must be inside the workspace (uninstall refuses to delete anything outside it), and `confirm: true` must be present. Either missing blocks the run before anything is touched.
120
+ 2. **Safety** — if any task is `in_progress`, the run blocks unless `force: true`. Uninstalling under live workflows would strand them.
121
+ 3. **Crons** — removes the crew's scheduler jobs, and only those: the exact ids from `$CREW_HOME/.cron-registry.json` (written by init; covers id overrides), plus discovery of `crew-poll-*` jobs whose body contains the crew home path (covers installs from before the registry existed). Anything not on that union is never touched — shared crons are safe by construction, and a missing home still gets its orphaned crons removed.
122
+ 4. **Home** — best-effort `git worktree prune` on the crew's project repos, then deletes the crew home directory and verifies it is gone.
123
+
124
+ Dashboard artifacts are left untouched — uninstall removes the crew instance (its crons and its home), never the user's artifacts.
125
+
102
126
  ## Connecting an existing project
103
127
 
104
128
  Projects are registered through the API, not through init. Init sets up the infrastructure and registers the task service as the first project; you register additional projects separately.
package/lib/crew-api.js CHANGED
@@ -847,14 +847,32 @@ commands["retry-platform-failure"] = (db, args) => {
847
847
  if (task && task.state !== "parked") {
848
848
  const msg = "Platform workflow failed " + (failure.retry_count + 1) + "x: " +
849
849
  failure.error_message.substring(0, 200);
850
- db.prepare("UPDATE tasks SET state = 'parked', updated_at = datetime('now') WHERE id = ?")
851
- .run(failure.task_id);
852
- db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
853
- db.prepare(
854
- `INSERT INTO events (task_id, event_type, message) VALUES (?, 'parked', ?)`
855
- ).run(failure.task_id, msg);
850
+ const ts = new Date().toISOString();
851
+ db.exec("BEGIN");
852
+ try {
853
+ db.prepare("UPDATE tasks SET state = 'parked', updated_at = datetime('now') WHERE id = ?")
854
+ .run(failure.task_id);
855
+ db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
856
+ // Settle the ghost session: a parked task never keeps a running
857
+ // session, and 'failed' is what recover-task accepts.
858
+ const settled = db.prepare(
859
+ `UPDATE agent_sessions
860
+ SET status = 'failed', ended_at = ?,
861
+ notes = COALESCE(notes, '') || ' Parked: platform retry exhausted.'
862
+ WHERE task_id = ? AND status = 'running'`
863
+ ).run(ts, failure.task_id);
864
+ db.prepare(
865
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
866
+ VALUES (?, 'note', ?, NULL, ?, ?)`
867
+ ).run(uuid(), failure.task_id, "Parked: " + msg, ts);
868
+ db.exec("COMMIT");
869
+ return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes) };
870
+ } catch (e) {
871
+ db.exec("ROLLBACK");
872
+ throw e;
873
+ }
856
874
  }
857
- return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count };
875
+ return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: 0 };
858
876
  }
859
877
  // Clear reservation and re-queue for retry.
860
878
  db.exec("BEGIN");
@@ -863,22 +881,36 @@ commands["retry-platform-failure"] = (db, args) => {
863
881
  db.prepare(
864
882
  "UPDATE tasks SET state = 'todo', updated_at = datetime('now') WHERE id = ? AND state != 'done'"
865
883
  ).run(failure.task_id);
884
+ // Settle the ghost session atomically with the requeue: the platform
885
+ // workflow is dead, but its agent_sessions row still says 'running' —
886
+ // and the dispatcher skips any task whose latest session is running
887
+ // (crew-dispatch.js "work in flight"). Without this, the requeued task
888
+ // would sit until the 1-hour zombie-session sweep. 'stalled' is what
889
+ // the dispatcher already treats as a retry candidate.
890
+ const ts = new Date().toISOString();
891
+ const settled = db.prepare(
892
+ `UPDATE agent_sessions
893
+ SET status = 'stalled', ended_at = ?,
894
+ notes = COALESCE(notes, '') || ' Platform retry: launcher died; session settled for redispatch.'
895
+ WHERE task_id = ? AND status = 'running'`
896
+ ).run(ts, failure.task_id);
866
897
  db.prepare(
867
898
  `UPDATE platform_run_failures
868
899
  SET retry_count = retry_count + 1, last_retry_at = datetime('now')
869
900
  WHERE platform_run_id = ?`
870
901
  ).run(args.platform_run_id);
871
902
  db.exec("COMMIT");
903
+ return {
904
+ ok: true,
905
+ action: "requeued",
906
+ task_id: failure.task_id,
907
+ retry_count: failure.retry_count + 1,
908
+ settled_sessions: Number(settled.changes),
909
+ };
872
910
  } catch (e) {
873
911
  db.exec("ROLLBACK");
874
912
  throw e;
875
913
  }
876
- return {
877
- ok: true,
878
- action: "requeued",
879
- task_id: failure.task_id,
880
- retry_count: failure.retry_count + 1,
881
- };
882
914
  };
883
915
 
884
916
  commands["park-task"] = (db, args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -36,21 +36,22 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
36
36
 
37
37
  If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH and exit.
38
38
 
39
- 5. **Monitor launched workflows within this tick (tick-bounded — 2026-09-13):** The platform ties async workflow subagent authorization to the launcher's lifetime: when THIS tick ends (120s timeout), any still-running workflow's next `agent()` call may fail with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". You cannot prevent that by staying alive — this tick WILL end at 120s, so a "stay alive until terminal" monitor is fiction. The design that survives launcher death is per-tick recovery: watch while you live, and let the next tick's Step 0 continue through the durable platform run -> task mapping.
40
- - For each launched run_id, poll its status every ~20 seconds via muse.db (you have ~100s of monitoring budget — leave margin before the 120s kill):
39
+ 5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` your death is what kills it.
40
+ - For each launched run_id, poll its status every ~2 minutes via muse.db:
41
41
  ```sql
42
42
  SELECT status, error FROM runtime.workflow_runs WHERE run_id = '<run_id>'
43
43
  ```
44
44
  - **If status is `completed`:** Done. Log success and stop monitoring this run.
45
45
  - **If status is `failed`:** Check if it's a platform `agent()` error (error contains "subagent bootstrap", "reservation owner is terminal", "bootstrap was cancelled", or "workflow agent call failed"):
46
- - **Platform error:** Record it and retry immediately (same two commands as Step 0). If the retry says `requeued` AND you have at least ~40s left in this tick AND retries remain (max 3 per task per tick):
46
+ - **Platform error:** Record it and retry immediately (same two commands as Step 0). If the retry says `requeued` and retries remain (max 3 per task):
47
47
  - Re-acquire the reservation: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} reserve-dispatch --json '{"task_id": "<task_id>"}'`
48
48
  - If `acquired` is true, re-launch via workflow_launch_async with the same scriptPath and args, acknowledge with the new run_id, and continue monitoring the NEW run_id.
49
49
  - If `acquired` is false, stop — another dispatcher claimed it.
50
- If the retry result says `parked` (3 attempts exhausted), stop monitoring this task.
50
+ If the retry result says `parked` (attempts exhausted), stop monitoring this task.
51
51
  - **Task-level error (not a platform error):** The workflow's own error handling applies. Stop monitoring this run.
52
52
  - **If status is `running` or `paused`:** Continue polling.
53
- - **Tick end:** At ~100s elapsed, stop monitoring and exit. Do NOT try to outlive the 120s timeout — the monitor's job is not to prevent launcher death (impossible) but to recover from it fast. Anything that dies after you leave is caught by the next tick's Step 0 via the mapping, at most ~3 minutes later. That is the designed recovery path, not a fallback.
53
+ - **Monitor ceiling:** 90 minutes from tick start. If a run is still not terminal then (pathological — the work phase caps each `agent()` call at 60 minutes), exit; the next tick's Step 0 continues recovery through the durable mapping.
54
+ - **Backstop (not the plan, the insurance):** If THIS launcher dies early for any reason (platform kill, cell recycle), the next tick's Step 0 detects the dead run through the durable platform run -> task mapping and retries it — at most ~3 minutes later. The mapping exists so a dead launcher never strands a task for an hour; the monitor exists so the launcher rarely dies mid-run in the first place.
54
55
  - When all launched workflows are terminal or retry-exhausted, exit silently.
55
56
 
56
57
  6. Exit silently.
package/seed/crons.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "every": "3m",
11
11
  "kind": "interval"
12
12
  },
13
- "timeout_secs": 120,
13
+ "timeout_secs": 5400,
14
14
  "title": "Muse Crew polling loop"
15
15
  }
16
16
  ],
@@ -363,9 +363,18 @@ try {
363
363
  " Call cron_update with only the fields that differ. If nothing differs,\n" +
364
364
  " do not call cron_update. Record action 'updated' with the updated field\n" +
365
365
  " names, or 'unchanged' with no updated fields.\n" +
366
- "3. Return { passed: true, summary: { crons: [...] } } with one entry per\n" +
367
- " manifest entry, in manifest order: { id, action: 'created' | 'updated' |\n" +
368
- " 'unchanged', updated_fields: [...] }.",
366
+ "3. Write the cron registry at " + crewHome + "/.cron-registry.json the exact\n" +
367
+ " record a later uninstall uses to find these jobs. Build this JSON (one entry\n" +
368
+ " per manifest entry, in manifest order, using the live ids from step 2):\n" +
369
+ " {\"version\":1,\"instanceId\":\"" + instanceId + "\",\"owner\":\"" + cronOwner + "\",\n" +
370
+ " \"crons\":[{\"id\":<live id>,\"manifestId\":<entry.id>}, ...]}\n" +
371
+ " Write it with printf '%s' and a single-quoted heredoc so the shell\n" +
372
+ " interpolates nothing, then read it back to confirm it parses.\n" +
373
+ "4. Return { passed: true, summary: { crons: [...], registry: {...} } } with one\n" +
374
+ " crons entry per manifest entry, in manifest order:\n" +
375
+ " { id, action: 'created' | 'updated' | 'unchanged', updated_fields: [...] },\n" +
376
+ " and registry: { path: \"" + crewHome + "/.cron-registry.json\",\n" +
377
+ " ids: [<live id>, ...] } echoing the ids written to disk.",
369
378
  {
370
379
  key: "crons-1",
371
380
  label: "Create and converge cron jobs",
@@ -387,9 +396,17 @@ try {
387
396
  },
388
397
  required: ["id", "action", "updated_fields"]
389
398
  }
399
+ },
400
+ registry: {
401
+ type: "object",
402
+ properties: {
403
+ path: { type: "string" },
404
+ ids: { type: "array", items: { type: "string" } }
405
+ },
406
+ required: ["path", "ids"]
390
407
  }
391
408
  },
392
- required: ["crons"]
409
+ required: ["crons", "registry"]
393
410
  }
394
411
  },
395
412
  required: ["passed", "summary"]
@@ -399,10 +416,12 @@ try {
399
416
  } catch (e) {
400
417
  return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron setup failed", message: String(e.message || e) } };
401
418
  }
402
- if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.crons)) {
403
- return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron manifest invalid", message: "seed/crons.json failed validation or produced no crons list" } };
419
+ if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.crons) ||
420
+ !cronsResult.summary.registry || !Array.isArray(cronsResult.summary.registry.ids)) {
421
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron manifest invalid", message: "seed/crons.json failed validation, produced no crons list, or wrote no cron registry" } };
404
422
  }
405
- log("Crons: " + cronsResult.summary.crons.map(function (c) { return c.id + "=" + c.action; }).join(", "));
423
+ log("Crons: " + cronsResult.summary.crons.map(function (c) { return c.id + "=" + c.action; }).join(", ") +
424
+ "; registry: " + cronsResult.summary.registry.path + " (" + cronsResult.summary.registry.ids.join(", ") + ")");
406
425
 
407
426
 
408
427
  // ── Summary ───────────────────────────────────────────────────────────
@@ -416,5 +435,6 @@ return {
416
435
  releaseHash: releaseResult.hash,
417
436
  scaffold: { created: scaffoldCreated, skipped: scaffoldSkipped },
418
437
  project: projectResult.action,
419
- crons: cronsResult.summary.crons
438
+ crons: cronsResult.summary.crons,
439
+ registry: cronsResult.summary.registry
420
440
  };
@@ -0,0 +1,264 @@
1
+ export const meta = {
2
+ name: "crew-uninstall",
3
+ description: "Uninstall a Muse Crew instance: remove its scheduler cron jobs, then delete the crew home directory. Destructive and irreversible — requires confirm: true, and refuses to run while any task is in_progress unless force: true. Only the crew's own crons are removed (exact registry plus body-match discovery); shared crons are never touched. Dashboard artifacts are left alone.",
4
+ phases: [
5
+ { name: "gates", title: "Containment gate and explicit confirmation" },
6
+ { name: "safety", title: "Refuse while work is live (unless forced)" },
7
+ { name: "crons", title: "Remove the crew's scheduler crons" },
8
+ { name: "home", title: "Delete the crew home directory" }
9
+ ]
10
+ };
11
+
12
+ // ── Arguments ──────────────────────────────────────────────────────────
13
+ const inputs = args ?? {};
14
+ const crewHome = inputs.crewHome;
15
+ const confirm = inputs.confirm;
16
+ const force = inputs.force;
17
+
18
+ if (!crewHome) throw new Error("crewHome is required — e.g. ~/workspace/.crew");
19
+
20
+ // ── Pure decision helpers ──────────────────────────────────────────────
21
+ // The agent is a sensor, not a judge: it reports raw facts (HOME, expanded
22
+ // paths, existence, in-progress counts) and the verdicts below are computed
23
+ // here, deterministically. (Prompt hardening is a smell — this is the
24
+ // mechanical version.)
25
+ function expandTilde(p, home) {
26
+ if (p === "~") return home;
27
+ if (p.indexOf("~/") === 0) return home + p.slice(1);
28
+ return p;
29
+ }
30
+ function uninstallGateDecide(facts) {
31
+ // facts: { home, crewHomeExpanded }
32
+ // Uninstall deletes a directory tree — it must never run against a home
33
+ // outside the workspace. Same prefix rule as crew-init's Gate 0.
34
+ var workspace = facts.home + "/workspace";
35
+ var launchable = facts.crewHomeExpanded.indexOf(workspace + "/") === 0;
36
+ return { workspace: workspace, launchable: launchable };
37
+ }
38
+ function confirmDecide(c) {
39
+ // Strict: only the boolean true counts. "true", 1, and undefined do not.
40
+ if (c === true) return { ok: true, message: "" };
41
+ return {
42
+ ok: false,
43
+ message: "crew-uninstall is destructive and irreversible: it removes the crew's scheduler cron jobs and deletes the crew home directory. Re-run with confirm: true to proceed."
44
+ };
45
+ }
46
+ function safetyDecide(inProgressCount, f) {
47
+ // Live work strands if its home disappears mid-run. Refuse unless forced.
48
+ if (inProgressCount > 0 && f !== true) {
49
+ return {
50
+ ok: false,
51
+ message: inProgressCount + " task(s) are in_progress. Uninstalling now would strand their workflows. Re-run with force: true to uninstall anyway, or finish/park the work first."
52
+ };
53
+ }
54
+ return { ok: true, message: "" };
55
+ }
56
+
57
+ // ── Gates: containment + confirmation ──────────────────────────────────
58
+ phase("gates");
59
+ var gateFacts;
60
+ try {
61
+ gateFacts = await agent(
62
+ "Report filesystem facts for a crew uninstall. Do not judge — just report.\n\n" +
63
+ "Requested crewHome: " + crewHome + "\n\n" +
64
+ "Steps (run in shell):\n" +
65
+ "1. home=$(echo $HOME)\n" +
66
+ "2. Expand a leading ~/ in the requested crewHome against $HOME (leave other paths untouched).\n" +
67
+ "3. Report whether the expanded path exists: test -e \"<expanded>\"; echo EXISTS:$?\n" +
68
+ "4. Return JSON { home, crewHomeExpanded, homeExists } where homeExists is true\n" +
69
+ " when the path exists.",
70
+ {
71
+ key: "uninstall-gates",
72
+ label: "Validate uninstall target",
73
+ schema: {
74
+ type: "object",
75
+ properties: {
76
+ home: { type: "string" },
77
+ crewHomeExpanded: { type: "string" },
78
+ homeExists: { type: "boolean" }
79
+ },
80
+ required: ["home", "crewHomeExpanded", "homeExists"]
81
+ }
82
+ }
83
+ );
84
+ } catch (e) {
85
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Uninstall validation failed", message: String(e.message || e) } };
86
+ }
87
+ var gateResult = uninstallGateDecide(gateFacts);
88
+ if (!gateResult.launchable) {
89
+ return {
90
+ __hatchWorkflowControl: "blocked",
91
+ result: {
92
+ blocked_reason: "crewHome is not workspace-contained",
93
+ message: "crewHome must be inside the workspace (" + gateResult.workspace + "). Requested: " + gateFacts.crewHomeExpanded + ". Uninstall refuses to delete anything outside the workspace."
94
+ }
95
+ };
96
+ }
97
+ var crewHomeExpanded = gateFacts.crewHomeExpanded;
98
+ var confirmation = confirmDecide(confirm);
99
+ if (!confirmation.ok) {
100
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Uninstall not confirmed", message: confirmation.message } };
101
+ }
102
+ log("Uninstall confirmed for " + crewHomeExpanded + (gateFacts.homeExists ? "" : " (crew home already gone — removing orphaned crons only)"));
103
+
104
+ // ── Safety: refuse while work is live (unless forced) ───────────────────
105
+ phase("safety");
106
+ var safetyFacts = { homeExists: gateFacts.homeExists, inProgress: 0 };
107
+ if (gateFacts.homeExists) {
108
+ try {
109
+ safetyFacts = await agent(
110
+ "Report whether this crew has live work. Do not judge — just report.\n\n" +
111
+ "crewHome: " + crewHomeExpanded + "\n\n" +
112
+ "Steps (run in shell):\n" +
113
+ "1. If \"" + crewHomeExpanded + "/crew-state.db\" exists, run:\n" +
114
+ " sqlite3 \"" + crewHomeExpanded + "/crew-state.db\" \"SELECT COUNT(*) FROM tasks WHERE state='in_progress';\"\n" +
115
+ " Report the number. When the database is missing, the count is 0.\n" +
116
+ "2. Return JSON { homeExists: true, inProgress: <number> }.",
117
+ {
118
+ key: "uninstall-safety",
119
+ label: "Check for live work",
120
+ schema: {
121
+ type: "object",
122
+ properties: {
123
+ homeExists: { type: "boolean" },
124
+ inProgress: { type: "number" }
125
+ },
126
+ required: ["homeExists", "inProgress"]
127
+ }
128
+ }
129
+ );
130
+ } catch (e) {
131
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Safety check failed", message: String(e.message || e) } };
132
+ }
133
+ }
134
+ var safety = safetyDecide(safetyFacts.inProgress, force);
135
+ if (!safety.ok) {
136
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Live work in progress", message: safety.message } };
137
+ }
138
+ log("Safety: " + safetyFacts.inProgress + " in_progress task(s)" + (force === true ? " (forced)" : ""));
139
+
140
+ // ── Remove the crew's scheduler crons ──────────────────────────────────
141
+ // Two sources, unioned: the exact registry written by crew-init (covers id
142
+ // overrides), and discovery (covers installs from before the registry
143
+ // existed, and a registry that was never written). A job is only removed
144
+ // when it is on that union — never by guesswork.
145
+ phase("crons");
146
+ var cronsResult;
147
+ try {
148
+ cronsResult = await agent(
149
+ "Remove this crew instance's scheduler cron jobs — and nothing else.\n\n" +
150
+ "crewHome: " + crewHomeExpanded + "\n\n" +
151
+ "Steps:\n" +
152
+ "1. Registry: if \"" + crewHomeExpanded + "/.cron-registry.json\" exists, read it\n" +
153
+ " and take its crons[].id list as registry candidates.\n" +
154
+ "2. Discovery: call cron_list. For every job whose id starts with 'crew-poll-',\n" +
155
+ " call cron_view and keep it as a discovery candidate when the job body\n" +
156
+ " contains the crewHome string '" + crewHomeExpanded + "' (the poll body template\n" +
157
+ " embeds the crew home path). The bare id 'crew-poll' with no instance suffix\n" +
158
+ " is never a live crew job — never remove it.\n" +
159
+ "3. Removal list = registry candidates ∪ discovery candidates, deduped.\n" +
160
+ " SAFETY: remove only jobs on this list. When in doubt about any job, fail\n" +
161
+ " closed (passed: false) instead of guessing.\n" +
162
+ "4. For each id on the removal list: call cron_remove. A job that is already\n" +
163
+ " gone records action 'already_gone'.\n" +
164
+ "5. Call cron_list again and confirm none of the removed ids remain. If any\n" +
165
+ " remain, return passed: false.\n" +
166
+ "6. Return { passed: true, summary: { removed: [...] } } with one entry per\n" +
167
+ " removal-list id: { id, source: 'registry' | 'discovery',\n" +
168
+ " action: 'removed' | 'already_gone' }. An empty removal list is a pass\n" +
169
+ " with removed: [].",
170
+ {
171
+ key: "uninstall-crons",
172
+ label: "Remove the crew's crons",
173
+ schema: {
174
+ type: "object",
175
+ properties: {
176
+ passed: { type: "boolean" },
177
+ summary: {
178
+ type: "object",
179
+ properties: {
180
+ removed: {
181
+ type: "array",
182
+ items: {
183
+ type: "object",
184
+ properties: {
185
+ id: { type: "string" },
186
+ source: { type: "string", enum: ["registry", "discovery"] },
187
+ action: { type: "string", enum: ["removed", "already_gone"] }
188
+ },
189
+ required: ["id", "source", "action"]
190
+ }
191
+ }
192
+ },
193
+ required: ["removed"]
194
+ }
195
+ },
196
+ required: ["passed", "summary"]
197
+ }
198
+ }
199
+ );
200
+ } catch (e) {
201
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron removal failed", message: String(e.message || e) } };
202
+ }
203
+ if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.removed)) {
204
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron removal failed", message: "The cron-removal agent did not pass. No further teardown was attempted; the crew home is untouched." } };
205
+ }
206
+ var removedDesc = cronsResult.summary.removed.map(function (r) { return r.id + "=" + r.action; }).join(", ");
207
+ log("Crons: " + (removedDesc || "(none belonged to this crew)"));
208
+
209
+ // ── Delete the crew home directory ─────────────────────────────────────
210
+ phase("home");
211
+ var homeRemoved = false;
212
+ if (gateFacts.homeExists) {
213
+ var homeResult;
214
+ try {
215
+ homeResult = await agent(
216
+ "Delete the crew home directory.\n\n" +
217
+ "crewHome: " + crewHomeExpanded + "\n\n" +
218
+ "Steps:\n" +
219
+ "1. Best-effort worktree hygiene (failures do not block): if\n" +
220
+ " \"" + crewHomeExpanded + "/crew-state.db\" exists, list the repo_path values\n" +
221
+ " from its projects table and run 'git worktree prune' in each. Ignore errors.\n" +
222
+ "2. Run: rm -rf \"" + crewHomeExpanded + "\"\n" +
223
+ "3. Verify: test ! -e \"" + crewHomeExpanded + "\" — fail closed (passed: false)\n" +
224
+ " if the path still exists.\n" +
225
+ "4. Return { passed: true, summary: { removed: true } }.",
226
+ {
227
+ key: "uninstall-home",
228
+ label: "Delete the crew home",
229
+ schema: {
230
+ type: "object",
231
+ properties: {
232
+ passed: { type: "boolean" },
233
+ summary: {
234
+ type: "object",
235
+ properties: {
236
+ removed: { type: "boolean" }
237
+ },
238
+ required: ["removed"]
239
+ }
240
+ },
241
+ required: ["passed", "summary"]
242
+ }
243
+ }
244
+ );
245
+ } catch (e) {
246
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Crew home removal failed", message: String(e.message || e) } };
247
+ }
248
+ if (!homeResult.passed || !homeResult.summary || homeResult.summary.removed !== true) {
249
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Crew home removal failed", message: "The crew home could not be deleted. Its crons were already removed; delete " + crewHomeExpanded + " manually." } };
250
+ }
251
+ homeRemoved = true;
252
+ log("Crew home deleted: " + crewHomeExpanded);
253
+ } else {
254
+ log("Crew home already gone — nothing to delete.");
255
+ }
256
+
257
+ // ── Summary ────────────────────────────────────────────────────────────
258
+ return {
259
+ message: "Muse Crew uninstalled.",
260
+ crewHome: crewHomeExpanded,
261
+ removed_crons: cronsResult.summary.removed,
262
+ crew_home_removed: homeRemoved,
263
+ note: "Dashboard artifacts were left untouched — uninstall removes the crew instance (its crons and its home), never the user's artifacts."
264
+ };