muse-crew 0.11.1 → 0.13.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/API.md +40 -7
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +24 -3
- package/docs/state-boundary-spec.md +37 -0
- package/lib/AGENTS.md +6 -3
- package/lib/advance-publish-base.js +271 -0
- package/lib/classify-surface.js +109 -0
- package/lib/compose-evidence-caption.js +59 -10
- package/lib/compute-publish-diff.js +145 -0
- package/lib/crew-api.js +283 -49
- package/lib/schema.sql +12 -0
- package/lib/test-worktree-backend.sh +29 -0
- package/lib/verify-publish.js +4 -3
- package/lib/worktree-lifecycle.sh +9 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +10 -7
- package/workflows/bugfix.js +67 -69
- package/workflows/chore.js +66 -68
- package/workflows/crew-dispatch.js +21 -6
- package/workflows/crew-init.js +69 -3
- package/workflows/standard.js +67 -69
- package/workflows/upgrade.js +8 -3
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
// caller appends AFTER this caption. Deterministic: every line is extracted
|
|
10
10
|
// mechanically from the Crew API, the task's events, and the merge commit —
|
|
11
11
|
// no LLM prose. The caller (seed/cron-body-template.md Step 6) prints this
|
|
12
|
-
// caption verbatim, then the two attachment lines
|
|
12
|
+
// caption verbatim, then the two attachment lines (only when the audit
|
|
13
|
+
// harness actually captured screenshots), then logs delivery.
|
|
14
|
+
//
|
|
15
|
+
// Capture guard (2026-09-17, room #14): the composer must SEE the captures
|
|
16
|
+
// before it claims them. It once fabricated "desktop + mobile captures
|
|
17
|
+
// below" for a terminal-surface task with no audit dir at all, and opened
|
|
18
|
+
// with "these screenshots are dropping in" when no screenshots existed. So
|
|
19
|
+
// it resolves the audit dir itself — <home>/workspace/ts-spaces/<deploy_slug>
|
|
20
|
+
// /audits/<auditDir>/, the same root lib/crew-api.js and lib/readback-disk.js
|
|
21
|
+
// resolve — and checks screenshot.png + screenshot-mobile.png are files.
|
|
22
|
+
// Missing deploy_slug, missing dir, or missing PNGs gets the honest
|
|
23
|
+
// no-captures line; terminal surfaces are named, not assumed.
|
|
13
24
|
//
|
|
14
25
|
// Usage:
|
|
15
26
|
// node lib/compose-evidence-caption.js --crew-home <home> \
|
|
@@ -19,6 +30,7 @@
|
|
|
19
30
|
|
|
20
31
|
const { execFileSync } = require("node:child_process");
|
|
21
32
|
const fs = require("node:fs");
|
|
33
|
+
const os = require("node:os");
|
|
22
34
|
const path = require("node:path");
|
|
23
35
|
|
|
24
36
|
function usage(msg) {
|
|
@@ -76,6 +88,22 @@ try {
|
|
|
76
88
|
}
|
|
77
89
|
const task = (state.tasks || []).find((t) => t.id === taskId);
|
|
78
90
|
if (!task) usage("task " + taskId + " not found");
|
|
91
|
+
const project = (state.projects || []).find((p) => p.id === task.project);
|
|
92
|
+
|
|
93
|
+
// Capture guard (2026-09-17, room #14): resolve the audit dir the audit
|
|
94
|
+
// harness actually writes and verify the captures exist before claiming
|
|
95
|
+
// them. A bare --audit-dir name is never trusted on its own.
|
|
96
|
+
function isFile(p) {
|
|
97
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
let auditPath = null;
|
|
100
|
+
if (project && project.deploy_slug) {
|
|
101
|
+
auditPath = path.join(os.homedir(), "workspace", "ts-spaces",
|
|
102
|
+
project.deploy_slug, "audits", auditDir);
|
|
103
|
+
}
|
|
104
|
+
const hasCaptures = !!(auditPath &&
|
|
105
|
+
isFile(path.join(auditPath, "screenshot.png")) &&
|
|
106
|
+
isFile(path.join(auditPath, "screenshot-mobile.png")));
|
|
79
107
|
|
|
80
108
|
let events = [];
|
|
81
109
|
try {
|
|
@@ -126,7 +154,8 @@ if (!verdict) {
|
|
|
126
154
|
}
|
|
127
155
|
|
|
128
156
|
// Provenance: this task's own newest `publish: verified` event first; the
|
|
129
|
-
//
|
|
157
|
+
// project-scoped provenance record only when it belongs to this task
|
|
158
|
+
// (provenance is per-project — room #15 blocker 8).
|
|
130
159
|
let commit = null;
|
|
131
160
|
const verifiedNote = notesNewest
|
|
132
161
|
.find((e) => (e.message || "").includes("publish: verified"));
|
|
@@ -134,9 +163,9 @@ if (verifiedNote) {
|
|
|
134
163
|
const m = /\b([0-9a-f]{40})\b/.exec(verifiedNote.message);
|
|
135
164
|
if (m) commit = m[1];
|
|
136
165
|
}
|
|
137
|
-
if (!commit) {
|
|
166
|
+
if (!commit && project) {
|
|
138
167
|
try {
|
|
139
|
-
const prov = api("get-provenance", {}).provenance;
|
|
168
|
+
const prov = api("get-provenance", { project_id: project.id }).provenance;
|
|
140
169
|
if (prov && prov.task_id === taskId && /^[0-9a-f]{40}$/.test(prov.source_commit || "")) {
|
|
141
170
|
commit = prov.source_commit;
|
|
142
171
|
}
|
|
@@ -149,8 +178,7 @@ if (!commit) {
|
|
|
149
178
|
// task title when the commit is unknown or unreadable.
|
|
150
179
|
let solution = "";
|
|
151
180
|
if (commit) {
|
|
152
|
-
|
|
153
|
-
if (proj && proj.repo_path) solution = gitSubject(proj.repo_path, commit);
|
|
181
|
+
if (project && project.repo_path) solution = gitSubject(project.repo_path, commit);
|
|
154
182
|
}
|
|
155
183
|
if (!solution) solution = task.title;
|
|
156
184
|
|
|
@@ -169,16 +197,37 @@ const newestBaselineNote = notesNewest.find((e) => /^baseline:\s*/i.test(e.messa
|
|
|
169
197
|
const visualUnavailable = !verdict && !!newestBaselineNote &&
|
|
170
198
|
/^baseline:\s*none/i.test(newestBaselineNote.message || "");
|
|
171
199
|
|
|
200
|
+
const verdictLine = verdict
|
|
201
|
+
? " QA verdict: " + verdict.status + (verdict.summary ? " — " + trimWords(verdict.summary, 200) : "") + "."
|
|
202
|
+
: " No QA verdict recorded.";
|
|
203
|
+
|
|
172
204
|
const lines = [];
|
|
173
|
-
|
|
205
|
+
if (hasCaptures) {
|
|
206
|
+
lines.push("These screenshots are dropping in out of context — quick transition.");
|
|
207
|
+
} else {
|
|
208
|
+
// Honest opener: no screenshots are arriving, so the report says so up
|
|
209
|
+
// front instead of pretending they are dropping in.
|
|
210
|
+
lines.push("QA evidence for this task — no screenshots, so this is the whole report.");
|
|
211
|
+
}
|
|
174
212
|
lines.push("");
|
|
175
213
|
lines.push("**" + task.title + "**");
|
|
176
214
|
lines.push("Problem: " + problem);
|
|
177
215
|
lines.push("Shipped: " + trimWords(solution, 160) +
|
|
178
216
|
(commit ? " (commit `" + commit.slice(0, 12) + "`, parent-verified)" : " (provenance not yet stamped)"));
|
|
179
|
-
|
|
180
|
-
(
|
|
181
|
-
|
|
217
|
+
if (hasCaptures) {
|
|
218
|
+
lines.push("Evidence: desktop + mobile captures below, taken by the platform audit harness after deploy (`" + auditDir + "`)." + verdictLine);
|
|
219
|
+
} else {
|
|
220
|
+
// Honest no-captures line (2026-09-17, room #14): never claim captures
|
|
221
|
+
// that do not exist. Terminal surfaces are named, not assumed.
|
|
222
|
+
const surfaceNote = project && project.environment_type === "terminal"
|
|
223
|
+
? " (terminal surface — QA judges the CLI, not pixels)"
|
|
224
|
+
: "";
|
|
225
|
+
lines.push("Evidence: no screenshots captured for this task" + surfaceNote + " — nothing to attach." + verdictLine);
|
|
226
|
+
}
|
|
227
|
+
if (visualUnavailable && hasCaptures) {
|
|
228
|
+
// The note's premise is the post-deploy capture — without captures it
|
|
229
|
+
// would fabricate evidence again (2026-09-17: the no-captures line above
|
|
230
|
+
// already covers the honest case).
|
|
182
231
|
lines.push("Note: the visual protocol is unavailable — these are the post-deploy capture plus QA's mechanical checks, not a QA visual inspection.");
|
|
183
232
|
}
|
|
184
233
|
process.stdout.write(lines.join("\n") + "\n");
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// compute-publish-diff.js — deterministic publish-diff computer.
|
|
3
|
+
//
|
|
4
|
+
// Room #14 (2026-09-17): the Publish phase asked an agent to run
|
|
5
|
+
// `git diff "$BASE" "$HEAD"` and return the raw unified diff as a JSON
|
|
6
|
+
// string field. The agent produced the diff correctly on disk but the
|
|
7
|
+
// JSON ferry dropped it — parseUnifiedDiff saw zero files and the task
|
|
8
|
+
// parked at "Publish diff parsed to zero files" for a real, valid
|
|
9
|
+
// 700-line / 23-file diff. General rule: NEVER ferry a deterministic
|
|
10
|
+
// unified diff through an LLM JSON response.
|
|
11
|
+
//
|
|
12
|
+
// This script is the deterministic replacement. The workflow's agent is
|
|
13
|
+
// pure hands — it runs exactly one command (this script) and returns the
|
|
14
|
+
// small JSON summary verbatim. The diff bytes live in a file; the LLM
|
|
15
|
+
// never carries them:
|
|
16
|
+
//
|
|
17
|
+
// node compute-publish-diff.js --repo-path <path> --base <sha> --out <file>
|
|
18
|
+
//
|
|
19
|
+
// --base is the previously-stamped provenance source_commit (or the
|
|
20
|
+
// empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
|
|
21
|
+
// publish) — the SAME base the workflow read from get-provenance. The
|
|
22
|
+
// script echoes the base it used so the workflow can guard against the
|
|
23
|
+
// agent running the wrong command (fail-closed, as before).
|
|
24
|
+
//
|
|
25
|
+
// Stdout contract: exactly one JSON object, always small (no diff text):
|
|
26
|
+
// { ok:true, commit, base, ancestor:"yes", diff_file, sha256, bytes,
|
|
27
|
+
// file_count, changed_lines, files:[<paths>], has_binary, has_rename }
|
|
28
|
+
// { ok:false, error:"not-ancestor", commit, base } (semantic, exit 0)
|
|
29
|
+
//
|
|
30
|
+
// Exit codes: 0 ok (JSON on stdout) · 2 usage/validation · 1 git/diff/IO
|
|
31
|
+
// failure. On non-zero exit NOTHING is printed to stdout, so a caller
|
|
32
|
+
// must never treat stdout as a summary. Errors go to stderr as JSON.
|
|
33
|
+
//
|
|
34
|
+
// Security: --base must be 40-hex (or the empty tree); --repo-path must
|
|
35
|
+
// be an existing directory; git is invoked via execFileSync with an argv
|
|
36
|
+
// array (no shell). The diff file's parent directories are created.
|
|
37
|
+
|
|
38
|
+
import { execFileSync } from "node:child_process";
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
import { existsSync, mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs";
|
|
41
|
+
import { dirname, resolve } from "node:path";
|
|
42
|
+
|
|
43
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
44
|
+
|
|
45
|
+
function arg(name, required = true) {
|
|
46
|
+
const i = process.argv.indexOf(name);
|
|
47
|
+
if (i < 0 || i + 1 >= process.argv.length) {
|
|
48
|
+
if (!required) return null;
|
|
49
|
+
fail("usage", `${name} is required.`, 2);
|
|
50
|
+
}
|
|
51
|
+
return process.argv[i + 1];
|
|
52
|
+
}
|
|
53
|
+
function fail(code, message, exitCode) {
|
|
54
|
+
process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
|
|
55
|
+
process.exit(exitCode);
|
|
56
|
+
}
|
|
57
|
+
function git(repoPath, args) {
|
|
58
|
+
try {
|
|
59
|
+
return execFileSync("git", ["-C", repoPath, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
fail("git-failed", `git ${args[0]} failed: ${(e && e.message) || e}`.slice(0, 500), 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const repoPath = arg("--repo-path");
|
|
66
|
+
const base = arg("--base");
|
|
67
|
+
const outFile = arg("--out");
|
|
68
|
+
|
|
69
|
+
if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "--base must be a 40-char hex commit SHA (or the empty-tree SHA).", 2);
|
|
70
|
+
let st = null;
|
|
71
|
+
try { st = statSync(repoPath); } catch { /* falls through */ }
|
|
72
|
+
if (!st || !st.isDirectory()) fail("usage", "--repo-path must be an existing directory.", 2);
|
|
73
|
+
|
|
74
|
+
const head = git(repoPath, ["rev-parse", "HEAD"]).trim();
|
|
75
|
+
if (!/^[0-9a-f]{40}$/.test(head)) fail("git-failed", "rev-parse HEAD did not return a commit SHA.", 1);
|
|
76
|
+
|
|
77
|
+
// Ancestor check — deterministic, in code. The empty tree is not a commit:
|
|
78
|
+
// git merge-base --is-ancestor fails on it, so a first publish hardcodes
|
|
79
|
+
// ANCESTOR=yes here instead of executing the conditional (clean-room
|
|
80
|
+
// 2026-09-16: an agent ran merge-base on the empty tree directly and parked).
|
|
81
|
+
let ancestor = "yes";
|
|
82
|
+
if (base !== EMPTY_TREE) {
|
|
83
|
+
try {
|
|
84
|
+
execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, head], { stdio: "ignore" });
|
|
85
|
+
ancestor = "yes";
|
|
86
|
+
} catch {
|
|
87
|
+
ancestor = "no";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (ancestor !== "yes") {
|
|
91
|
+
process.stdout.write(JSON.stringify({ ok: false, error: "not-ancestor", commit: head, base }) + "\n");
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outPath = resolve(outFile);
|
|
96
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
97
|
+
const diffText = git(repoPath, ["diff", base, head]);
|
|
98
|
+
writeFileSync(outPath, diffText, "utf8");
|
|
99
|
+
const bytes = readFileSync(outPath);
|
|
100
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
101
|
+
|
|
102
|
+
// summarizeDiff — the parseUnifiedDiff logic, moved out of the workflows
|
|
103
|
+
// (canary run 4, 2026-09-11: the builder's source tree was stale and the
|
|
104
|
+
// workflow stamped a new hash on it — provenance fiction; the parse
|
|
105
|
+
// exists so Publish verifies the artifact builder applied the carried
|
|
106
|
+
// change). Counts changed lines (added + removed), never raw diff output
|
|
107
|
+
// lines: context lines and file headers inflated the old split("\n").length
|
|
108
|
+
// count ~2x, parking a 95-line change against the 200-line budget
|
|
109
|
+
// (Gate 1 Journey 3 attempt 3, 2026-09-13).
|
|
110
|
+
let files = [];
|
|
111
|
+
let current = null;
|
|
112
|
+
let added = 0;
|
|
113
|
+
let removed = 0;
|
|
114
|
+
let has_binary = false;
|
|
115
|
+
let has_rename = false;
|
|
116
|
+
for (const line of diffText.split("\n")) {
|
|
117
|
+
const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
|
|
118
|
+
if (m) {
|
|
119
|
+
current = m[2];
|
|
120
|
+
files.push(current);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (/^Binary files /.test(line)) { has_binary = true; continue; }
|
|
124
|
+
if (/^rename from /.test(line)) { has_rename = true; continue; }
|
|
125
|
+
if (current === null) continue;
|
|
126
|
+
if (/^--- /.test(line) || /^\+\+\+ /.test(line)) continue;
|
|
127
|
+
if (/^@@ /.test(line)) continue;
|
|
128
|
+
if (line.charAt(0) === "+") added++;
|
|
129
|
+
else if (line.charAt(0) === "-") removed++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
process.stdout.write(JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
commit: head,
|
|
135
|
+
base,
|
|
136
|
+
ancestor: "yes",
|
|
137
|
+
diff_file: outPath,
|
|
138
|
+
sha256,
|
|
139
|
+
bytes: bytes.length,
|
|
140
|
+
file_count: files.length,
|
|
141
|
+
changed_lines: added + removed,
|
|
142
|
+
files,
|
|
143
|
+
has_binary,
|
|
144
|
+
has_rename,
|
|
145
|
+
}) + "\n");
|