muse-crew 0.14.5 → 0.14.7

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.
Files changed (41) hide show
  1. package/API.md +106 -12
  2. package/docs/decisions/AGENTS.md +5 -0
  3. package/docs/decisions/publish-path.md +247 -9
  4. package/docs/decisions/qa-reproduce.md +30 -0
  5. package/docs/guide.md +41 -5
  6. package/docs/publish-unknown-recovery.md +28 -120
  7. package/docs/publish-verification.md +148 -501
  8. package/lib/AGENTS.md +9 -13
  9. package/lib/advance-publish-base.js +3 -3
  10. package/lib/compose-evidence-caption.js +1 -2
  11. package/lib/compute-publish-diff.js +82 -5
  12. package/lib/crew-api.js +1134 -1027
  13. package/lib/merge-lock.sh +153 -41
  14. package/lib/publish-note-vocabulary.js +135 -42
  15. package/lib/qa-db.js +132 -0
  16. package/lib/schema.sql +69 -0
  17. package/lib/serve-artifact.js +46 -2
  18. package/lib/test-detached-integrate.sh +122 -0
  19. package/lib/test-merge-lock.sh +30 -1
  20. package/lib/worktree-lifecycle.sh +284 -34
  21. package/package.json +1 -1
  22. package/seed/AGENTS.md +1 -0
  23. package/seed/cron-body-ack-scan.md +44 -0
  24. package/seed/cron-body-template.md +55 -89
  25. package/seed/crons.json +12 -0
  26. package/workflows/AGENTS.md +1 -1
  27. package/workflows/bugfix.js +489 -224
  28. package/workflows/chore.js +306 -226
  29. package/workflows/crew-dispatch.js +57 -4
  30. package/workflows/crew-init.js +23 -0
  31. package/workflows/crew-uninstall.js +7 -4
  32. package/workflows/docs.js +24 -2
  33. package/workflows/standard.js +494 -251
  34. package/workflows/upgrade.js +13 -1
  35. package/lib/build-readback-request.js +0 -140
  36. package/lib/check-intent-freshness.js +0 -101
  37. package/lib/classify-publish-absence.js +0 -462
  38. package/lib/publish-content.js +0 -154
  39. package/lib/readback-disk.js +0 -195
  40. package/lib/retry-publish.js +0 -417
  41. package/lib/verify-publish.js +0 -416
@@ -1,195 +0,0 @@
1
- #!/usr/bin/env node
2
- // readback-disk.js — deterministic disk read-back sensor for parent publish
3
- // verification (docs/publish-verification.md).
4
- //
5
- // The read-back gap (2026-09-14): the platform removed `artifact_inspect`
6
- // and no agent-callable content read-back existed, so every artifact publish
7
- // parked at `publish: verification-requested` permanently. The platform's
8
- // artifact edits land in its on-disk working copy of the artifact source
9
- // (~/workspace/ts-spaces/<slug>/ — verified empirically 2026-09-15: added
10
- // lines present, removed lines absent across real platform commits), so a
11
- // deterministic local sensor can replace the removed inspection tool: no
12
- // LLM, no async handoff, no prose to parse.
13
- //
14
- // This script is the SENSOR; lib/verify-publish.js stays the judge. It
15
- // emits the exact machine-readable findings block the verifier already
16
- // parses:
17
- //
18
- // FILE: <path>
19
- // ADDED: <exact added line from the diff> :: PRESENT|ABSENT
20
- // REMOVED: <exact removed line from the diff> :: PRESENT|ABSENT
21
- // END_FILE
22
- //
23
- // one ADDED line per (+) diff line and one REMOVED line per (-) diff line,
24
- // in diff order. The verdict is PRESENT iff that exact line occurs in the
25
- // file's CURRENT on-disk source (whole-line match), ABSENT otherwise.
26
- //
27
- // Authority boundary: the sensor reads the platform's working copy of the
28
- // artifact source — the tree the hosted artifact is built/served from. If
29
- // the working copy is stale relative to a just-applied edit, the findings
30
- // honestly report ABSENT and the verifier fails CLOSED (parked, never a
31
- // false stamp). Staleness can only park, never certify.
32
- //
33
- // Usage:
34
- // node readback-disk.js --repo-path <path> --commit <sha> --base <sha>
35
- // --slug <artifact-slug> --task-id <uuid> [--spaces-root <dir>]
36
- //
37
- // --base is the previously-stamped provenance source_commit (or the
38
- // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
39
- // publish) — the SAME base the verifier uses. --spaces-root defaults to
40
- // ~/workspace/ts-spaces.
41
- //
42
- // Exit codes: 0 ok (findings block on stdout) · 2 usage/validation ·
43
- // 1 git/diff/read failure. On non-zero exit NOTHING is printed to stdout,
44
- // so a caller must never save stdout as a findings file.
45
- //
46
- // Security: the slug is charset-validated and the resolved space directory
47
- // must stay under the spaces root (realpath containment); every file path
48
- // from the diff is likewise contained before reading. A path that escapes
49
- // is a validation failure (exit 2), never a read.
50
-
51
- import { execFileSync } from "node:child_process";
52
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
53
- import { homedir } from "node:os";
54
- import { join, resolve } from "node:path";
55
-
56
- // --help: before required-arg parsing (shebang⇔CLI contract).
57
- if (process.argv.slice(2).includes("--help")) {
58
- console.log(
59
- "usage: node readback-disk.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> [--spaces-root <dir>]\n" +
60
- "the deterministic disk read-back SENSOR: emits the machine-readable findings block (FILE:/ADDED:/REMOVED:/END_FILE)"
61
- );
62
- process.exit(0);
63
- }
64
-
65
- const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
66
-
67
- function arg(name) {
68
- const i = process.argv.indexOf(name);
69
- if (i < 0 || i + 1 >= process.argv.length) {
70
- fail("usage", `${name} is required.`, 2);
71
- }
72
- return process.argv[i + 1];
73
- }
74
- function fail(code, message, exitCode) {
75
- // stderr only — stdout must stay empty on failure.
76
- process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
77
- process.exit(exitCode);
78
- }
79
-
80
- const repoPath = arg("--repo-path");
81
- const commit = arg("--commit");
82
- const base = arg("--base");
83
- const slug = arg("--slug");
84
- const taskId = arg("--task-id");
85
- const spacesRoot = process.argv.includes("--spaces-root")
86
- ? arg("--spaces-root")
87
- : join(homedir(), "workspace", "ts-spaces");
88
-
89
- if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
90
- if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
91
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(slug)) {
92
- fail("usage", `slug ${JSON.stringify(slug)} is not a safe artifact slug.`, 2);
93
- }
94
-
95
- // Resolve the space directory with realpath containment: the slug must not
96
- // escape the spaces root (no "..", no symlinks pointing out).
97
- let spaceDir;
98
- try {
99
- const rootReal = realpathSync(spacesRoot);
100
- const candReal = realpathSync(join(spacesRoot, slug));
101
- if (candReal !== rootReal && !candReal.startsWith(rootReal + "/")) {
102
- fail("usage", `slug ${JSON.stringify(slug)} resolves outside the spaces root.`, 2);
103
- }
104
- spaceDir = candReal;
105
- } catch (e) {
106
- fail("usage", `cannot resolve space dir for slug ${JSON.stringify(slug)}: ${e.message}`, 2);
107
- }
108
- try {
109
- if (!statSync(spaceDir).isDirectory()) fail("usage", `space dir ${spaceDir} is not a directory.`, 2);
110
- } catch (e) {
111
- fail("usage", `space dir ${spaceDir} unreadable: ${e.message}`, 2);
112
- }
113
-
114
- // The publish delta is base..commit — the same diff the verifier checks the
115
- // findings against. Refuse a base that is not an ancestor: certifying a
116
- // diff against an unrelated tree is worse than no read-back.
117
- if (base !== EMPTY_TREE) {
118
- try {
119
- execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
120
- } catch (e) {
121
- fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to read back an unrelated tree.`, 2);
122
- }
123
- }
124
-
125
- let diff;
126
- try {
127
- diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
128
- encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
129
- });
130
- } catch (e) {
131
- fail("git", `git diff failed: ${e.message}`, 1);
132
- }
133
- if (!diff.trim()) {
134
- fail("git", "empty diff for commit — nothing to read back.", 1);
135
- }
136
-
137
- // Parse the diff into per-file added/removed lines (byte-identical parser
138
- // to build-readback-request.js and verify-publish.js: the verifier looks
139
- // the raw lines up verbatim, so all three must agree on the split).
140
- const files = [];
141
- let current = null;
142
- for (const line of diff.split("\n")) {
143
- if (line.startsWith("diff --git")) {
144
- const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
145
- current = { path: m ? m[2] : "unknown", added: [], removed: [] };
146
- files.push(current);
147
- } else if (current && line.startsWith("+") && !line.startsWith("+++")) {
148
- current.added.push(line.slice(1));
149
- } else if (current && line.startsWith("-") && !line.startsWith("---")) {
150
- current.removed.push(line.slice(1));
151
- }
152
- }
153
-
154
- // Whole-line content of a disk file, normalized for the membership test.
155
- // Trailing-newline artifact popped (same convention as verify-publish.js
156
- // oldTreeLines); one trailing CR stripped per line so CRLF working copies
157
- // compare honestly against LF diffs. The RAW diff lines are still what get
158
- // emitted — the verifier looks those up verbatim.
159
- const norm = (s) => (s.endsWith("\r") ? s.slice(0, -1) : s);
160
- function diskLines(absPath) {
161
- if (!existsSync(absPath)) return new Set(); // missing file: every line ABSENT
162
- let text;
163
- try {
164
- text = readFileSync(absPath, "utf8");
165
- } catch (e) {
166
- fail("read", `cannot read ${absPath}: ${e.message}`, 1);
167
- }
168
- const lines = text.split("\n");
169
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
170
- return new Set(lines.map(norm));
171
- }
172
-
173
- const out = [];
174
- for (const f of files) {
175
- // Contain every diff path inside the space dir before reading.
176
- const abs = resolve(spaceDir, f.path);
177
- if (abs !== spaceDir && !abs.startsWith(spaceDir + "/")) {
178
- fail("usage", `diff path ${JSON.stringify(f.path)} escapes the space dir.`, 2);
179
- }
180
- const present = diskLines(abs);
181
- out.push(`FILE: ${f.path}`);
182
- for (const line of f.added) {
183
- out.push(`ADDED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
184
- }
185
- for (const line of f.removed) {
186
- out.push(`REMOVED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
187
- }
188
- out.push("END_FILE");
189
- }
190
-
191
- process.stdout.write(out.join("\n") + "\n");
192
- process.stderr.write(JSON.stringify({
193
- ok: true, slug, task_id: taskId, commit, base,
194
- files: files.map((f) => f.path),
195
- } ) + "\n");
@@ -1,417 +0,0 @@
1
- #!/usr/bin/env node
2
- // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5;
3
- // ported 2026-09-20: node:sqlite + crew-state.db (blocker 23) and direct
4
- // tick-worker issuance (blocker 22 — children cannot reach artifact_edit).
5
- //
6
- // The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
7
- // lock handling, journaling, diff generation, ledger writes, mirroring, and
8
- // cleanup by following template instructions. A missed step (or a tick dying
9
- // mid-protocol) left the lock held or the state inconsistent.
10
- //
11
- // This command owns all deterministic transitions with try/finally lock
12
- // release. Only the unavoidable `artifact_edit` tool call remains
13
- // tick-mediated (it requires the artifact namespace, which this process
14
- // cannot load): the session-carrying tick worker issues the edit directly
15
- // in its own turn — NEVER a child (blocker 22, room #24).
16
- //
17
- // Two phases:
18
- // Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
19
- // journal intent, generate diff. Outputs JSON with the diff path/sha
20
- // for the tick worker's direct artifact_edit call. The lock is HELD on
21
- // success (the tick must call phase 2 to release it).
22
- // Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
23
- // write the outcome notes, append the ledger entry, mirror verification,
24
- // and RELEASE the lock in a finally block.
25
- //
26
- // Crash safety: if the tick dies between phases, the lock expires after
27
- // 600s and the note journal (`publish: retry-intended` without
28
- // `publish: retry-issued`) lets the next scan mirror verification-requested
29
- // without re-triggering — exactly one retry, enforced from notes.
30
- //
31
- // Usage:
32
- // node retry-publish.js --crew-home <path> --task-id <uuid> --phase prepare
33
- // node retry-publish.js --crew-home <path> --task-id <uuid> --phase complete --edit-outcome <accepted|refused> [--refusal-text <text>]
34
- //
35
- // Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
36
-
37
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
38
- import { join } from "path";
39
- import { execFileSync } from "child_process";
40
- import { randomUUID as uuid } from "crypto";
41
-
42
- const args = process.argv.slice(2);
43
- function arg(name, required = true, def = undefined) {
44
- const i = args.indexOf(name);
45
- if (i === -1 || i + 1 >= args.length) {
46
- if (required) { console.error(`missing required arg ${name}`); process.exit(2); }
47
- return def;
48
- }
49
- return args[i + 1];
50
- }
51
- function fail(code, msg, exitCode = 1) {
52
- process.stdout.write(JSON.stringify({ ok: false, code, message: msg }) + "\n");
53
- process.exit(exitCode);
54
- }
55
-
56
- // --help: handled before any required-arg parsing so
57
- // `node retry-publish.js --help` always exits 0 (entry-gate contract).
58
- if (args.includes("--help")) {
59
- console.log(
60
- "usage: node retry-publish.js --crew-home <path> --task-id <uuid> --phase <prepare|complete>\n" +
61
- " prepare: acquire the merge lock, re-check HEAD, re-classify, journal intent, emit the diff\n" +
62
- " complete: --edit-outcome <accepted|refused> [--refusal-text <text>] — write the outcome,\n" +
63
- " append the ledger entry, mirror verification, and release the lock\n" +
64
- "exit 0 = success, 1 = terminal stop, 2 = usage error"
65
- );
66
- process.exit(0);
67
- }
68
-
69
- const crewHome = arg("--crew-home");
70
- const taskId = arg("--task-id");
71
- const phase = arg("--phase");
72
-
73
- if (!["prepare", "complete"].includes(phase)) {
74
- console.error("phase must be 'prepare' or 'complete'");
75
- process.exit(2);
76
- }
77
-
78
- // Retry protocol: phase-1 state is a persistent-disk pin
79
- // ($CREW_HOME/.pins/ — never /tmp: tmpfs is wiped on cell reboot, and the
80
- // lock release depends on phase 2 reading this state).
81
- function pinsDir() {
82
- const dir = join(crewHome, ".pins");
83
- mkdirSync(dir, { recursive: true });
84
- return dir;
85
- }
86
- function retryStatePath() {
87
- return join(pinsDir(), `retry-publish-${taskId}.json`);
88
- }
89
-
90
- // --- Shared helpers ---------------------------------------------------------
91
-
92
- function nowISO() { return new Date().toISOString(); }
93
-
94
- function writePublishNote(db, taskId, message) {
95
- db.prepare(
96
- `INSERT INTO events (id, type, task_id, identity, message, timestamp)
97
- VALUES (?, 'note', ?, NULL, ?, ?)`
98
- ).run(uuid(), taskId, message, nowISO());
99
- }
100
-
101
- // Load the crew DB via node:sqlite (the runtime's built-in — 2026-09-20,
102
- // blocker 23: better-sqlite3 is not available and the old code pointed at
103
- // the wrong database name). crew-api.js opens the same path the same way.
104
- import { DatabaseSync } from "node:sqlite";
105
- const dbPath = join(crewHome, "crew-state.db");
106
- if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
107
- const db = new DatabaseSync(dbPath);
108
-
109
- const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
110
-
111
- // The merge lock is per-repo (merge-lock.sh fails closed without
112
- // CREW_REPO — 2026-09-20: the old code never set it, so the lock could
113
- // never be acquired). The retry protocol locks the task's own repo.
114
- function runMergeLock(action, repoPath) {
115
- const lockScript = join(crewHome, "lib", "merge-lock.sh");
116
- try {
117
- execFileSync(lockScript, [action, taskId], {
118
- env: { ...process.env, CREW_HOME: crewHome, CREW_REPO: repoPath },
119
- encoding: "utf8",
120
- timeout: 30000,
121
- });
122
- return { ok: true };
123
- } catch (e) {
124
- return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
125
- }
126
- }
127
-
128
- // --- Phase 1: prepare --------------------------------------------------------
129
- // Acquires the lock, re-checks HEAD, re-classifies, journals intent,
130
- // generates the diff. On success the lock is HELD and the output carries
131
- // the diff path/sha for the tick worker's direct artifact_edit call.
132
-
133
- if (phase === "prepare") {
134
- // Derive the unknown attempt (same derivation as scan-publish-unknown).
135
- const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
136
- if (!task) fail("unknown-task", `task ${taskId} not found`);
137
- if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
138
-
139
- const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
140
- const slug = project && project.deploy_slug;
141
- if (!slug) fail("no-slug", "project has no deploy_slug");
142
- const repoPath = (project && project.repo_path) || null;
143
- if (!repoPath) fail("no-repo", "project has no repo_path — the retry protocol locks the task's repo");
144
-
145
- const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
146
- let entries = [];
147
- try {
148
- entries = readFileSync(ledgerPath, "utf8").split("\n")
149
- .filter((l) => l.trim().length > 0)
150
- .map((l) => JSON.parse(l))
151
- .filter((e) => e.task_id === taskId);
152
- } catch (e) {
153
- fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
154
- }
155
- const unknownEntry = entries[entries.length - 1];
156
- if (!unknownEntry || unknownEntry.outcome !== "unknown") {
157
- fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
158
- }
159
- const commit = unknownEntry.commit;
160
- const attempt = unknownEntry.attempt || null;
161
- if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
162
- fail("bad-commit", "unknown ledger entry carries no usable commit hash");
163
- }
164
-
165
- // 1. Re-check HEAD: must still be the retry target.
166
- let head;
167
- try {
168
- head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
169
- } catch (e) {
170
- fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
171
- }
172
- if (head !== commit) {
173
- writePublishNote(db, taskId,
174
- `publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
175
- process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
176
- process.exit(0);
177
- }
178
-
179
- // 2. Acquire the merge lock.
180
- const lock = runMergeLock("acquire", repoPath);
181
- if (!lock.ok) {
182
- process.stdout.write(JSON.stringify({
183
- ok: true, phase: "prepare", outcome: "lock-held",
184
- message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
185
- }) + "\n");
186
- process.exit(0);
187
- }
188
-
189
- // From here to the end of phase 1, the lock is held. Any failure must
190
- // release it — try/finally.
191
- let lockHeld = true;
192
- const releaseLock = () => {
193
- if (lockHeld) {
194
- runMergeLock("release", repoPath);
195
- lockHeld = false;
196
- }
197
- };
198
-
199
- try {
200
- // 3. Inside the lock, re-classify.
201
- let base = EMPTY_TREE;
202
- try {
203
- const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
204
- if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
205
- } catch (e) {
206
- releaseLock();
207
- fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
208
- }
209
-
210
- const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
211
- // Find the trigger entry for the manifest baseline: the OLDEST
212
- // tick-worker "submitted" entry by ts (D1, 2026-09-19; entry_kind cut
213
- // 2026-09-20; issuer anchor 2026-09-20, one-party worker-owned publish —
214
- // only the session-carrying tick worker performs issuance, so only its
215
- // stamped writes bind; a workflow-written "submitted" without issuer is
216
- // a phantom claim, never a baseline source) — it binds issued_at
217
- // (falling back to its ts when the capture was unobserved), the same
218
- // anchor verify-publish.js and findTriggerEntry use.
219
- // agent_id is null on the issuance entry (no receipt observed at trigger
220
- // time) — the baseline comes from manifest_before, never agent_id.
221
- const submitted = entries.filter((e) =>
222
- e.outcome === "submitted" && e.issuer === "tick-worker" &&
223
- e.commit === commit && (attempt == null || e.attempt === attempt));
224
- submitted.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
225
- const trigger = submitted[0] || null;
226
- const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
227
-
228
- // Find the park note timestamp for quiesce calculation.
229
- const parkNote = db.prepare(
230
- `SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
231
- AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
232
- ORDER BY timestamp DESC LIMIT 1`
233
- ).get(taskId);
234
- if (!parkNote) {
235
- releaseLock();
236
- fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
237
- }
238
-
239
- const classifierArgs = [
240
- classifierPath,
241
- "--slug", slug,
242
- "--commit", commit,
243
- "--base", base,
244
- "--trigger-ts", trigger ? (trigger.issued_at || trigger.ts) : parkNote.timestamp,
245
- "--park-ts", parkNote.timestamp,
246
- "--repo-path", repoPath,
247
- ];
248
- if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
249
-
250
- let classification;
251
- try {
252
- const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
253
- classification = JSON.parse(out);
254
- } catch (e) {
255
- releaseLock();
256
- fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
257
- }
258
-
259
- if (!classification.ok || classification.decision !== "provably-dropped") {
260
- // Not dropped — release the lock and route via the existing
261
- // record-retry-recheck path (the code owns the routing).
262
- releaseLock();
263
- process.stdout.write(JSON.stringify({
264
- ok: true, phase: "prepare", outcome: "not-dropped",
265
- decision: classification.decision,
266
- message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
267
- classification,
268
- }) + "\n");
269
- process.exit(0);
270
- }
271
-
272
- // 3b. Capture the pre-retry manifest baseline (inside the lock).
273
- let retryBaseline = null;
274
- try {
275
- const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
276
- if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
277
- retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
278
- }
279
- } catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
280
-
281
- // 4. Journal the intent BEFORE the trigger.
282
- writePublishNote(db, taskId,
283
- `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.`);
284
-
285
- // 5. Regenerate the diff deterministically.
286
- const diffPath = `/tmp/retry-diff-${taskId}.txt`;
287
- let diffMeta;
288
- try {
289
- const out = execFileSync("node", [
290
- join(crewHome, "lib", "compute-publish-diff.js"),
291
- "--repo-path", repoPath,
292
- "--base", base,
293
- "--out", diffPath,
294
- ], { encoding: "utf8" });
295
- diffMeta = JSON.parse(out);
296
- } catch (e) {
297
- releaseLock();
298
- fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
299
- }
300
- if (!diffMeta.ok) {
301
- releaseLock();
302
- fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
303
- }
304
-
305
- // Success: lock remains HELD. The tick worker issues the edit directly
306
- // in its own turn with the diff, then calls phase 2 (complete) to finish.
307
- // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
308
- // but phase 2 needs the exact commit/attempt/baseline/ledger path).
309
- const statePath = retryStatePath();
310
- writeFileSync(statePath, JSON.stringify({
311
- task_id: taskId, commit, attempt, slug, repoPath,
312
- project_id: project.id, ledger_path: ledgerPath,
313
- retry_baseline: retryBaseline,
314
- diff_path: diffPath, diff_sha256: diffMeta.sha256,
315
- prepared_at: nowISO(),
316
- }));
317
-
318
- process.stdout.write(JSON.stringify({
319
- ok: true, phase: "prepare", outcome: "ready",
320
- task_id: taskId, commit, attempt,
321
- diff_path: diffPath, diff_sha256: diffMeta.sha256,
322
- slug,
323
- message: "lock held; issue the artifact_edit directly in this tick (the session-carrying tick worker owns issuance), then call phase 2 (complete)",
324
- }) + "\n");
325
- process.exit(0);
326
- } catch (e) {
327
- releaseLock();
328
- throw e;
329
- }
330
- }
331
-
332
- // --- Phase 2: complete -------------------------------------------------------
333
- // Given the artifact_edit outcome, write the outcome notes, append the
334
- // ledger entry, mirror verification, and RELEASE the lock (finally).
335
-
336
- if (phase === "complete") {
337
- const editOutcome = arg("--edit-outcome");
338
- if (!["accepted", "refused"].includes(editOutcome)) {
339
- console.error("--edit-outcome must be 'accepted' or 'refused'");
340
- process.exit(2);
341
- }
342
- const refusalText = arg("--refusal-text", false, "");
343
-
344
- const statePath = retryStatePath();
345
- if (!existsSync(statePath)) {
346
- fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
347
- }
348
- const state = JSON.parse(readFileSync(statePath, "utf8"));
349
- const { commit, attempt, slug, repoPath, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
350
- // (2026-09-20, one-party publish) The phase-1 pin carries repoPath; the
351
- // merge lock is repo-scoped, so a missing value is a hard failure — the
352
- // finally's release must never run against an undefined repo.
353
- if (!repoPath) fail("no-repo-in-state", "phase-1 state has no repoPath — cannot release the merge lock safely; the lock lease expires on its own");
354
-
355
- let lockHeld = true;
356
- const releaseLock = () => {
357
- if (lockHeld) {
358
- runMergeLock("release", repoPath);
359
- lockHeld = false;
360
- }
361
- };
362
-
363
- try {
364
- if (editOutcome === "refused") {
365
- // Conclusive negative: rejected, not unknown. Terminal.
366
- writePublishNote(db, taskId,
367
- `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
368
- process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
369
- } else {
370
- // The tick worker's direct call ended with no explicit refusal —
371
- // fire-and-forget, same shape as the first attempt.
372
- writePublishNote(db, taskId,
373
- `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
374
-
375
- // Mirror verification with a settle window.
376
- const settleMs = Number(process.env.CREW_RETRY_SETTLE_MS) > 0
377
- ? Number(process.env.CREW_RETRY_SETTLE_MS)
378
- : 20 * 60 * 1000;
379
- const notBefore = new Date(Date.now() + settleMs).toISOString();
380
- const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
381
- writePublishNote(db, taskId,
382
- `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.`);
383
-
384
- // Append the -retry1 ledger entry with the pre-retry baseline (required).
385
- const ledgerEntry = {
386
- ts: nowISO(),
387
- task_id: taskId,
388
- workflow: "unknown-recovery",
389
- slug,
390
- commit,
391
- attempt: retryAttempt,
392
- agent_id: null,
393
- applied_report: null,
394
- outcome: "submitted",
395
- issuer: "tick-worker",
396
- manifest_before: retryBaseline,
397
- detail: "retry trigger re-issued after provably-dropped classification",
398
- };
399
- try {
400
- appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
401
- } catch (e) {
402
- // Best-effort: the note journal is the source of truth, not the ledger.
403
- console.error(`ledger append failed (non-fatal): ${e.message}`);
404
- }
405
-
406
- process.stdout.write(JSON.stringify({
407
- ok: true, phase: "complete", outcome: "issued",
408
- commit, attempt: retryAttempt, not_before: notBefore,
409
- }) + "\n");
410
- }
411
- } finally {
412
- releaseLock();
413
- // Clean up the phase-1 state file (best-effort).
414
- try { unlinkSync(statePath); } catch {}
415
- }
416
- process.exit(0);
417
- }