muse-crew 0.17.2 → 0.17.3

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 (70) hide show
  1. package/API.md +11 -0
  2. package/docs/decisions/composition-machinery.md +180 -0
  3. package/docs/decisions/publish-path.md +6 -0
  4. package/docs/decisions/workflow-core.md +5 -4
  5. package/lib/AGENTS.md +2 -1
  6. package/lib/bugfix/phases/build.js +168 -0
  7. package/lib/bugfix/phases/capture.js +170 -0
  8. package/lib/bugfix/phases/integrate.js +165 -0
  9. package/lib/bugfix/phases/map.js +129 -0
  10. package/lib/bugfix/phases/publish.js +592 -0
  11. package/lib/bugfix/phases/qa.js +356 -0
  12. package/lib/bugfix/phases/reproduce.js +254 -0
  13. package/lib/bugfix/phases/review.js +292 -0
  14. package/lib/bugfix/phases/triage.js +69 -0
  15. package/lib/chore/CONTRACT.md +181 -0
  16. package/lib/chore/DISPOSITION.md +98 -0
  17. package/lib/chore/extract.js +204 -0
  18. package/lib/chore/phase-lib.js +885 -0
  19. package/lib/chore/phases/build.js +110 -0
  20. package/lib/chore/phases/capture.js +82 -0
  21. package/lib/chore/phases/integrate.js +109 -0
  22. package/lib/chore/phases/map.js +81 -0
  23. package/lib/chore/phases/publish.js +543 -0
  24. package/lib/chore/phases/review.js +255 -0
  25. package/lib/chore/phases/triage.js +57 -0
  26. package/lib/chore/prompts/evidence-gatherer.js +41 -0
  27. package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
  28. package/lib/chore/prompts/tool-check.js +15 -0
  29. package/lib/chore/prompts/trailers.js +56 -0
  30. package/lib/chore/prompts/verdict-reask.js +28 -0
  31. package/lib/chore/prompts/verdict-reask.schema.json +1 -0
  32. package/lib/chore/prompts/work-agent.js +52 -0
  33. package/lib/chore/prompts/work-agent.schema.json +1 -0
  34. package/lib/chore/spawn-keys.js +44 -0
  35. package/lib/chore/spawn-vocab.js +87 -0
  36. package/lib/chore-run.js +538 -0
  37. package/lib/chore-tick.js +289 -0
  38. package/lib/crew-api.js +273 -0
  39. package/lib/crew-dispatch-worker.js +27 -7
  40. package/lib/crew-release.sh +7 -2
  41. package/lib/extract.js +252 -0
  42. package/lib/prompts/tool-check.js +18 -0
  43. package/lib/prompts/trailers.js +59 -0
  44. package/lib/prompts/verdict-reask.js +31 -0
  45. package/lib/prompts/verdict-reask.schema.json +1 -0
  46. package/lib/prompts/work-agent.js +56 -0
  47. package/lib/prompts/work-agent.schema.json +1 -0
  48. package/lib/reap-spawns.js +407 -0
  49. package/lib/schema.sql +12 -1
  50. package/lib/spawn-keys.js +47 -0
  51. package/lib/spawn-step.js +572 -0
  52. package/lib/standard/phases/build.js +120 -0
  53. package/lib/standard/phases/capture.js +163 -0
  54. package/lib/standard/phases/integrate.js +172 -0
  55. package/lib/standard/phases/map.js +119 -0
  56. package/lib/standard/phases/publish.js +565 -0
  57. package/lib/standard/phases/qa.js +399 -0
  58. package/lib/standard/phases/review.js +281 -0
  59. package/lib/standard/phases/triage.js +64 -0
  60. package/lib/test-detached-integrate.sh +47 -0
  61. package/lib/workflow-driver.js +605 -0
  62. package/lib/workflow-lib.js +1012 -0
  63. package/lib/workflow-spec.js +187 -0
  64. package/lib/worktree-lifecycle.sh +55 -3
  65. package/package.json +1 -1
  66. package/seed/cron-body-template.md +61 -9
  67. package/workflows/bugfix.js +17 -17
  68. package/workflows/chore.js +16 -16
  69. package/workflows/docs.js +14 -11
  70. package/workflows/standard.js +16 -16
@@ -0,0 +1,565 @@
1
+ // lib/standard/phases/publish.js — Publish (wren) phase module (sandbox
2
+ // exit, Phase C Piece 1). Import-safe: no side effects on import, bare
3
+ // `node` exits 0.
4
+ //
5
+ // Reads: claim/session context, the release decision RE-DERIVED from
6
+ // durable Build notes at every entry (the run-local decision is null on
7
+ // a dispatcher retry resumed at Publish — this is the hydrate step the
8
+ // current source defines but never calls), env.publishType, merge-lock
9
+ // state, npm registry base version, dashboard provenance.
10
+ // Writes: completed/failed session + event (machine-readable marker lines
11
+ // appended after the summary slice — QA's backstop reads TARGET_VERSION=),
12
+ // ~/.publish-diffs/<taskId>.files.json (the complete file list of what
13
+ // the task changed — QA classifies content findings against it),
14
+ // publish-attempt ledger intent entries, post-deploy cleanup (lock release
15
+ // + worktree reclaim).
16
+ // Transitions: ADVANCE → QA on success or honest skip. FAILED on
17
+ // operational failure (retryable under the dispatcher's cap). PARK on:
18
+ // unknown publish target, missing/unparseable release decision, publish
19
+ // verification failure, artifact publish intent (one-party: issuance
20
+ // belongs to the parent layer — the phase parks at intent), vercel
21
+ // (declared but unimplemented — fails honestly).
22
+
23
+ import {
24
+ runCmd, crewApi, recordPhase, summarizeReport, ensureClaimed,
25
+ latestSessionNotes, lifecycle, parkTask, pinLifecycle, PIN_BASENAMES, log,
26
+ } from "../../workflow-lib.js";
27
+ import {
28
+ extractReleaseDecision, bumpVersion, extractMarkerLines,
29
+ } from "../../extract.js";
30
+ import { join, basename, dirname } from "path";
31
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, readlinkSync, appendFileSync } from "fs";
32
+
33
+ export const PHASE = { name: "Publish", identity: "wren" };
34
+ const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
35
+
36
+ // buildInstructions — pure function of ctx. The deterministic publish
37
+ // procedure for the configured publish type (the worker layer runs the
38
+ // pinned scripts directly — no creative agent is dispatched by this
39
+ // phase; the text documents the mechanical procedure).
40
+ export function buildInstructions(ctx) {
41
+ var env = ctx.env;
42
+ var taskId = env.taskId;
43
+ if (!env.publishType) {
44
+ return "Publish is skipped: no publish target is configured for this project (deploy_type empty). Run post-deploy cleanup (release the merge lock, reclaim the worktree) and advance to QA. Nothing is shipped.";
45
+ }
46
+ if (env.publishType === "npm") {
47
+ return "Deterministic npm publish. The release decision was already made and recorded by the workflow — there is no decision to make.\n" +
48
+ "1. Read the registry base version (npm view " + (env.npmPackage || "muse-crew") + " version, falling back to the repo's package.json), validate it is semver, and compute the target version as base + version_bump (the scope from the Build report's release decision).\n" +
49
+ "2. Run exactly one command — the pinned publish script at " + join(env.runLib, "publish-npm.sh") + " with TASK_ID=" + taskId + " REPO_PATH=" + env.repoPath + " PKG=" + (env.npmPackage || "muse-crew") + " TARGET_VERSION=<target> CREW_HOME=" + env.crewHome + " LIFECYCLE=" + env.lifecycle + " RELEASE_SCRIPT=" + env.releaseScript + ".\n" +
50
+ "3. Parse the script's marker lines: PUBLISH_SKIPPED=no-lock-held (empty-diff Integrate — nothing merged, nothing to ship, an honest skip), PUBLISH_SKIPPED=no-npm-publish (npm publish not configured — honest skip), PUBLISH_FAILED=<reason> (operational failure), PUBLISH_COMPLETE=<version> (shipped).\n" +
51
+ "4. On PUBLISH_COMPLETE, verify mechanically: the registry version must equal the target. A mismatch parks fail-closed — the publish did not land.\n" +
52
+ "Do NOT run npm, npm pack, npm publish, or the python publish script directly. Do NOT compare local and registry versions by judgment. Do NOT decide whether to publish.";
53
+ }
54
+ if (env.publishType === "artifact") {
55
+ return "Deterministic artifact publish (one-party: this phase stages the intent; issuance belongs to the parent layer).\n" +
56
+ "1. Read the merge-lock state explicitly (lock-status). UNLOCKED means an empty-diff Integrate — skip gracefully: nothing merged, nothing to ship. Otherwise refresh the lock; a refresh failure parks fail-closed.\n" +
57
+ "2. Verified re-entry guard: if the dashboard provenance stamps this task's merge commit as the repo's current HEAD, the parent already published and verified it — skip issuance entirely and advance to QA.\n" +
58
+ "3. Preflight: the artifact target directory ~/workspace/ts-spaces/" + (env.publishSlug || "<deploy_slug>") + "/ must exist (conclusive). A missing target parks with a ledger 'rejected' entry; an inconclusive preflight parks with 'unknown'.\n" +
59
+ "4. Compute the publish diff deterministically (compute-publish-diff.js from the stamped provenance base), persist the file list to " + env.publishDiffsDir + "/" + taskId + ".files.json for QA attribution, anchor the intent in the publish-attempt ledger, and park at publish intent.\n" +
60
+ "This phase never issues an artifact_edit and never writes an issuance ledger entry — the outcome is 'publish-intent', never 'submitted'.";
61
+ }
62
+ return "Publish target '" + env.publishType + "' is not a known publish type — the phase parks fail-closed. Expected 'npm', 'artifact', 'vercel', or empty (skip publish).";
63
+ }
64
+
65
+ // pinGuard — the Publish preflight from the source: all pinned lifecycle
66
+ // scripts must be present (re-pin once, then park honestly).
67
+ async function pinGuard(env) {
68
+ var present = [];
69
+ try {
70
+ present = readdirSync(env.runLib);
71
+ } catch (e) {
72
+ present = [];
73
+ }
74
+ var missing = PIN_BASENAMES.filter(function (b) { return present.indexOf(b) === -1; });
75
+ if (missing.length > 0) {
76
+ log("Pin guard at Publish: missing " + missing.join(", ") + " — re-pinning once");
77
+ try {
78
+ pinLifecycle(env);
79
+ present = readdirSync(env.runLib);
80
+ } catch (e) {
81
+ present = [];
82
+ }
83
+ missing = PIN_BASENAMES.filter(function (b) { return present.indexOf(b) === -1; });
84
+ }
85
+ if (missing.length > 0) {
86
+ return { parked: await parkTask(env, "Pinned lifecycle scripts missing at Publish even after re-pin: " + missing.join(", ") + " in " + env.runLib + ".") };
87
+ }
88
+ return { ok: true };
89
+ }
90
+
91
+ // postDeploy — STEP 2 (mechanical, always — skip path included):
92
+ // post-deploy commits builder leftovers if any, removes the worktree, and
93
+ // releases the merge lock (forgiving when none is held) — even when the
94
+ // publish itself failed, so a skipped or failed publish can never leave the
95
+ // lock held. Best-effort hygiene: never a gate, never a park.
96
+ async function postDeploy(env) {
97
+ try {
98
+ var res = await lifecycle(env, "post-deploy", [env.taskId]);
99
+ var out = String((res && res.stdout) || "");
100
+ log("Publish post-deploy for task " + env.taskId + ": " + (/DEPLOYED/.test(out) ? "DEPLOYED" : "output: " + out.slice(0, 120)));
101
+ } catch (e) {
102
+ log("Publish post-deploy threw for task " + env.taskId + " (" + ((e && e.message) || e) + ") — forgiving, continuing");
103
+ }
104
+ }
105
+
106
+ // recordPublishLedger — append one line to the publish-attempt ledger
107
+ // (best-effort observability, not a gate — except the intent entry, which
108
+ // is the machine's anchor: the caller fails loudly instead of parking an
109
+ // unclaimable intent). Direct fs append — no agent courier on the worker
110
+ // layer. The ts is stamped as a real UTC instant via `date` (argv, no
111
+ // shell) — the same substitution the sandbox source performs with sed.
112
+ // The ack scan sorts intent entries by ts, so a literal "@LEDGER_TS@"
113
+ // placeholder would mis-order the workflow's attempt-1 entry against the
114
+ // tick worker's re-issues. The timestamp is observability data, never a
115
+ // decision input: no decision branch reads it.
116
+ async function recordPublishLedger(env, entry) {
117
+ var ledgerDir = env.crewHome + "/.publish-ledger";
118
+ var ts = "";
119
+ try {
120
+ ts = String((await runCmd(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"], {})).stdout || "").trim();
121
+ } catch (e) {
122
+ ts = "";
123
+ }
124
+ var line = JSON.stringify({
125
+ ts: ts || "@LEDGER_TS@",
126
+ task_id: env.taskId,
127
+ workflow: env.resolvedWorkflow || "standard",
128
+ slug: env.publishSlug || null,
129
+ commit: entry.commit || null,
130
+ attempt: entry.attempt || null,
131
+ agent_id: entry.agent_id || null,
132
+ applied_report: entry.applied_report || null,
133
+ manifest_before: entry.manifest_before || null,
134
+ outcome: entry.outcome,
135
+ detail: entry.detail || "",
136
+ issued_at: entry.issued_at || null,
137
+ issuer: entry.issuer || null,
138
+ diff_path: entry.diff_path || null,
139
+ diff_sha256: entry.diff_sha256 || null,
140
+ base: entry.base || null,
141
+ publish_attempt: entry.publish_attempt || null,
142
+ version: entry.version || null,
143
+ });
144
+ try {
145
+ mkdirSync(ledgerDir, { recursive: true });
146
+ appendFileSync(ledgerDir + "/" + (env.publishSlug || "unknown") + ".jsonl", line + "\n", "utf8");
147
+ log("Noted publish outcome '" + entry.outcome + "' for task " + env.taskId + " in ledger");
148
+ return true;
149
+ } catch (e) {
150
+ log("Publish ledger write UNCONFIRMED for task " + env.taskId + ": " + ((e && e.message) || e));
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // computeNpmTarget — deterministic pre-publish: read the registry base,
156
+ // validate semver, compute target = base + scope. Fail-closed parks.
157
+ async function computeNpmTarget(env, releaseDecision) {
158
+ var pkg = env.npmPackage || "muse-crew";
159
+ var base = "";
160
+ try {
161
+ base = String((await runCmd(["npm", "view", pkg, "version"], {})).stdout || "").trim();
162
+ } catch (e) {
163
+ base = "";
164
+ }
165
+ if (!base) {
166
+ try {
167
+ base = String((await runCmd(["node", "-p", "require('" + join(env.repoPath, "package.json") + "').version"], {})).stdout || "").trim();
168
+ } catch (e) {
169
+ base = "";
170
+ }
171
+ }
172
+ if (!/^\d+\.\d+\.\d+$/.test(base)) {
173
+ return { parked: await parkTask(env, "Publish base version unreadable: '" + base + "'. Fail-closed.") };
174
+ }
175
+ var scope = (releaseDecision && releaseDecision.version_bump) || "patch";
176
+ var target = { base: base, scope: scope, target: bumpVersion(base, scope) };
177
+ log("Publish target for task " + env.taskId + ": " + base + " + " + scope + " -> " + target.target);
178
+ return { target: target, pkg: pkg };
179
+ }
180
+
181
+ // runNpmPublish — the deterministic publish script, run directly (the
182
+ // source has the Publish agent run exactly this one command; the worker
183
+ // layer skips the agent and runs it mechanically).
184
+ async function runNpmPublish(env, pkg, target) {
185
+ var script = join(env.runLib, "publish-npm.sh");
186
+ var scriptEnv = Object.assign({}, process.env, {
187
+ CREW_HOME: env.crewHome, CREW_REPO: env.repoPath,
188
+ TASK_ID: env.taskId, REPO_PATH: env.repoPath, PKG: pkg,
189
+ TARGET_VERSION: target.target, LIFECYCLE: env.lifecycle,
190
+ RELEASE_SCRIPT: env.releaseScript,
191
+ });
192
+ var out = "";
193
+ try {
194
+ out = String((await runCmd([script], { env: scriptEnv })).stdout || "");
195
+ } catch (e) {
196
+ out = String((e && e.stdout) || "");
197
+ var failLine = /^PUBLISH_FAILED=(.+)$/m.exec(out);
198
+ return { failed: "publish script failed: " + (failLine ? failLine[1].trim() : ((e && e.message) || e).slice(0, 200)), output: out };
199
+ }
200
+ var skip = /^PUBLISH_SKIPPED=(no-lock-held|no-npm-publish)$/m.exec(out);
201
+ if (skip) return { skipped: skip[1], output: out };
202
+ var complete = /^PUBLISH_COMPLETE=(.+)$/m.exec(out);
203
+ if (complete) return { complete: complete[1].trim(), output: out };
204
+ var failed = /^PUBLISH_FAILED=(.+)$/m.exec(out);
205
+ if (failed) return { failed: "publish script reported PUBLISH_FAILED=" + failed[1].trim(), output: out };
206
+ return { failed: "publish script produced no usable marker (no PUBLISH_COMPLETE/PUBLISH_FAILED/PUBLISH_SKIPPED)", output: out };
207
+ }
208
+
209
+ // verifyNpmPublish — the parent verifies the publish (publish-path.md:
210
+ // the agent cannot verify publish; the parent does). Registry version
211
+ // must equal the target, else park fail-closed.
212
+ async function verifyNpmPublish(env, pkg, target) {
213
+ var regVer = "";
214
+ try {
215
+ regVer = String((await runCmd(["npm", "view", pkg, "version"], {})).stdout || "").trim();
216
+ } catch (e) {
217
+ return { parked: await parkTask(env, "Publish verification failed: could not read the registry version (" + ((e && e.message) || e).slice(0, 200) + "). Fail-closed.") };
218
+ }
219
+ if (regVer !== target.target) {
220
+ return { parked: await parkTask(env, "Publish verification failed: registry shows " + regVer + " but the release decision required " + target.target + " (" + target.base + " + " + target.scope + "). The publish did not land.") };
221
+ }
222
+ return { ok: true };
223
+ }
224
+
225
+ // refreshProvenance — provenance-refresh (publish-verification.md): refresh
226
+ // crew_release after a landed publish, preserving source_commit.
227
+ // published_at defaults server-side (stampProvenance: published_at ?? now())
228
+ // so this module never reads the clock.
229
+ async function refreshProvenance(env) {
230
+ var taskId = env.taskId;
231
+ var prov;
232
+ try {
233
+ prov = await crewApi(env, "get-provenance", { project_id: env.projectId });
234
+ } catch (e) {
235
+ return { parked: await parkTask(env, "Provenance refresh failed: could not read dashboard provenance after a verified npm publish (" + ((e && e.message) || e).slice(0, 200) + "). Fail-closed.") };
236
+ }
237
+ var existing = prov && prov.provenance;
238
+ if (!existing) {
239
+ log("Provenance refresh skipped for task " + taskId + ": no existing record to refresh (fresh instance — the dashboard's first artifact publish will create it)");
240
+ return { ok: true, skipped: true };
241
+ }
242
+ var rel;
243
+ try {
244
+ rel = basename(readlinkSync(env.crewHome + "/current"));
245
+ } catch (e) {
246
+ return { parked: await parkTask(env, "Provenance refresh failed: could not resolve the active release symlink (" + ((e && e.message) || e).slice(0, 200) + "). Fail-closed.") };
247
+ }
248
+ try {
249
+ var res = await crewApi(env, "set-provenance", {
250
+ project_id: env.projectId, source_commit: existing.source_commit,
251
+ crew_release: rel, published_at: null, task_id: taskId,
252
+ });
253
+ var ok = res && (res.ok === true || /ok:\s*true/i.test(JSON.stringify(res)));
254
+ if (!ok) {
255
+ return { parked: await parkTask(env, "Provenance refresh failed after a verified npm publish (crew_release " + rel + "). The release is live but the dashboard record is stale — fail-closed.") };
256
+ }
257
+ log("Provenance refreshed for task " + taskId + ": crew_release " + rel);
258
+ return { ok: true };
259
+ } catch (e) {
260
+ return { parked: await parkTask(env, "Provenance refresh failed: could not refresh dashboard provenance after a verified npm publish (" + ((e && e.message) || e).slice(0, 200) + "). Fail-closed.") };
261
+ }
262
+ }
263
+
264
+ // checkArtifactReentry — the verified re-entry guard (blocker 22): if the
265
+ // stamped provenance names THIS task and its source_commit is the repo's
266
+ // current HEAD, the merge commit was already published and verified by the
267
+ // parent — issuance is skipped entirely. A guard failure never claims
268
+ // verification — the task proceeds down the normal path.
269
+ async function checkArtifactReentry(env) {
270
+ try {
271
+ var prov = await crewApi(env, "get-provenance", { project_id: env.projectId });
272
+ var head = String((await runCmd(["git", "-C", env.repoPath, "rev-parse", "HEAD"], {})).stdout || "").trim();
273
+ var p = prov && prov.provenance;
274
+ if (p && p.source_commit && p.source_commit === head && p.task_id === env.taskId) {
275
+ log("Publish verified re-entry for task " + env.taskId + ": provenance stamps " + p.source_commit + " == HEAD for this task — the parent already published and verified it; issuance skipped");
276
+ return true;
277
+ }
278
+ } catch (e) {
279
+ log("Publish re-entry guard threw for task " + env.taskId + " (" + ((e && e.message) || e).slice(0, 200) + ") — proceeding with the normal publish path; a guard failure never claims verification");
280
+ }
281
+ return false;
282
+ }
283
+
284
+ // publishArtifact — the one-party artifact publish path. Everything through
285
+ // the intent anchor is read-only and deterministic (direct calls, no agent
286
+ // couriers — the worker layer has no ferry constraint). Parks at intent:
287
+ // issuance belongs to the parent layer, which issues the edit directly and
288
+ // whose acknowledgement scan owns the task from there.
289
+ async function publishArtifact(env, state) {
290
+ var taskId = env.taskId;
291
+ var slug = env.publishSlug;
292
+
293
+ // STEP 0 (mechanical): read the merge-lock state explicitly — never infer
294
+ // it from prose. An empty-diff Integrate (MERGED_EMPTY) intentionally
295
+ // takes no lock: nothing merged, nothing to ship. When no lock is held,
296
+ // Publish skips gracefully instead of failing closed on refresh-lock.
297
+ var lockOut = "";
298
+ try {
299
+ lockOut = String((await lifecycle(env, "lock-status", [taskId])).stdout || "").trim();
300
+ } catch (e) {
301
+ return { parked: await parkTask(env, "Publish lock state unreadable: " + ((e && e.message) || e).slice(0, 200) + ". Fail-closed — lock state unknown.") };
302
+ }
303
+ if (lockOut === "UNLOCKED") {
304
+ log("Publish skipped for task " + taskId + ": no merge lock held (empty-diff Integrate) — nothing to ship");
305
+ await postDeploy(env);
306
+ await recordPhase(env, {
307
+ task_id: taskId,
308
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed",
309
+ notes: "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)" },
310
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
311
+ });
312
+ return { type: "ADVANCE", next: "QA" };
313
+ }
314
+ try {
315
+ await lifecycle(env, "refresh-lock", [taskId]);
316
+ } catch (e) {
317
+ return { parked: await parkTask(env, "Publish lock refresh failed: " + ((e && e.message) || e).slice(0, 200) + ". Fail-closed — lock state unknown.") };
318
+ }
319
+
320
+ // Verified re-entry: the intent park's terminal cleanup already released
321
+ // the lock and reclaimed the worktree. Post-deploy below is best-effort
322
+ // hygiene on this branch — never a gate, never a park.
323
+ if (await checkArtifactReentry(env)) {
324
+ await postDeploy(env);
325
+ await recordPhase(env, {
326
+ task_id: taskId,
327
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed",
328
+ notes: "Publish verified re-entry: provenance stamps this task's merge commit as HEAD — the parent already published and verified it; issuance skipped." },
329
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
330
+ });
331
+ return { type: "ADVANCE", next: "QA" };
332
+ }
333
+
334
+ // Artifact preflight: the project is classified artifact but the target
335
+ // must provably exist. Conclusive missing -> ledger "rejected" + park;
336
+ // inconclusive -> ledger "unknown" + park. (Publish edits an existing
337
+ // artifact; it never creates one.)
338
+ if (!slug) {
339
+ await recordPublishLedger(env, { outcome: "rejected", detail: "artifact surface with empty deploy_slug (preflight): the project is classified as artifact but has no deploy_slug — misconfiguration, not a missing artifact. No edit was issued." });
340
+ return { parked: await parkTask(env, "Publish cannot proceed for task " + taskId + ": the project is classified as an artifact surface but has no deploy_slug. This is a project configuration defect — set a deploy_slug for the project, then re-run Publish. Human attention needed.") };
341
+ }
342
+ if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
343
+ await recordPublishLedger(env, { outcome: "unknown", detail: "artifact preflight inconclusive: PUBLISH_SLUG has an unsafe shape — fail-closed, no edit issued." });
344
+ return { parked: await parkTask(env, "Publish cannot proceed for task " + taskId + ": the artifact target preflight was inconclusive (deploy_slug has an unsafe shape). Target existence is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.") };
345
+ }
346
+ var artifactTargetDir = join(process.env.HOME || "", "workspace/ts-spaces", slug);
347
+ var targetPresent = false;
348
+ try {
349
+ targetPresent = readdirSync(artifactTargetDir).length >= 0;
350
+ } catch (e) {
351
+ targetPresent = false;
352
+ }
353
+ if (!targetPresent) {
354
+ await recordPublishLedger(env, { outcome: "rejected", detail: "artifact target directory missing (preflight): " + artifactTargetDir + " does not exist — setup never provisioned the artifact for deploy_slug " + slug + ". The edit provably did not go through: no trigger issued, no blind retry" });
355
+ return { parked: await parkTask(env, "Publish cannot proceed for task " + taskId + ": the artifact target directory " + artifactTargetDir + " does not exist. The project is classified as an artifact surface (deploy_slug " + slug + ") but setup never provisioned the artifact — this is conclusive (rejected, not unknown): no edit was issued. Create the artifact via the Muse UI (Publish edits an existing artifact; it never creates one), then re-run init and Publish. Human attention needed.") };
356
+ }
357
+ log("Publish artifact preflight for task " + taskId + ": target " + artifactTargetDir + " present");
358
+
359
+ // Stamped publish base (per-project provenance; empty tree = first publish).
360
+ var publishBase = "";
361
+ try {
362
+ var provResult = await crewApi(env, "get-provenance", { project_id: env.projectId });
363
+ publishBase = String((provResult && provResult.provenance && provResult.provenance.source_commit) || "").trim();
364
+ } catch (e) {
365
+ return { parked: await parkTask(env, "Publish cannot proceed for task " + taskId + ": the stamped publish base is unreadable (" + ((e && e.message) || e).slice(0, 200) + "). Human attention needed.") };
366
+ }
367
+ if (!publishBase) {
368
+ publishBase = EMPTY_TREE_SHA;
369
+ log("Publish base for task " + taskId + ": no provenance stamped yet — using empty tree (first publish)");
370
+ } else if (!/^[0-9a-f]{40}$/.test(publishBase)) {
371
+ return { parked: await parkTask(env, "Publish base '" + publishBase + "' is not a valid commit SHA — cannot compute the publish diff. Human attention needed.") };
372
+ }
373
+
374
+ // Deterministic diff (diff travels via file, never through an LLM).
375
+ var diffFile = env.publishDiffsDir + "/" + taskId + (state.reworkCount > 0 ? "-r" + state.reworkCount : "") + ".diff";
376
+ var diffSummary = null;
377
+ try {
378
+ mkdirSync(env.publishDiffsDir, { recursive: true });
379
+ var diffOut = String((await runCmd(["node", join(env.runLib, "compute-publish-diff.js"),
380
+ "--repo-path", env.repoPath, "--base", publishBase,
381
+ "--task-id", taskId, "--attempt", "1", "--out", diffFile], {})).stdout || "").trim();
382
+ diffSummary = JSON.parse(diffOut);
383
+ } catch (e) {
384
+ return { parked: await parkTask(env, "Publish diff computation returned unparseable output — cannot verify application. Human attention needed.") };
385
+ }
386
+ if (!diffSummary || diffSummary.ok !== true) {
387
+ if (diffSummary && diffSummary.error === "not-ancestor") {
388
+ return { parked: await parkTask(env, "Publish base " + publishBase.slice(0, 12) + " is not an ancestor of HEAD " + String(diffSummary.commit || "").slice(0, 12) + " — the stamped provenance does not lead to the integrated commit. Human attention needed.") };
389
+ }
390
+ return { parked: await parkTask(env, "Publish diff computation failed (" + ((diffSummary && diffSummary.error) || "unknown error") + ") — cannot verify application. Human attention needed.") };
391
+ }
392
+ if (diffSummary.base !== publishBase) {
393
+ return { parked: await parkTask(env, "Publish diff base mismatch: script reported '" + String(diffSummary.base || "").slice(0, 12) + "' but the stamped base is '" + publishBase.slice(0, 12) + "'. Human attention needed.") };
394
+ }
395
+ var mergeCommitForPublish = String(diffSummary.commit || "").trim();
396
+ var publishVersion = String(diffSummary.version || "");
397
+ if (!/^[0-9a-f]{40}:[1-9][0-9]*$/.test(publishVersion)) {
398
+ return { parked: await parkTask(env, "Publish diff for commit " + (mergeCommitForPublish || "unknown") + " carries no usable publish version — cannot stage a verifiable intent. Human attention needed.") };
399
+ }
400
+ if (!diffSummary.bytes) {
401
+ return { parked: await parkTask(env, "Publish diff is empty for commit " + (mergeCommitForPublish || "unknown") + " — a merge lock was held but there is no change to carry. Human attention needed.") };
402
+ }
403
+ if (diffSummary.has_binary) {
404
+ return { parked: await parkTask(env, "Publish diff contains binary files — the diff transport cannot carry them. Human attention needed.") };
405
+ }
406
+ if (diffSummary.has_rename) {
407
+ return { parked: await parkTask(env, "Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.") };
408
+ }
409
+ // Persist the publish-diff file set for QA content-finding attribution
410
+ // (blocker 38): the complete record of what the task changed. Direct fs
411
+ // write — the worker layer needs no agent courier.
412
+ var filesPath = env.publishDiffsDir + "/" + taskId + ".files.json";
413
+ try {
414
+ writeFileSync(filesPath, JSON.stringify(diffSummary.files || []) + "\n", "utf8");
415
+ } catch (e) {
416
+ return { parked: await parkTask(env, "Publish diff file list could not be persisted (" + ((e && e.message) || e).slice(0, 200) + ") — QA attribution would be blind. Human attention needed.") };
417
+ }
418
+ // Budget counts CHANGED lines (added + removed), not raw unified-diff
419
+ // output lines. The count comes from the deterministic script summary,
420
+ // never from an LLM.
421
+ var changedLines = diffSummary.changed_lines || 0;
422
+ if (changedLines > 200) {
423
+ return { parked: await parkTask(env, "Publish diff changes " + changedLines + " lines (budget 200) — too large for the diff transport. Human attention needed.") };
424
+ }
425
+ if (!diffSummary.file_count) {
426
+ return { parked: await parkTask(env, "Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.") };
427
+ }
428
+
429
+ // Anchor the intent. The entry is issuer-written BY THE WORKFLOW and
430
+ // names the intent: outcome "publish-intent", never "submitted" — the
431
+ // workflow issued nothing. The parent claims it via the park note below.
432
+ var intentAnchored = await recordPublishLedger(env, {
433
+ commit: mergeCommitForPublish,
434
+ publish_attempt: 1,
435
+ version: publishVersion,
436
+ agent_id: null,
437
+ applied_report: null,
438
+ outcome: "publish-intent",
439
+ issuer: "workflow",
440
+ diff_path: diffFile,
441
+ diff_sha256: String(diffSummary.sha256 || ""),
442
+ base: publishBase,
443
+ detail: "publish intent: preflight and checksummed diff are complete; issuance is owned by the parent layer. The workflow performed no artifact_edit and wrote no issuance.",
444
+ });
445
+ if (!intentAnchored) {
446
+ // Fail loudly instead of parking an orphan intent.
447
+ await recordPhase(env, {
448
+ task_id: taskId,
449
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "failed",
450
+ notes: "publish-intent ledger anchor write unconfirmed — refusing to park an unclaimable intent" },
451
+ event: { task_id: taskId, type: "failed", identity: PHASE.identity, message: PHASE.name + " failed by " + PHASE.identity },
452
+ });
453
+ return { type: "FAILED", reason: "publish-intent ledger anchor write unconfirmed for task " + taskId + " — refusing to park an unclaimable intent" };
454
+ }
455
+ return { parked: await parkTask(env, "publish: publish-requested " + mergeCommitForPublish + " attempt=1 — Publish intent recorded for task " + taskId + ": the merged change is staged as a checksummed diff (" + diffFile + ", sha256 " + String(diffSummary.sha256 || "") + ") carrying the per-attempt publish version for the parent layer to issue. The parent issues the edit directly and the acknowledgement scan owns the task from there; no action needed.") };
456
+ }
457
+
458
+ export async function runPhase(ctx) {
459
+ var env = ctx.env, state = ctx.state;
460
+ var taskId = env.taskId;
461
+ var claimed = await ensureClaimed(env, state, PHASE);
462
+ if (claimed.type !== "CLAIMED") return claimed;
463
+
464
+ // Publish is optional and target-based. Empty target = prototyping
465
+ // project: skip the phase. Unknown target (incl. legacy "repo") = config
466
+ // error: block.
467
+ if (!env.publishType) {
468
+ log("Publish skipped for task " + taskId + " — no publish target configured (deploy_type empty)");
469
+ await postDeploy(env);
470
+ await recordPhase(env, {
471
+ task_id: taskId,
472
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed",
473
+ notes: "Publish completed — no publish target configured (nothing to ship). Advancing to QA." },
474
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
475
+ });
476
+ return { type: "ADVANCE", next: "QA" };
477
+ }
478
+ if (env.publishType !== "npm" && env.publishType !== "artifact" && env.publishType !== "vercel") {
479
+ log("Unknown publish target for task " + taskId + ": " + env.publishType);
480
+ return { parked: await parkTask(env, "Unknown publish target '" + env.publishType + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish).") };
481
+ }
482
+ if (env.publishType === "vercel") {
483
+ return { parked: await parkTask(env, "Vercel publish is declared but has no implementation path. Human attention needed: implement the Vercel publish path or change the project's publish type.") };
484
+ }
485
+
486
+ var pg = await pinGuard(env);
487
+ if (pg.parked) return pg.parked;
488
+
489
+ // Merge-time versioning, decided deterministically: the release decision
490
+ // is re-derived from the durable Build notes at every Publish entry (a
491
+ // dispatcher retry resumed at Publish never re-runs Build). release: no
492
+ // skips like the no-target case; an unparseable decision blocks
493
+ // fail-closed.
494
+ var releaseDecision = extractReleaseDecision(await latestSessionNotes(env, "Build", "completed"));
495
+ state.memo.releaseDecision = releaseDecision;
496
+
497
+ if (env.publishType === "npm") {
498
+ if (!releaseDecision) {
499
+ return { parked: await parkTask(env, "Build report has no machine-readable release:/version_bump: declaration — cannot assign version at publish time.") };
500
+ }
501
+ if (releaseDecision.release === "no") {
502
+ log("Publish skipped for task " + taskId + " — accepted Build report declared release: no");
503
+ await postDeploy(env);
504
+ await recordPhase(env, {
505
+ task_id: taskId,
506
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed",
507
+ notes: "Publish skipped — accepted Build report declared release: no (internal-only change, nothing to ship). Advancing to QA." },
508
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
509
+ });
510
+ return { type: "ADVANCE", next: "QA" };
511
+ }
512
+ var ct = await computeNpmTarget(env, releaseDecision);
513
+ if (ct.parked) return ct.parked;
514
+ var target = ct.target, pkg = ct.pkg;
515
+
516
+ var pub = await runNpmPublish(env, pkg, target);
517
+ await postDeploy(env);
518
+ var markerLines, status, eventType;
519
+ if (pub.failed) {
520
+ markerLines = "TARGET_VERSION=" + target.target + " computed as " + target.base + " + " + target.scope + " → " + target.target + "\n" +
521
+ "publish: FAILED — " + pub.failed;
522
+ status = "failed"; eventType = "failed";
523
+ await recordPhase(env, {
524
+ task_id: taskId,
525
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: status, notes: markerLines },
526
+ event: { task_id: taskId, type: eventType, identity: PHASE.identity, message: PHASE.name + " failed by " + PHASE.identity },
527
+ });
528
+ return { type: "FAILED", reason: "npm publish failed: " + pub.failed };
529
+ }
530
+ if (pub.skipped) {
531
+ // The skip path records the computed target and the explicit skip —
532
+ // never "published:", which would be a false claim.
533
+ var skipNote = pub.skipped === "no-npm-publish"
534
+ ? "skipped: no-npm-publish (npm publish not configured — helper or credential missing; nothing versioned or published)"
535
+ : "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)";
536
+ log("Publish skipped for task " + taskId + " (" + pub.skipped + "): publish verification vacuous, nothing was shipped");
537
+ markerLines = "TARGET_VERSION=" + target.target + " computed as " + target.base + " + " + target.scope + " → " + target.target + "\n" + skipNote;
538
+ await recordPhase(env, {
539
+ task_id: taskId,
540
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed", notes: markerLines },
541
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
542
+ });
543
+ return { type: "ADVANCE", next: "QA" };
544
+ }
545
+ // Landed — the parent verifies (the agent cannot verify publish).
546
+ var pv = await verifyNpmPublish(env, pkg, target);
547
+ if (pv.parked) return pv.parked;
548
+ var pr = await refreshProvenance(env);
549
+ if (pr.parked) return pr.parked;
550
+ markerLines = "TARGET_VERSION=" + target.target + " computed as " + target.base + " + " + target.scope + " → " + target.target + "\n" +
551
+ "published: " + pkg + "@" + target.target;
552
+ var workerMarkers = extractMarkerLines(pub.output || "");
553
+ await recordPhase(env, {
554
+ task_id: taskId,
555
+ session: { id: state.activeSessionId, task_id: taskId, identity: PHASE.identity, step: PHASE.name, status: "completed",
556
+ notes: markerLines + (workerMarkers ? "\n" + workerMarkers : "") },
557
+ event: { task_id: taskId, type: "completed", identity: PHASE.identity, message: PHASE.name + " completed by " + PHASE.identity },
558
+ });
559
+ log("Publish complete for task " + taskId + ": " + pkg + "@" + target.target);
560
+ return { type: "ADVANCE", next: "QA" };
561
+ }
562
+
563
+ // Artifact path (one-party publish).
564
+ return await publishArtifact(env, state);
565
+ }