muse-crew 0.13.2 → 0.14.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.
@@ -73,9 +73,12 @@ cmd_init() {
73
73
  echo "CURRENT: $cur"
74
74
  }
75
75
 
76
- # ── workflow syntax validation ──────────────────────────────────────
77
- # Every workflow script must parse before a release can install.
76
+ # ── workflow validation ─────────────────────────────────────────────
77
+ # Every workflow script must (a) parse under the workflow-loader emulation
78
+ # and (b) fit the launch budget before a release can install.
79
+ # Exit codes: 10 = parse check failed, 20 = size check failed (0 = pass).
78
80
  _validate_workflows() {
81
+ # --- Parse gate (restored 2026-09-18, finding B2) ---
79
82
  # The workflow runtime accepts top-level `export`, `return`, and `await`
80
83
  # (it wraps scripts in an async function), so neither `node --check` on the
81
84
  # raw .js (vacuous for ESM — exits 0 even on blatant syntax errors) nor a
@@ -83,8 +86,23 @@ _validate_workflows() {
83
86
  # Emulate the runtime instead: strip `export`, wrap the script in an async
84
87
  # function, then node --check the result. Tokenizer errors (e.g. an
85
88
  # unterminated string literal) still fail under the wrap.
89
+ #
90
+ # HONESTY NOTE: this is a provisional emulation, not a verification. The
91
+ # platform's actual loading mechanism was never verified (probe blocked,
92
+ # 2026-09-18), so this check cannot prove how the platform loads the
93
+ # workflows. What it DOES prove: the file is syntactically well-formed JS
94
+ # when read the way the runtime claims to read it (strip export, run as one
95
+ # async function body). What it does NOT prove: that the platform actually
96
+ # loads it that way, or that the code runs correctly. The export-strip +
97
+ # async-wrap transform exists precisely because raw `node --check` on ESM
98
+ # `.js` is unreliable — it silently exits 0 on some malformed input — so
99
+ # the transform is what makes the check actually parse.
100
+ # A failure here is a signal to INSPECT THE CODE, never to delete the
101
+ # check: this gate was once removed on a misdiagnosis ("the check
102
+ # false-positives"), and the workflows it rejected turned out to be
103
+ # genuinely broken (2026-09-18, Gate 1 H2 — a true positive).
86
104
  local dir="$1"
87
- local f tmp base
105
+ local f tmp base size
88
106
  tmp="$(mktemp -d)"
89
107
  for f in "$dir"/workflows/*.js; do
90
108
  [ -f "$f" ] || continue
@@ -96,13 +114,28 @@ _validate_workflows() {
96
114
  } > "$tmp/$base.js"
97
115
  if ! node --check "$tmp/$base.js" 2>"$tmp/$base.err"; then
98
116
  cat "$tmp/$base.err" >&2
99
- echo "VALIDATION FAILED: $f does not parse" >&2
117
+ echo "VALIDATION FAILED [parse]: $f does not parse under the workflow-loader emulation" >&2
100
118
  rm -rf "$tmp"
101
- return 1
119
+ return 10
102
120
  fi
103
121
  done
104
122
  rm -rf "$tmp"
105
- echo "VALIDATED: workflow scripts parse"
123
+
124
+ # --- Size gate (H5, 2026-09-18) ---
125
+ # The platform reads the full workflow file on launch; the platform hard
126
+ # maximum is 262144 bytes (256 KiB) and 245760 bytes (240 KiB) is the
127
+ # project's safety target. The release fails closed if any launchable
128
+ # workflow hits the safety target.
129
+ for f in "$dir"/workflows/*.js; do
130
+ [ -f "$f" ] || continue
131
+ size="$(wc -c < "$f")"
132
+ if [ "$size" -ge 245760 ]; then
133
+ echo "VALIDATION FAILED [size]: $f is $size bytes (>= 245760 byte launch budget)" >&2
134
+ return 20
135
+ fi
136
+ done
137
+
138
+ echo "VALIDATED: workflow scripts parse and fit the launch budget"
106
139
  }
107
140
 
108
141
  # ── workflow doc sync ───────────────────────────────────────────────
@@ -255,10 +288,19 @@ cmd_deploy() {
255
288
  rm -rf "$staging_dir"
256
289
  die "release $hash rejected: registry build failed"
257
290
  fi
258
- # Gate: refuse to install a release whose workflow scripts don't parse.
259
- if ! _validate_workflows "$staging_dir"; then
291
+ # Gate: refuse to install a release whose workflow scripts fail the
292
+ # parse or size checks. _validate_workflows returns 10 on a parse
293
+ # failure, 20 on a size-budget failure; the rejection names the check
294
+ # that failed so the message is never misleading.
295
+ _vw_status=0
296
+ _validate_workflows "$staging_dir" || _vw_status=$?
297
+ if [ "$_vw_status" -ne 0 ]; then
260
298
  rm -rf "$staging_dir"
261
- die "release $hash rejected: workflow syntax validation failed"
299
+ case "$_vw_status" in
300
+ 10) die "release $hash rejected: workflow parse check failed" ;;
301
+ 20) die "release $hash rejected: workflow size-budget check failed" ;;
302
+ *) die "release $hash rejected: workflow validation failed (unexpected exit $_vw_status)" ;;
303
+ esac
262
304
  fi
263
305
  # Atomic rename into place
264
306
  mv "$staging_dir" "$release_dir"
@@ -0,0 +1,154 @@
1
+ // publish-content.js — shared deterministic content-judgment primitives.
2
+ //
3
+ // Parent publish verification (lib/verify-publish.js) and unknown-recovery
4
+ // classification (lib/classify-publish-absence.js) both judge the same
5
+ // question — "does the on-disk source contain the publish commit's diff?" —
6
+ // against the same diff parser, findings parser, and collision-exemption
7
+ // rules. Two copies of the collision logic already diverged once (2026-09-15,
8
+ // task 00bca4b8); this module is the single source of truth.
9
+ //
10
+ // ESM (imported by verify-publish.js and classify-publish-absence.js).
11
+ // Pure and deterministic: no wall-clock reads, no randomness, no I/O except
12
+ // the git subprocesses in makeOldCounter (read-only).
13
+
14
+ import { execFileSync } from "node:child_process";
15
+
16
+ export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
17
+
18
+ // Parse `git diff base commit` into per-file added/removed lines.
19
+ // Byte-identical split to build-readback-request.js and the old
20
+ // verify-publish.js inline parser (the verifier looks raw lines up
21
+ // verbatim, so all consumers must agree on the split).
22
+ export function parseDiff(diffText) {
23
+ const expected = new Map(); // path -> { added: [], removed: [], isBinary: bool }
24
+ let curFile = null;
25
+ for (const line of diffText.split("\n")) {
26
+ if (line.startsWith("diff --git")) {
27
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
28
+ curFile = m ? m[2] : "unknown";
29
+ expected.set(curFile, { added: [], removed: [], isBinary: false });
30
+ } else if (curFile && line.startsWith("Binary files ")) {
31
+ expected.get(curFile).isBinary = true;
32
+ } else if (curFile && line.startsWith("+") && !line.startsWith("+++")) {
33
+ expected.get(curFile).added.push(line.slice(1));
34
+ } else if (curFile && line.startsWith("-") && !line.startsWith("---")) {
35
+ expected.get(curFile).removed.push(line.slice(1));
36
+ }
37
+ }
38
+ return expected;
39
+ }
40
+
41
+ // Parse the machine-readable findings block:
42
+ // FILE: <path>
43
+ // ADDED: <line> :: PRESENT|ABSENT
44
+ // REMOVED: <line> :: PRESENT|ABSENT
45
+ // END_FILE
46
+ export function parseFindings(text) {
47
+ const findings = new Map(); // path -> { added: Map(line->verdict), removed: Map(line->verdict) }
48
+ let cur = null;
49
+ let malformed = null;
50
+ for (const rawLine of text.split("\n")) {
51
+ const line = rawLine.trimEnd();
52
+ if (line.startsWith("FILE: ")) {
53
+ cur = { added: new Map(), removed: new Map() };
54
+ findings.set(line.slice(6).trim(), cur);
55
+ } else if (line === "END_FILE") {
56
+ cur = null;
57
+ } else if (cur && (line.startsWith("ADDED: ") || line.startsWith("REMOVED: "))) {
58
+ const kind = line.startsWith("ADDED: ") ? "added" : "removed";
59
+ const rest = line.slice(kind === "added" ? 7 : 9);
60
+ const sep = rest.lastIndexOf(" :: ");
61
+ if (sep < 0) { malformed = `malformed finding line: ${line.slice(0, 80)}`; break; }
62
+ const content = rest.slice(0, sep);
63
+ const verdict = rest.slice(sep + 4).trim();
64
+ if (verdict !== "PRESENT" && verdict !== "ABSENT") {
65
+ malformed = `bad verdict: ${verdict}`;
66
+ break;
67
+ }
68
+ cur[kind].set(content, verdict);
69
+ }
70
+ }
71
+ return { findings, malformed };
72
+ }
73
+
74
+ // Whole-line occurrence counts of a file's content at <base>.
75
+ // [] when the base is the empty tree or the file did not exist there.
76
+ function oldTreeLines(repoPath, base, path) {
77
+ if (base === EMPTY_TREE) return [];
78
+ let text;
79
+ try {
80
+ text = execFileSync("git", ["-C", repoPath, "show", `${base}:${path}`], {
81
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
82
+ });
83
+ } catch {
84
+ return [];
85
+ }
86
+ if (text === "") return [];
87
+ const lines = text.split("\n");
88
+ if (lines[lines.length - 1] === "") lines.pop(); // drop the trailing-newline artifact
89
+ return lines;
90
+ }
91
+
92
+ // Returns (path, line) => occurrences of line in path's old tree.
93
+ // Cached per path. Read-only git subprocesses.
94
+ export function makeOldCounter(repoPath, base) {
95
+ const cache = new Map(); // path -> Map(line -> count)
96
+ return (path, line) => {
97
+ let counts = cache.get(path);
98
+ if (counts === undefined) {
99
+ counts = new Map();
100
+ for (const l of oldTreeLines(repoPath, base, path)) {
101
+ counts.set(l, (counts.get(l) || 0) + 1);
102
+ }
103
+ cache.set(path, counts);
104
+ }
105
+ return counts.get(line) || 0;
106
+ };
107
+ }
108
+
109
+ // Split each file's diff lines into discriminating vs non-discriminating.
110
+ //
111
+ // A line has zero discriminating power when its presence/absence cannot
112
+ // tell "hunk landed" from "hunk dropped":
113
+ // - added line already in the old tree (colliding pre-existing line):
114
+ // PRESENT whether or not the hunk landed.
115
+ // - removed line occurring in the old tree strictly more times than the
116
+ // diff removes it: PRESENT whether or not the removal happened.
117
+ //
118
+ // Returns Map(path -> {
119
+ // addedDisc: [...], removedDisc: [...], // discriminating lines
120
+ // unjudgeable: null | "binary-or-mode-or-rename" | "fully-colliding-added"
121
+ // }).
122
+ // unjudgeable marks files the line-based judge cannot certify at all:
123
+ // a diff with no content lines (binary, mode-only, rename), or added
124
+ // lines that ALL collide (their PRESENT findings prove nothing).
125
+ export function discriminatingLines(diffMap, oldCount) {
126
+ const out = new Map();
127
+ for (const [path, exp] of diffMap) {
128
+ const entry = { addedDisc: [], removedDisc: [], unjudgeable: null };
129
+ if (exp.added.length === 0 && exp.removed.length === 0) {
130
+ entry.unjudgeable = "binary-or-mode-or-rename";
131
+ out.set(path, entry);
132
+ continue;
133
+ }
134
+ for (const line of exp.added) {
135
+ if (oldCount(path, line) === 0) entry.addedDisc.push(line);
136
+ }
137
+ // The diff may remove the same line more than once; the old tree must
138
+ // account for every removal before a line counts as colliding.
139
+ const removedBudget = new Map();
140
+ for (const line of exp.removed) {
141
+ removedBudget.set(line, (removedBudget.get(line) || 0) + 1);
142
+ }
143
+ for (const line of exp.removed) {
144
+ if (oldCount(path, line) <= removedBudget.get(line)) {
145
+ entry.removedDisc.push(line);
146
+ }
147
+ }
148
+ if (exp.added.length > 0 && entry.addedDisc.length === 0) {
149
+ entry.unjudgeable = "fully-colliding-added";
150
+ }
151
+ out.set(path, entry);
152
+ }
153
+ return out;
154
+ }
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5).
3
+ //
4
+ // The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
5
+ // lock handling, journaling, diff generation, ledger writes, mirroring, and
6
+ // cleanup by following template instructions. A missed step (or a tick dying
7
+ // mid-protocol) left the lock held or the state inconsistent.
8
+ //
9
+ // This command owns all deterministic transitions with try/finally lock
10
+ // release. Only the unavoidable `artifact_edit` tool call remains
11
+ // agent-mediated (it requires the artifact namespace, which this process
12
+ // cannot load).
13
+ //
14
+ // Two phases:
15
+ // Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
16
+ // journal intent, generate diff. Outputs JSON with the diff path/sha
17
+ // for the agent to feed to artifact_edit. The lock is HELD on success
18
+ // (the agent must call phase 2 to release it).
19
+ // Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
20
+ // write the outcome notes, append the ledger entry, mirror verification,
21
+ // and RELEASE the lock in a finally block.
22
+ //
23
+ // Crash safety: if the agent dies between phases, the lock expires after
24
+ // 600s and the note journal (`publish: retry-intended` without
25
+ // `publish: retry-issued`) lets the next scan mirror verification-requested
26
+ // without re-triggering — exactly one retry, enforced from notes.
27
+ //
28
+ // Usage:
29
+ // node retry-publish.js --crew-home <path> --task-id <uuid> --phase prepare
30
+ // node retry-publish.js --crew-home <path> --task-id <uuid> --phase complete --edit-outcome <accepted|refused> [--refusal-text <text>]
31
+ //
32
+ // Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
33
+
34
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
35
+ import { join } from "path";
36
+ import { execFileSync } from "child_process";
37
+ import { randomUUID as uuid } from "crypto";
38
+
39
+ const args = process.argv.slice(2);
40
+ function arg(name, required = true, def = undefined) {
41
+ const i = args.indexOf(name);
42
+ if (i === -1 || i + 1 >= args.length) {
43
+ if (required) { console.error(`missing required arg ${name}`); process.exit(2); }
44
+ return def;
45
+ }
46
+ return args[i + 1];
47
+ }
48
+ function fail(code, msg, exitCode = 1) {
49
+ process.stdout.write(JSON.stringify({ ok: false, code, message: msg }) + "\n");
50
+ process.exit(exitCode);
51
+ }
52
+
53
+ const crewHome = arg("--crew-home");
54
+ const taskId = arg("--task-id");
55
+ const phase = arg("--phase");
56
+
57
+ if (!["prepare", "complete"].includes(phase)) {
58
+ console.error("phase must be 'prepare' or 'complete'");
59
+ process.exit(2);
60
+ }
61
+
62
+ // --- Shared helpers ---------------------------------------------------------
63
+
64
+ function nowISO() { return new Date().toISOString(); }
65
+
66
+ function writePublishNote(db, taskId, message) {
67
+ db.prepare(
68
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
69
+ VALUES (?, 'note', ?, NULL, ?, ?)`
70
+ ).run(uuid(), taskId, message, nowISO());
71
+ }
72
+
73
+ // Load the crew DB. We use better-sqlite3 via the crew-api's own dependency
74
+ // resolution — fall back to a clear error if unavailable.
75
+ let Database;
76
+ try {
77
+ Database = (await import("better-sqlite3")).default;
78
+ } catch {
79
+ fail("db-unavailable", "better-sqlite3 is not available to retry-publish.js");
80
+ }
81
+ const dbPath = join(crewHome, "crew.db");
82
+ if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
83
+ const db = new Database(dbPath);
84
+
85
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
86
+
87
+ function runMergeLock(action) {
88
+ const lockScript = join(crewHome, "lib", "merge-lock.sh");
89
+ try {
90
+ execFileSync(lockScript, [action, taskId], {
91
+ env: { ...process.env, CREW_HOME: crewHome },
92
+ encoding: "utf8",
93
+ timeout: 30000,
94
+ });
95
+ return { ok: true };
96
+ } catch (e) {
97
+ return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
98
+ }
99
+ }
100
+
101
+ // --- Phase 1: prepare --------------------------------------------------------
102
+ // Acquires the lock, re-checks HEAD, re-classifies, journals intent,
103
+ // generates the diff. On success the lock is HELD and the output carries
104
+ // the diff path/sha for the agent's artifact_edit child.
105
+
106
+ if (phase === "prepare") {
107
+ // Derive the unknown attempt (same derivation as scan-publish-unknown).
108
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
109
+ if (!task) fail("unknown-task", `task ${taskId} not found`);
110
+ if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
111
+
112
+ const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
113
+ const slug = project && project.deploy_slug;
114
+ if (!slug) fail("no-slug", "project has no deploy_slug");
115
+ const repoPath = (project && project.repo_path) || null;
116
+
117
+ const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
118
+ let entries = [];
119
+ try {
120
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
121
+ .filter((l) => l.trim().length > 0)
122
+ .map((l) => JSON.parse(l))
123
+ .filter((e) => e.task_id === taskId);
124
+ } catch (e) {
125
+ fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
126
+ }
127
+ const unknownEntry = entries[entries.length - 1];
128
+ if (!unknownEntry || unknownEntry.outcome !== "unknown") {
129
+ fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
130
+ }
131
+ const commit = unknownEntry.commit;
132
+ const attempt = unknownEntry.attempt || null;
133
+ if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
134
+ fail("bad-commit", "unknown ledger entry carries no usable commit hash");
135
+ }
136
+
137
+ // 1. Re-check HEAD: must still be the retry target.
138
+ let head;
139
+ try {
140
+ head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
141
+ } catch (e) {
142
+ fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
143
+ }
144
+ if (head !== commit) {
145
+ writePublishNote(db, taskId,
146
+ `publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
147
+ process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
148
+ process.exit(0);
149
+ }
150
+
151
+ // 2. Acquire the merge lock.
152
+ const lock = runMergeLock("acquire");
153
+ if (!lock.ok) {
154
+ process.stdout.write(JSON.stringify({
155
+ ok: true, phase: "prepare", outcome: "lock-held",
156
+ message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
157
+ }) + "\n");
158
+ process.exit(0);
159
+ }
160
+
161
+ // From here to the end of phase 1, the lock is held. Any failure must
162
+ // release it — try/finally.
163
+ let lockHeld = true;
164
+ const releaseLock = () => {
165
+ if (lockHeld) {
166
+ runMergeLock("release");
167
+ lockHeld = false;
168
+ }
169
+ };
170
+
171
+ try {
172
+ // 3. Inside the lock, re-classify.
173
+ let base = EMPTY_TREE;
174
+ try {
175
+ const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
176
+ if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
177
+ } catch (e) {
178
+ releaseLock();
179
+ fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
180
+ }
181
+
182
+ const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
183
+ // Find the trigger entry for the manifest baseline.
184
+ const trigger = [...entries].reverse().find((e) =>
185
+ e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
186
+ const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
187
+
188
+ // Find the park note timestamp for quiesce calculation.
189
+ const parkNote = db.prepare(
190
+ `SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
191
+ AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
192
+ ORDER BY timestamp DESC LIMIT 1`
193
+ ).get(taskId);
194
+ if (!parkNote) {
195
+ releaseLock();
196
+ fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
197
+ }
198
+
199
+ const classifierArgs = [
200
+ classifierPath,
201
+ "--slug", slug,
202
+ "--commit", commit,
203
+ "--base", base,
204
+ "--trigger-ts", trigger ? trigger.ts : parkNote.timestamp,
205
+ "--park-ts", parkNote.timestamp,
206
+ "--repo-path", repoPath,
207
+ ];
208
+ if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
209
+
210
+ let classification;
211
+ try {
212
+ const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
213
+ classification = JSON.parse(out);
214
+ } catch (e) {
215
+ releaseLock();
216
+ fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
217
+ }
218
+
219
+ if (!classification.ok || classification.decision !== "provably-dropped") {
220
+ // Not dropped — release the lock and route via the existing
221
+ // record-retry-recheck path (the code owns the routing).
222
+ releaseLock();
223
+ process.stdout.write(JSON.stringify({
224
+ ok: true, phase: "prepare", outcome: "not-dropped",
225
+ decision: classification.decision,
226
+ message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
227
+ classification,
228
+ }) + "\n");
229
+ process.exit(0);
230
+ }
231
+
232
+ // 3b. Capture the pre-retry manifest baseline (inside the lock).
233
+ let retryBaseline = null;
234
+ try {
235
+ const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
236
+ if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
237
+ retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
238
+ }
239
+ } catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
240
+
241
+ // 4. Journal the intent BEFORE the trigger.
242
+ writePublishNote(db, taskId,
243
+ `publish: retry-intended ${commit} ${nowISO()} — re-issuing the dropped edit once; if this tick dies before the trigger returns, the next scan mirrors verification-requested rather than re-triggering.`);
244
+
245
+ // 5. Regenerate the diff deterministically.
246
+ const diffPath = `/tmp/retry-diff-${taskId}.txt`;
247
+ let diffMeta;
248
+ try {
249
+ const out = execFileSync("node", [
250
+ join(crewHome, "lib", "compute-publish-diff.js"),
251
+ "--repo-path", repoPath,
252
+ "--base", base,
253
+ "--out", diffPath,
254
+ ], { encoding: "utf8" });
255
+ diffMeta = JSON.parse(out);
256
+ } catch (e) {
257
+ releaseLock();
258
+ fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
259
+ }
260
+ if (!diffMeta.ok) {
261
+ releaseLock();
262
+ fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
263
+ }
264
+
265
+ // Success: lock remains HELD. The agent spawns the artifact_edit child
266
+ // with the diff, then calls phase 2 (complete) to finish.
267
+ // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
268
+ // but phase 2 needs the exact commit/attempt/baseline/ledger path).
269
+ const statePath = `/tmp/retry-publish-${taskId}.json`;
270
+ writeFileSync(statePath, JSON.stringify({
271
+ task_id: taskId, commit, attempt, slug, repoPath,
272
+ project_id: project.id, ledger_path: ledgerPath,
273
+ retry_baseline: retryBaseline,
274
+ diff_path: diffPath, diff_sha256: diffMeta.sha256,
275
+ prepared_at: nowISO(),
276
+ }));
277
+
278
+ process.stdout.write(JSON.stringify({
279
+ ok: true, phase: "prepare", outcome: "ready",
280
+ task_id: taskId, commit, attempt,
281
+ diff_path: diffPath, diff_sha256: diffMeta.sha256,
282
+ slug,
283
+ message: "lock held; spawn the artifact_edit child with the diff, then call phase 2 (complete)",
284
+ }) + "\n");
285
+ process.exit(0);
286
+ } catch (e) {
287
+ releaseLock();
288
+ throw e;
289
+ }
290
+ }
291
+
292
+ // --- Phase 2: complete -------------------------------------------------------
293
+ // Given the artifact_edit outcome, write the outcome notes, append the
294
+ // ledger entry, mirror verification, and RELEASE the lock (finally).
295
+
296
+ if (phase === "complete") {
297
+ const editOutcome = arg("--edit-outcome");
298
+ if (!["accepted", "refused"].includes(editOutcome)) {
299
+ console.error("--edit-outcome must be 'accepted' or 'refused'");
300
+ process.exit(2);
301
+ }
302
+ const refusalText = arg("--refusal-text", false, "");
303
+
304
+ const statePath = `/tmp/retry-publish-${taskId}.json`;
305
+ if (!existsSync(statePath)) {
306
+ fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
307
+ }
308
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
309
+ const { commit, attempt, slug, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
310
+
311
+ let lockHeld = true;
312
+ const releaseLock = () => {
313
+ if (lockHeld) {
314
+ runMergeLock("release");
315
+ lockHeld = false;
316
+ }
317
+ };
318
+
319
+ try {
320
+ if (editOutcome === "refused") {
321
+ // Conclusive negative: rejected, not unknown. Terminal.
322
+ writePublishNote(db, taskId,
323
+ `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
324
+ process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
325
+ return;
326
+ }
327
+
328
+ // The child ended after the edit call with no explicit refusal —
329
+ // fire-and-forget, same shape as the first attempt.
330
+ writePublishNote(db, taskId,
331
+ `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
332
+
333
+ // Mirror verification with a settle window.
334
+ const notBefore = new Date(Date.now() + 20 * 60 * 1000).toISOString();
335
+ const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
336
+ writePublishNote(db, taskId,
337
+ `publish: verification-requested ${commit} attempt=${retryAttempt} not-before=${notBefore} — unknown-recovery retried the dropped edit once; content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
338
+
339
+ // Append the -retry1 ledger entry with the pre-retry baseline (required).
340
+ const ledgerEntry = {
341
+ ts: nowISO(),
342
+ task_id: taskId,
343
+ workflow: "unknown-recovery",
344
+ slug,
345
+ commit,
346
+ attempt: retryAttempt,
347
+ agent_id: null,
348
+ applied_report: null,
349
+ outcome: "submitted",
350
+ manifest_before: retryBaseline,
351
+ detail: "retry trigger re-issued after provably-dropped classification",
352
+ };
353
+ try {
354
+ appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
355
+ } catch (e) {
356
+ // Best-effort: the note journal is the source of truth, not the ledger.
357
+ console.error(`ledger append failed (non-fatal): ${e.message}`);
358
+ }
359
+
360
+ process.stdout.write(JSON.stringify({
361
+ ok: true, phase: "complete", outcome: "issued",
362
+ commit, attempt: retryAttempt, not_before: notBefore,
363
+ }) + "\n");
364
+ } finally {
365
+ releaseLock();
366
+ // Clean up the phase-1 state file (best-effort).
367
+ try { (await import("fs")).unlinkSync(statePath); } catch {}
368
+ }
369
+ process.exit(0);
370
+ }