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.
@@ -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, then logs delivery.
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
- // global provenance record only when it belongs to this task.
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
- const proj = (state.projects || []).find((p) => p.id === task.project);
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
- lines.push("These screenshots are dropping in out of context — quick transition.");
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
- lines.push("Evidence: desktop + mobile captures below, taken by the platform audit harness after deploy (`" + auditDir + "`)." +
180
- (verdict ? " QA verdict: " + verdict.status + (verdict.summary ? " — " + trimWords(verdict.summary, 200) : "") + "." : " No QA verdict recorded."));
181
- if (visualUnavailable) {
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");