muse-crew 0.7.7 → 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/docs/guide.md +24 -0
- package/package.json +1 -1
- package/workflows/crew-init.js +28 -8
- package/workflows/crew-uninstall.js +264 -0
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/package.json
CHANGED
package/workflows/crew-init.js
CHANGED
|
@@ -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.
|
|
367
|
-
"
|
|
368
|
-
"
|
|
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
|
-
|
|
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
|
+
};
|