muse-crew 0.6.6 → 0.6.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.
@@ -29,31 +29,57 @@ const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
29
29
  const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
30
30
  const CLAIM_WORKFLOW_PERSIST = (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) ? ", \"workflow\": \"" + RESOLVED_WORKFLOW + "\"" : "";
31
31
 
32
+ // Visual verdict protocol availability — the workflow parks for parent-run
33
+ // baseline capture and visual verdict ONLY when the protocol is fully
34
+ // shipped. The protocol requires docs/visual-verdict.md in the release AND
35
+ // the parent-side capture tooling (task b309a97d, "QA owns the visual
36
+ // verdict"). Until both exist, the parks would deadlock waiting for a
37
+ // parent who cannot fulfill them.
38
+ // Effective value for this run, resolved by the dispatcher from the
39
+ // project's visual_protocol setting (null=inherits crew default=off).
40
+ // Manual launches without the arg default to off (previous behavior).
41
+ var VISUAL_PROTOCOL_AVAILABLE = inputs.visual_protocol === true;
42
+
32
43
  // Config from args — backward-compatible fallbacks for manual launches
33
- const DASHBOARD_SLUG = inputs.dashboardSlug || "orchestra-dashboard";
34
44
  const crewHome = inputs.crewHome || "~/workspace/.jarvis";
45
+ // Crew API: the workflow calls the crew-owned CLI, not the dashboard.
46
+ // The CLI implements the API.md contract against $CREW_HOME/crew-state.db.
47
+ const CREW_API = crewHome + "/current/lib/crew-api.js";
48
+ // Build a shell command invoking the CLI. Args are JSON-encoded and
49
+ // single-quote-wrapped for safe shell passing. The agent runs this and
50
+ // returns the stdout verbatim (the CLI emits JSON on stdout).
51
+ function crewCmd(command, args) {
52
+ var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
53
+ return "node " + CREW_API + " --crew-home " + crewHome + " " + command + " --json '" + json + "'";
54
+ }
35
55
  const ORCH_PATH = crewHome + "/.orchestration";
36
56
 
37
57
  // Pin lifecycle scripts to this run — snapshot them so mid-run upgrades can't break us
38
58
  const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
39
59
  const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
40
- const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1); stale pins reaped by orphan-sweep
60
+ const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1)
41
61
  const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
42
62
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
43
63
  const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
44
64
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
45
- const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
46
- const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
47
- // The four basenames the pin step must materialize — asserted mechanically
65
+ // The three basenames the pin step must materialize — asserted mechanically
48
66
  // by workflow code from the verbatim listing, never from agent prose.
49
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
67
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM].map(function (p) { return p.split("/").pop(); });
50
68
 
51
69
  // Project config — passed by dispatcher, falls back to dashboard defaults
52
70
  const projectConfig = inputs.project_config || {};
53
71
  // Project this run was dispatched for — the mid-run project-change guard
54
72
  // compares the task's live project against this on every phase boundary.
55
73
  const LAUNCH_PROJECT_ID = inputs.project_id || "";
56
- const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-dashboard";
74
+ // Fail closed on a missing repo_path — never silently fall back to
75
+ // another checkout (the canary's wrong-repo Build, 2026-09-11: prepare
76
+ // failed in the npm package and the builder freelanced into a dashboard
77
+ // clone). The dispatcher skips unconfigured projects; this is the
78
+ // backstop for direct launches.
79
+ const REPO_PATH = projectConfig.repo_path || "";
80
+ if (!REPO_PATH) {
81
+ throw new Error("Project '" + (inputs.project_id || "unknown") + "' has no repo_path configured — set it via updateproject before dispatching tasks.");
82
+ }
57
83
 
58
84
  // Worktree layout: the task's worktree lives at
59
85
  // <repo>/.worktrees/<task_id> on branch task/<task_id>. The lifecycle
@@ -61,7 +87,7 @@ const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-da
61
87
  // registry at <repo>/.worktrees/.registry/<task_id> is the source of
62
88
  // truth for task→branch/path.
63
89
  // Env prefix baked into every lifecycle invocation the agents run.
64
- const LIFECYCLE_ENV = "CREW_REPO=" + REPO_PATH + " ";
90
+ const LIFECYCLE_ENV = "CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " ";
65
91
  // The task branch is always task/<task_id>.
66
92
  const TASK_BRANCH = "task/" + taskId;
67
93
  // Where the agent works.
@@ -70,7 +96,7 @@ const WORKTREE_HINT = REPO_PATH + "/.worktrees/" + taskId;
70
96
  const WORKTREE_PRESERVED_HINT = ".worktrees/" + taskId;
71
97
 
72
98
  const PUBLISH_TYPE = projectConfig.deploy_type || "";
73
- const PUBLISH_SLUG = projectConfig.deploy_slug || DASHBOARD_SLUG;
99
+ const PUBLISH_SLUG = projectConfig.deploy_slug || "";
74
100
  const PROJECT_DESC = projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)";
75
101
  const RELEASE_SCRIPT = crewHome + "/crew-release.sh";
76
102
 
@@ -192,7 +218,7 @@ function attemptKey(base, reworkCount) {
192
218
  function pinLifecycle(key) {
193
219
  return agent(
194
220
  "Snapshot lifecycle scripts for version pinning.\n" +
195
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
221
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
196
222
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
197
223
  { key: key, label: "Pinning lifecycle scripts",
198
224
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -204,19 +230,65 @@ function pinLifecycle(key) {
204
230
  function parsePinListing(result) {
205
231
  return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
206
232
  }
233
+ // TOOL_CHECK_PREAMBLE - every work agent runs this first. The artifact tool
234
+ // namespace is deferred for workflow children: present but invisible until
235
+ // the child loads it via tool_search.load_tool_namespace (bug 3472bf36 root
236
+ // cause, verified 2026-09-11 by direct probe: 5/5 children self-loaded it;
237
+ // the "non-deterministic platform flake" was children never being told to
238
+ // load it). The two signal lines are the ONLY machine-read tool-availability
239
+ // evidence - the workflow never guesses from English prose.
240
+ // Byte-identical across standard/bugfix/chore/docs - pinned by
241
+ // tests/artifact-tools.test.js.
242
+ var TOOL_CHECK_PREAMBLE =
243
+ "TOOL CHECK (do this first, before any other work):\n" +
244
+ "1. Call tool_search.load_tool_namespace with paths [\"artifact\"].\n" +
245
+ "2. Write exactly one line: artifact_tools: ok - or artifact_tools: missing if the call failed or the tool does not exist.\n" +
246
+ "3. Run the shell command: echo tool-probe-ok - then write exactly one line: shell_transport: ok - or shell_transport: unavailable if you cannot run shell commands.\n" +
247
+ "Then do the assignment below.\n\n";
248
+ // ARTIFACT_LOAD_PREAMBLE - the one-line load instruction that precedes every
249
+ // special-purpose prompt calling artifact tools (rebuild trigger, provenance
250
+ // stamp, publish verification). The artifact tool namespace is deferred for
251
+ // workflow children: present but invisible until the child loads it via
252
+ // tool_search.load_tool_namespace (bug 3472bf36 root cause). Byte-identical
253
+ // across standard/bugfix/chore - pinned by tests/artifact-tools.test.js.
254
+ var ARTIFACT_LOAD_PREAMBLE =
255
+ "First call tool_search.load_tool_namespace with paths [\"artifact\"] - the artifact tool namespace is deferred and invisible until loaded.\n";
207
256
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
208
- // reason: "discarded" (the runtime threw the output away — it could not be
209
- // machine-read) or "empty" (agent() returned without throwing but produced
210
- // nothing usable). The trailer tells the retry what to expect, not just to
211
- // try again.
257
+ // reason: "discarded" (the runtime threw the output away - it could not be
258
+ // machine-read), "empty" (agent() returned without throwing but produced
259
+ // nothing usable), "no-tools" (the worker's TOOL CHECK reported
260
+ // artifact_tools: missing), or "no-transport" (the worker's TOOL CHECK
261
+ // reported shell_transport: unavailable).
262
+ // The trailer tells the retry what to expect, not just to try again.
212
263
  var why = reason === "empty"
213
264
  ? "your previous attempt returned no usable output"
265
+ : reason === "no-tools"
266
+ ? "your previous attempt's TOOL CHECK reported artifact_tools: missing (this is a fresh launch, so run the TOOL CHECK's load step again before the work)"
267
+ : reason === "no-transport"
268
+ ? "your previous attempt's TOOL CHECK reported shell_transport: unavailable (this is a fresh launch, so run the TOOL CHECK's shell probe again before the work)"
214
269
  : "your previous attempt's output could not be machine-read as JSON and was discarded";
215
270
  return "\n\nTRANSPORT RETRY (attempt " + attempt + " of 2): " + why + ". " +
216
- "First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) — " +
271
+ "First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) - " +
217
272
  "if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
218
273
  "Then return your report as JSON in exactly the shape specified above.";
219
274
  }
275
+ // parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
276
+ // exact signal lines: artifact_tools: ok|missing and
277
+ // shell_transport: ok|unavailable. The workflow reads ONLY these lines.
278
+ // It never guesses tool availability from English prose: the old regex
279
+ // misclassified ordinary inability-prose (e.g. a Map agent describing what
280
+ // it could not inspect) and burned all three attempts on useless retries.
281
+ // Byte-identical across standard/bugfix/chore/docs - pinned by
282
+ // tests/artifact-tools.test.js.
283
+ function parseToolSignals(text) {
284
+ var t = String(text || "");
285
+ var out = { artifactTools: "unknown", shellTransport: "unknown" };
286
+ var am = t.match(/^artifact_tools:\s*(ok|missing)\s*$/m);
287
+ if (am) out.artifactTools = am[1];
288
+ var sm = t.match(/^shell_transport:\s*(ok|unavailable)\s*$/m);
289
+ if (sm) out.shellTransport = sm[1];
290
+ return out;
291
+ }
220
292
  function describeWorkAgentFailure(stepName, identity, attempts) {
221
293
  // Honest classification of a work-agent call that yielded no usable
222
294
  // report, with the per-attempt evidence preserved in the session notes.
@@ -270,6 +342,259 @@ function extractReleaseDecision(workerText) {
270
342
  if (rel === "yes" && !b) return null;
271
343
  return { release: rel, version_bump: b ? b[1].toLowerCase() : null };
272
344
  }
345
+ // Merge-record helpers (merge-lease fix, 2026-09-12): the integrate step
346
+ // records the exact merged commit in $CREW_HOME/.merge-records/<task_id>,
347
+ // so a re-dispatched run can verify a landed merge and publish it even
348
+ // after the branch was reclaimed under an expired lease. All pure — no I/O,
349
+ // no clock. Byte-identical in standard.js, bugfix.js, chore.js.
350
+ function parseMergeRecord(text) {
351
+ var t = (text || "").trim();
352
+ if (!t || t === "NO_RECORD") return null;
353
+ // Append-only record; last occurrence of each key wins.
354
+ function lastVal(prefix) {
355
+ var v = null, found = false;
356
+ var lines = t.split("\n");
357
+ for (var i = 0; i < lines.length; i++) {
358
+ var line = lines[i].trim();
359
+ if (line.indexOf(prefix) === 0) { v = line.slice(prefix.length).trim(); found = true; }
360
+ }
361
+ return found ? v : null;
362
+ }
363
+ var commit = lastVal("merge_commit=");
364
+ var release = lastVal("release=");
365
+ var bump = lastVal("version_bump=");
366
+ var malformed = false;
367
+ if (commit === null || !/^[0-9a-f]{40}$/.test(commit)) malformed = true;
368
+ if (release !== null && !/^(yes|no|unknown)$/.test(release)) malformed = true;
369
+ if (bump !== null && !/^(patch|minor|major|none)$/.test(bump)) malformed = true;
370
+ if (malformed) return { malformed: true };
371
+ return { merge_commit: commit, merged_at: lastVal("merged_at="), release: release, version_bump: bump };
372
+ }
373
+ // Publish lock decision table: what the merge record + lock state say about
374
+ // the publish window. Pure — pinned by tests/merge-record.test.js.
375
+ function decidePublishLockPath(o) {
376
+ var rec = o.rec, ancestor = !!o.ancestor, lockHeld = !!o.lockHeld;
377
+ if (rec === null) return lockHeld ? "refresh" : "skip";
378
+ if (rec.malformed) return "park";
379
+ if (!ancestor) return "park";
380
+ return lockHeld ? "refresh" : "reacquire";
381
+ }
382
+ // Integrate retry decision: a landed merge must route to Publish, not
383
+ // re-integrate (the branch may be gone — reclaimed under an expired lease).
384
+ // Pure — pinned by tests/merge-record.test.js.
385
+ function decideIntegrateRetry(o) {
386
+ var rec = o.rec, ancestor = !!o.ancestor;
387
+ if (rec === null) return "proceed";
388
+ if (rec.malformed) return "park";
389
+ return ancestor ? "skip-to-publish" : "proceed";
390
+ }
391
+ // On a dispatcher retry resumed at Integrate, the run-local releaseDecision
392
+ // is null (Build doesn't re-run). Hydrate it from the merge record so the
393
+ // npm Publish gate doesn't park a publishable merge. Pure — pinned by
394
+ // tests/merge-record.test.js.
395
+ function hydrateReleaseDecision(rec) {
396
+ if (rec && (rec.release === "yes" || rec.release === "no")) {
397
+ return { release: rec.release, version_bump: rec.version_bump === "none" ? null : rec.version_bump };
398
+ }
399
+ return null;
400
+ }
401
+
402
+ // Publish diff transport: parse a unified diff into per-file {path, added,
403
+ // removed} line lists. Pure function — no I/O, no clock. Used by Publish to
404
+ // verify the artifact builder applied the carried change (canary run 4,
405
+ // 2026-09-11: the builder's source tree was stale and disconnected from the
406
+ // crew's repo; "rebuild from current source" rebuilt stale code and the
407
+ // workflow stamped the new commit hash on it — provenance fiction).
408
+ function parseUnifiedDiff(diffText) {
409
+ var files = [];
410
+ var current = null;
411
+ var lines = (diffText || "").split("\n");
412
+ for (var i = 0; i < lines.length; i++) {
413
+ var line = lines[i];
414
+ var m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
415
+ if (m) {
416
+ current = { path: m[2], added: [], removed: [] };
417
+ files.push(current);
418
+ continue;
419
+ }
420
+ if (!current) continue;
421
+ if (/^--- /.test(line) || /^\+\+\+ /.test(line)) continue;
422
+ if (/^@@ /.test(line)) continue;
423
+ if (line.charAt(0) === "+") {
424
+ current.added.push(line.slice(1));
425
+ } else if (line.charAt(0) === "-") {
426
+ current.removed.push(line.slice(1));
427
+ }
428
+ }
429
+ return files;
430
+ }
431
+
432
+ // Compare the artifact builder's reported applied-changes against the diff's
433
+ // expected changes. Every added/removed line must match exactly per path,
434
+ // and the file counts must match — the builder applies exactly the carried
435
+ // change, nothing more, nothing less. Pure function — no I/O, no clock.
436
+ // OBSERVATION INPUT ONLY (2026-09-12, task 23ca8f3f): the applied report
437
+ // has a demonstrated false-negative mode (applied:[] for a diff the builder
438
+ // had actually applied), and it is derived from the carried diff, so a match
439
+ // certifies nothing either. A mismatch is logged as observation; it never
440
+ // parks and never blocks the stamp. The verification is the independent
441
+ // read-back (docs/publish-verification.md).
442
+ function verifyAppliedChanges(expected, applied) {
443
+ if (!Array.isArray(applied)) {
444
+ return { ok: false, reason: "builder returned no applied-changes list" };
445
+ }
446
+ function sorted(a) { return (a || []).slice().sort(); }
447
+ function eq(a, b) {
448
+ a = sorted(a); b = sorted(b);
449
+ if (a.length !== b.length) return false;
450
+ for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
451
+ return true;
452
+ }
453
+ for (var i = 0; i < expected.length; i++) {
454
+ var exp = expected[i];
455
+ var got = null;
456
+ for (var j = 0; j < applied.length; j++) {
457
+ if (applied[j] && applied[j].path === exp.path) { got = applied[j]; break; }
458
+ }
459
+ if (!got) {
460
+ return { ok: false, reason: "builder did not report changing '" + exp.path + "'" };
461
+ }
462
+ if (!eq(exp.added, got.added)) {
463
+ return { ok: false, reason: "added lines for '" + exp.path + "' do not match the carried diff" };
464
+ }
465
+ if (!eq(exp.removed, got.removed)) {
466
+ return { ok: false, reason: "removed lines for '" + exp.path + "' do not match the carried diff" };
467
+ }
468
+ }
469
+ if (applied.length !== expected.length) {
470
+ return { ok: false, reason: "builder reported changing " + applied.length + " file(s), diff carries " + expected.length };
471
+ }
472
+ return { ok: true };
473
+ }
474
+
475
+ // Publish read-back request builder: the verbatim_request the workflow hands
476
+ // to artifact_inspect (via a child) after the artifact build lands. Pure
477
+ // function — no I/O, no clock. The request carries the merged diff as the
478
+ // expected change and asks for an independent read of the artifact's actual
479
+ // source: for each file, the exact current text of the changed regions plus
480
+ // a per-line present/absent finding. The parent (docs/publish-verification.md)
481
+ // compares these findings against the diff mechanically and stamps provenance
482
+ // only on a match. This breaks the circularity that hollowed canary run 8
483
+ // (2026-09-11): verifyAppliedChanges compares the builder's applied-report
484
+ // against the diff the report was derived from — a fabricated report passes
485
+ // by construction. Independent read-back cannot be fabricated from the diff;
486
+ // it must match the artifact's real content.
487
+ function buildPublishReadbackRequest(taskId, commit, diff, buildAgentId) {
488
+ // Build-ID correlation (2026-09-12): buildAgentId is the build.agent_id the
489
+ // workflow observed for the publish attempt (the artifact system's durable
490
+ // build identifier). The read-back request carries it so the parent can
491
+ // prove the read-back inspected the live build of THIS attempt — not a
492
+ // different build's output. Null/empty means the edit was accepted but
493
+ // never correlated to a builder run. Pure function of inputs — no I/O,
494
+ // no clock.
495
+ var buildIdLine = (typeof buildAgentId === "string" && buildAgentId.length > 0)
496
+ ? "Expected builder build agent_id: " + buildAgentId + " (the artifact system's in-flight correlation ID for this publish attempt — not a durable post-completion identifier).\n"
497
+ : "No build agent_id was observed for this publish attempt (the edit was accepted but never correlated to a builder run) — say so explicitly in your report.\n";
498
+ return (
499
+ "Publish content read-back for task " + taskId + ", merge commit " + commit + ".\n" +
500
+ "The unified diff below was supposed to be applied to this artifact's source tree and deployed. Do NOT modify anything.\n" +
501
+ "Do NOT rely on the builder's applied-changes report — it is derived from this same diff, so it cannot confirm the content. Read the artifact's CURRENT source directly.\n" +
502
+ "\n" +
503
+ buildIdLine +
504
+ "Report the live build's agent_id as seen in artifact_status (or state explicitly that no build/agent_id is visible). If an expected agent_id is given above and the live one differs, say so exactly — the read-back may be inspecting a different build's output.\n" +
505
+ "\n" +
506
+ "UNIFIED DIFF (expected change):\n" +
507
+ "```diff\n" + diff + "\n```\n" +
508
+ "\n" +
509
+ "For each file in the diff:\n" +
510
+ "1. Read the file's CURRENT content in the artifact source tree.\n" +
511
+ "2. Quote the exact current text of the regions around the changed lines.\n" +
512
+ "3. For every added (+) line in the diff, state whether that exact line is PRESENT in the current source.\n" +
513
+ "4. For every removed (-) line in the diff, state whether that exact line is ABSENT from the current source.\n" +
514
+ "5. Report build/deploy health and the console error count.\n" +
515
+ "\n" +
516
+ "Return the per-file present/absent findings with the quoted observed lines. Do not modify anything.\n" +
517
+ "This read-back feeds the parent content-verification protocol (docs/publish-verification.md): the parent stamps provenance only when every added line is present and every removed line is absent."
518
+ );
519
+ }
520
+
521
+ // Pre-publish base observation (diagnostic, 2026-09-12): instruction fragment
522
+ // for the builder's edit request, asking it to report the sha256 of each
523
+ // touched file's CURRENT content BEFORE applying the diff. Pure function —
524
+ // no I/O, no clock.
525
+ //
526
+ // Why: the builder applies the carried diff to its own source tree, whose
527
+ // base state is unrecorded. The post-hoc read-back only checks the changed
528
+ // regions AFTER the edit; it cannot tell us what base the diff landed on.
529
+ // If the tree was dirty or drifted before the edit, the read-back still
530
+ // passes (the diff's lines are present) while the artifact silently carries
531
+ // uncommitted content — the production validateRepoPath incident
532
+ // (2026-09-12), where the live artifact contained code absent from every git
533
+ // ref. These pre-hashes, compared against the workflow-computed expected
534
+ // base hashes (merge parent), reveal what the publish actually read.
535
+ // Observation only — the workflow logs mismatches but never parks on them.
536
+ function buildPreHashInstruction(files) {
537
+ var paths = files.map(function(f) { return f.path; }).join(", ");
538
+ return (
539
+ "- BEFORE applying anything, compute the sha256 hash of each file below as it CURRENTLY exists in your source tree (before your changes). Use the exact bytes of the current file content.\n" +
540
+ "- Report these hashes in the \"pre_hashes\" field of your return JSON, as { \"<path>\": \"<sha256 hex>\" }.\n" +
541
+ "- If a file does not exist in your tree, report its hash as the string \"MISSING\".\n" +
542
+ "- Do this BEFORE applying the diff — the hashes must reflect the pre-edit state.\n" +
543
+ " Files: " + paths + "\n"
544
+ );
545
+ }
546
+
547
+ // Durable publish-attempt ledger (2026-09-12): every artifact publish
548
+ // attempt is recorded append-only at $CREW_HOME/.publish-ledger/<slug>.jsonl
549
+ // on persistent disk (NOT /tmp). The ledger is the correlation record for
550
+ // publish attempts whose outcome is UNKNOWN. When the rebuild trigger's
551
+ // child returns prose instead of JSON (structured-output failure), the edit
552
+ // may already have been accepted as pending_init — and artifact_status
553
+ // cannot see pending_init (diagnostic canary 2026-09-12: an edit accepted
554
+ // as pending_init was immediately followed by an all-false status check,
555
+ // and the old retry issued a DUPLICATE edit). "No build visible" is NOT
556
+ // evidence the edit did not go through, so the workflow never blind-retries
557
+ // on an unknown outcome: it records the attempt and parks fail-closed. A
558
+ // human or a later run correlates the accepted edit via the ledger (commit
559
+ // hash + attempt key + the artifact build's agent_id when one was observed)
560
+ // instead of guessing from a blind status poll.
561
+ // Best-effort observability: a failed write is logged loudly but never
562
+ // throws — the caller's park/proceed decision never depends on the ledger.
563
+ // Byte-identical across standard/bugfix/chore — pinned by
564
+ // tests/publish-ledger.test.js.
565
+ async function recordPublishLedger(entry, rework) {
566
+ try {
567
+ var ledgerDir = crewHome + "/.publish-ledger";
568
+ var line = JSON.stringify({
569
+ ts: "@LEDGER_TS@",
570
+ task_id: taskId,
571
+ workflow: RESOLVED_WORKFLOW || "unknown",
572
+ slug: PUBLISH_SLUG,
573
+ commit: entry.commit || null,
574
+ attempt: entry.attempt || null,
575
+ agent_id: entry.agent_id || null,
576
+ applied_report: entry.applied_report || null,
577
+ outcome: entry.outcome,
578
+ detail: entry.detail || ""
579
+ });
580
+ var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
581
+ var res = await agent(
582
+ "Append one line to the publish-attempt ledger (best-effort observability, not a gate).\n" +
583
+ "Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
584
+ " | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
585
+ "Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
586
+ { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
587
+ label: "Recording publish attempt in ledger",
588
+ schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
589
+ );
590
+ var ok = !!(res && res.result && res.result.indexOf("LEDGER_OK") !== -1);
591
+ log("Publish ledger: outcome '" + entry.outcome + "' for task " + taskId +
592
+ (ok ? " recorded." : " NOT confirmed (" + ((res && res.result) || "no output") + ")"));
593
+ } catch (e) {
594
+ log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
595
+ }
596
+ }
597
+
273
598
  // Marker line preservation: machine-readable lines (repo_diff:, release:,
274
599
  // version_bump:, VERDICT:, TARGET_VERSION=, published:) are extracted from
275
600
  // the full worker report and appended after the summary slice, so a long
@@ -279,13 +604,37 @@ function extractMarkerLines(workerText) {
279
604
  var markers = [];
280
605
  for (var i = 0; i < lines.length; i++) {
281
606
  var line = lines[i].trim();
282
- if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:)/i.test(line)) {
607
+ if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:|worktree:)/i.test(line)) {
283
608
  markers.push(line);
284
609
  }
285
610
  }
286
611
  return markers.join("\n");
287
612
  }
288
613
 
614
+ // Worktree confinement: the Build agent must declare the exact worktree
615
+ // path it built in on a `worktree:` marker line. The workflow compares it
616
+ // against WORKTREE_HINT mechanically (exact string match) — never by
617
+ // reading agent prose. This closes the hole where a builder whose prepare
618
+ // failed freelanced into a different checkout (canary, 2026-09-11): the
619
+ // honest-but-confused case fails here, and a fabricated path is caught one
620
+ // phase later when Review's inspect finds no commits in the configured repo.
621
+ function extractWorktree(workerText) {
622
+ var lines = (workerText || "").split("\n");
623
+ var found = null;
624
+ for (var i = 0; i < lines.length; i++) {
625
+ var line = lines[i].trim();
626
+ var m = /^worktree:\s*(\S.*)$/i.exec(line);
627
+ if (m) found = m[1].trim();
628
+ }
629
+ if (!found) return { ok: false };
630
+ // Normalize trailing slashes: ".../<task_id>/" and ".../<task_id>"
631
+ // name the same directory. Compare locations, not spellings — a
632
+ // builder that emits the trailing slash still worked in the right
633
+ // place. (Canary 2026-09-11: an exact comparison rejected a correct
634
+ // declaration over one trailing slash.)
635
+ return { ok: true, path: found.replace(/\/+$/, "") };
636
+ }
637
+
289
638
  // Visual verdict shared functions: byte-identical across standard.js,
290
639
  // bugfix.js, and chore.js (pinned by tests/visual-verdict.test.js — same
291
640
  // contract as buildTransportRetryTrailer).
@@ -334,8 +683,8 @@ async function resolveExperiential() {
334
683
  var expCheck = null;
335
684
  try {
336
685
  expCheck = await agent(
337
- "Find this task's Triage step session notes from the dashboard.\n" +
338
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
686
+ "Find this task's Triage step session notes.\n" +
687
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
339
688
  "Find the session with task_id \"" + taskId + "\" and step \"Triage\" (status completed) in the returned sessions array and read its notes field.\n" +
340
689
  "Return JSON { \"experiential_line\": \"<the exact text of the experiential: marker line from the notes, or empty string if absent>\" } and nothing else.",
341
690
  {
@@ -357,10 +706,12 @@ async function resolveExperiential() {
357
706
  }
358
707
  // Baseline evidence status: reads the task's note events for the exact
359
708
  // protocol prefixes (explicit state, never English matching). Returns
360
- // { baseline_found, baseline_kind, baseline_refs, requested_count }.
709
+ // { baseline_found, baseline_kind, baseline_refs, requested_count, evidence_count }.
361
710
  // found = any note starting exactly "baseline: captured" or "baseline:
362
711
  // none" (kind/refs come from the LATEST such message); requested_count =
363
- // the number of notes starting exactly "baseline: requested". Each call
712
+ // the number of notes starting exactly "baseline: requested";
713
+ // evidence_count = the number of notes starting exactly "baseline: captured" or
714
+ // "baseline: none" (the per-attempt evidence chain; state-derived, no wall clock). Each call
364
715
  // uses a fresh key: the evidence changes between calls (the parent logs
365
716
  // the capture while this run is parked), so a cached replay would lie.
366
717
  let baselineStatusCallCount = 0;
@@ -368,10 +719,10 @@ async function baselineStatus() {
368
719
  baselineStatusCallCount++;
369
720
  try {
370
721
  var ev = await agent(
371
- "Read this task's note events from the dashboard.\n" +
372
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
722
+ "Read this task's note events.\n" +
723
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId }) + "\n" +
373
724
  "Consider only events with type \"note\". For each message, check whether it starts exactly with \"baseline: captured\", \"baseline: none\", or \"baseline: requested\" (exact prefix, case-sensitive).\n" +
374
- "Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\"> } and nothing else.",
725
+ "Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\">, \"evidence_count\": <count of messages starting exactly with \"baseline: captured\" or \"baseline: none\"> } and nothing else.",
375
726
  {
376
727
  key: "baseline-status-" + taskId + "-" + baselineStatusCallCount,
377
728
  label: "Reading baseline evidence status",
@@ -381,9 +732,10 @@ async function baselineStatus() {
381
732
  baseline_found: { type: "boolean" },
382
733
  baseline_kind: { type: "string" },
383
734
  baseline_refs: { type: "string" },
384
- requested_count: { type: "number" }
735
+ requested_count: { type: "number" },
736
+ evidence_count: { type: "number" }
385
737
  },
386
- required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count"]
738
+ required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count", "evidence_count"]
387
739
  }
388
740
  }
389
741
  );
@@ -391,11 +743,12 @@ async function baselineStatus() {
391
743
  baseline_found: !!(ev && ev.baseline_found),
392
744
  baseline_kind: (ev && ev.baseline_kind) || "",
393
745
  baseline_refs: (ev && ev.baseline_refs) || "",
394
- requested_count: (ev && ev.requested_count) || 0
746
+ requested_count: (ev && ev.requested_count) || 0,
747
+ evidence_count: (ev && ev.evidence_count) || 0
395
748
  };
396
749
  } catch (e) {
397
750
  log("baselineStatus: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
398
- return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0 };
751
+ return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0, evidence_count: 0 };
399
752
  }
400
753
  }
401
754
  // Visual verdict status: the parent records the visual verdict as a note
@@ -409,10 +762,10 @@ async function visualVerdictStatus() {
409
762
  visualVerdictCallCount++;
410
763
  try {
411
764
  var vv = await agent(
412
- "Read this task's QA session and note events from the dashboard.\n" +
413
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
765
+ "Read this task's QA session and note events.\n" +
766
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
414
767
  "Find the LATEST session with task_id \"" + taskId + "\" and step \"QA\" in the returned sessions array and note its started_at timestamp (call it QA_START; use empty string if there is no QA session).\n" +
415
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
768
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId }) + "\n" +
416
769
  "Consider only events with type \"note\" whose timestamp is newer than QA_START. Among them, find messages starting exactly with \"visual_verdict: PASS\" or \"visual_verdict: FAIL\" (exact prefix, case-sensitive); use the latest such message.\n" +
417
770
  "Return JSON { \"found\": <true if such a message exists>, \"verdict\": \"<\"PASS\" or \"FAIL\" from that message, or empty string>\", \"detail\": \"<the text after the prefix in that message, or empty string>\" } and nothing else.",
418
771
  {
@@ -497,21 +850,44 @@ function releaseDecisionText() {
497
850
  // envelope the launcher sees. If the park call itself fails, the run
498
851
  // reports "failed" (retryable) so the next tick re-attempts the park —
499
852
  // a lost park is never reported as parked.
853
+ // Terminal cleanup: the run's last act at every park/fail boundary. A run
854
+ // that parks or fails must not leak its worktree, branch, or merge lock.
855
+ // The lifecycle's terminal-cleanup releases the lock unconditionally and
856
+ // reclaims the worktree+branch ONLY when the task branch is fully merged
857
+ // into main (then it is redundant); unmerged work is preserved for the
858
+ // human by design. Fire-and-forget with one bounded retry — the merge-lock
859
+ // lease expiry and the orphan sweep are the backstop for a dead transport.
860
+ async function terminalCleanup() {
861
+ for (var attempt = 1; attempt <= 2; attempt++) {
862
+ try {
863
+ await agent(
864
+ "Run in shell and return the stdout verbatim:\n" + LIFECYCLE_ENV + " terminal-cleanup " + taskId,
865
+ { key: "terminal-cleanup" + (attempt > 1 ? "-retry" : ""),
866
+ label: "Terminal cleanup (merged-branch reclamation)" + (attempt > 1 ? " (retry)" : "") }
867
+ );
868
+ return;
869
+ } catch (cleanupErr) {
870
+ log("Terminal cleanup attempt " + attempt + " failed for task " + taskId + ": " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
871
+ }
872
+ }
873
+ log("Terminal cleanup exhausted for task " + taskId + " — merge-lock lease expiry and orphan sweep are the backstop");
874
+ }
500
875
  async function parkTask(reason) {
501
876
  log("Parking task " + taskId + " for human attention: " + reason);
502
877
  var parkMessage = ("Parked: " + reason).slice(0, 1000);
503
878
  try {
504
879
  await agent(
505
880
  "Park this task for human attention.\n" +
506
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"parktask\", args: " +
507
- JSON.stringify({ task_id: taskId, message: parkMessage }) + ".\n" +
881
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("park-task", { task_id: taskId, message: parkMessage }) + "\n" +
508
882
  "The parked state is the human-attention signal — the dispatcher skips parked tasks.",
509
- { key: "park-task", label: "Parking task for human attention", schema: { type: "object" } }
883
+ { key: "park-task", label: "Parking task for human attention" }
510
884
  );
511
885
  } catch (parkErr) {
512
886
  log("PARK FAILED for task " + taskId + ": " + (parkErr && parkErr.message ? parkErr.message : parkErr) + " — park did not land, reporting failed so the next tick retries");
887
+ await terminalCleanup();
513
888
  return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
514
889
  }
890
+ await terminalCleanup();
515
891
  return { status: "parked", task_id: taskId, reason: reason };
516
892
  }
517
893
  let i = startStepIndex;
@@ -549,8 +925,8 @@ while (i < STEPS.length) {
549
925
  // re-launches the step with the new project's config.
550
926
  if (LAUNCH_PROJECT_ID) {
551
927
  const projectCheck = await agent(
552
- "Read this task's current project from the dashboard.\n" +
553
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }.\n" +
928
+ "Read this task's current project.\n" +
929
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
554
930
  "Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
555
931
  "Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
556
932
  {
@@ -565,14 +941,15 @@ while (i < STEPS.length) {
565
941
  log(abortMessage);
566
942
  await agent(
567
943
  "Abort the stale run and remove its worktree from the old project's repo.\n" +
568
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
569
- "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
570
- "\"notes\": " + JSON.stringify(abortMessage + " Rebuild from the Map session notes in the task's event history.") + " }.\n" +
571
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
572
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": " + JSON.stringify(abortMessage) + " }.\n" +
944
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
945
+ task_id: taskId,
946
+ session: { task_id: taskId, identity: step.identity, step: REWORK_STEP, status: "failed",
947
+ notes: abortMessage + " Rebuild from the Map session notes in the task's event history." },
948
+ event: { task_id: taskId, type: "failed", identity: step.identity, message: abortMessage }
949
+ }) + "\n" +
573
950
  "Then run: "+ LIFECYCLE_ENV + " cleanup " + taskId + "\n" +
574
951
  "The cleanup output should contain CLEANUP.",
575
- { key: "abort-project-change", label: "Aborting stale run (project changed)", schema: { type: "object" } }
952
+ { key: "abort-project-change", label: "Aborting stale run (project changed)" }
576
953
  );
577
954
  return { status: "failed", task_id: taskId, reason: abortMessage };
578
955
  }
@@ -613,7 +990,7 @@ while (i < STEPS.length) {
613
990
  "Release the merge lock and clean up without publishing.\n" +
614
991
  "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
615
992
  "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
616
- { key: attemptKey("publish-skip-cleanup", totalReworkCount), label: "Skipping Publish (no target)", schema: { type: "object" } }
993
+ { key: attemptKey("publish-skip-cleanup", totalReworkCount), label: "Skipping Publish (no target)" }
617
994
  );
618
995
  i++;
619
996
  continue;
@@ -638,7 +1015,7 @@ while (i < STEPS.length) {
638
1015
  "Release the merge lock and clean up without publishing.\n" +
639
1016
  "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
640
1017
  "If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
641
- { key: attemptKey("publish-skip-release-no", totalReworkCount), label: "Skipping Publish (release: no)", schema: { type: "object" } }
1018
+ { key: attemptKey("publish-skip-release-no", totalReworkCount), label: "Skipping Publish (release: no)" }
642
1019
  );
643
1020
  i++;
644
1021
  continue;
@@ -676,11 +1053,12 @@ while (i < STEPS.length) {
676
1053
  // claimed:false and this run stands down as a duplicate.
677
1054
  let activeSessionId;
678
1055
  if (isFirstClaim) {
1056
+ var firstClaimUpdateArgs = { id: taskId, state: "in_progress" };
1057
+ if (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) firstClaimUpdateArgs.workflow = RESOLVED_WORKFLOW;
679
1058
  const claimResult = await agent(
680
1059
  "Claim this task for the " + step.name + " step.\n" +
681
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"in_progress\"" + CLAIM_WORKFLOW_PERSIST + " }.\n" +
682
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
683
- "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started\" }.\n" +
1060
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
1061
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
684
1062
  "Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
685
1063
  "Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
686
1064
  {
@@ -701,8 +1079,8 @@ while (i < STEPS.length) {
701
1079
  } else {
702
1080
  const claimResult = await agent(
703
1081
  "Claim a session for this task step.\n" +
704
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
705
- "{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started" + (totalReworkCount > 0 ? " (rework #" + totalReworkCount + ")" : "") + "\" }.\n" +
1082
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name,
1083
+ notes: step.name + " step started" + (totalReworkCount > 0 ? " (rework #" + totalReworkCount + ")" : "") }) + "\n" +
706
1084
  "Return the session_id from the response.",
707
1085
  {
708
1086
  key: "claim-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
@@ -739,25 +1117,41 @@ while (i < STEPS.length) {
739
1117
  log("Capture skipped for task " + taskId + " — " + (capExp !== "yes" ? "not experiential" : "publish target is not artifact"));
740
1118
  await agent(
741
1119
  "Update the session and log the event.\n" +
742
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
743
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"Capture skipped — not an experiential artifact task\" }.\n" +
744
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
745
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
746
- { key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
1120
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
1121
+ task_id: taskId,
1122
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "Capture skipped — not an experiential artifact task" },
1123
+ event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
1124
+ }),
1125
+ { key: "record-Capture" + bounceSuffix, label: "Recording Capture result" }
747
1126
  );
748
1127
  i++;
749
1128
  continue;
750
1129
  }
751
1130
  var capStatus = await baselineStatus();
752
- if (capStatus.baseline_found) {
753
- log("Capture: baseline evidence already recorded for task " + taskId + " (" + capStatus.baseline_kind + ")");
1131
+ // Stale-decision guard: a "baseline: none (visual protocol unavailable)"
1132
+ // note is only durable while the protocol is unavailable. When
1133
+ // VISUAL_PROTOCOL_AVAILABLE is true, that old decision no longer
1134
+ // stands — fall through to the request path for a fresh capture
1135
+ // attempt. Exact-string trim comparison against the workflow's own
1136
+ // written message (explicit state, never English matching).
1137
+ var baselineLatestMessage = ("baseline: " + capStatus.baseline_kind + capStatus.baseline_refs).trim();
1138
+ var baselineStale = VISUAL_PROTOCOL_AVAILABLE && baselineLatestMessage === "baseline: none (visual protocol unavailable)";
1139
+ if (capStatus.baseline_found && !baselineStale) {
1140
+ var evidenceN = capStatus.evidence_count + 1;
1141
+ var carryNote = "baseline: " + capStatus.baseline_kind + " (#" + evidenceN + " carries forward prior:" + capStatus.baseline_refs + ")";
1142
+ log("Capture: baseline evidence already recorded for task " + taskId + " (" + capStatus.baseline_kind + ") — logging per-attempt carry-forward note (evidence #" + evidenceN + ")");
754
1143
  await agent(
755
- "Update the session and log the event.\n" +
756
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
757
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": " + JSON.stringify("Baseline evidence already recorded: " + capStatus.baseline_kind + " " + capStatus.baseline_refs) + " }.\n" +
758
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
759
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
760
- { key: "record-Capture" + bounceSuffix, label: "Recording Capture result", schema: { type: "object" } }
1144
+ "Log the per-attempt baseline carry-forward note, then record the phase.\n" +
1145
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", {
1146
+ task_id: taskId, type: "note", identity: step.identity, message: carryNote
1147
+ }) + "\n" +
1148
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
1149
+ task_id: taskId,
1150
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed",
1151
+ notes: "Baseline evidence already recorded: " + capStatus.baseline_kind + " " + capStatus.baseline_refs + " (evidence #" + evidenceN + ")" },
1152
+ event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
1153
+ }),
1154
+ { key: "record-Capture" + bounceSuffix, label: "Recording Capture result" }
761
1155
  );
762
1156
  i++;
763
1157
  continue;
@@ -767,13 +1161,13 @@ while (i < STEPS.length) {
767
1161
  log("Capture: baseline capture unavailable after 2 requests for task " + taskId + " — recording baseline:none");
768
1162
  await agent(
769
1163
  "Record that no baseline was capturable.\n" +
770
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
771
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: none (capture unavailable after 2 attempts)\" }.\n" +
772
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
773
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"completed\", \"notes\": \"baseline: none — final QA judges on rubric alone\" }.\n" +
774
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
775
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"identity\": \"" + step.identity + "\", \"message\": \"Capture completed by " + step.identity + "\" }.",
776
- { key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none", schema: { type: "object" } }
1164
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: none (capture unavailable after 2 attempts)" }) + "\n" +
1165
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
1166
+ task_id: taskId,
1167
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "baseline: none — final QA judges on rubric alone" },
1168
+ event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
1169
+ }),
1170
+ { key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none" }
777
1171
  );
778
1172
  i++;
779
1173
  continue;
@@ -781,12 +1175,26 @@ while (i < STEPS.length) {
781
1175
  log("Capture: requesting baseline capture (attempt " + attemptN + ") for task " + taskId);
782
1176
  await agent(
783
1177
  "Request the baseline capture and record the request.\n" +
784
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
785
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"note\", \"identity\": \"" + step.identity + "\", \"message\": \"baseline: requested (attempt " + attemptN + ")\" }.\n" +
786
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
787
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": " + JSON.stringify("Baseline capture requested (attempt " + attemptN + ") — parent protocol: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map. The Capture step re-checks baseline evidence when re-run.") + " }.",
788
- { key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request", schema: { type: "object" } }
1178
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: requested (attempt " + attemptN + ")" }) + "\n" +
1179
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("upsert-session", { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
1180
+ notes: "Baseline capture requested (attempt " + attemptN + ") — parent protocol: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map. The Capture step re-checks baseline evidence when re-run." }),
1181
+ { key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request" }
789
1182
  );
1183
+ if (!VISUAL_PROTOCOL_AVAILABLE) {
1184
+ log("Capture: visual protocol not available (VISUAL_PROTOCOL_AVAILABLE=false) — recording baseline:none instead of parking for task " + taskId);
1185
+ await agent(
1186
+ "Record that baseline capture was skipped (protocol unavailable).\n" +
1187
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: none (visual protocol unavailable)" }) + "\n" +
1188
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
1189
+ task_id: taskId,
1190
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "baseline: none — visual protocol unavailable, final QA judges on rubric alone" },
1191
+ event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
1192
+ }),
1193
+ { key: "record-Capture-none-protocol" + bounceSuffix, label: "Recording baseline: none (protocol unavailable)" }
1194
+ );
1195
+ i++;
1196
+ continue;
1197
+ }
790
1198
  return await parkTask("Baseline capture requested (attempt " + attemptN + ") — parent: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map");
791
1199
  }
792
1200
 
@@ -802,11 +1210,12 @@ while (i < STEPS.length) {
802
1210
  log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
803
1211
  await agent(
804
1212
  "Record the Map gate bounce.\n" +
805
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
806
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Map gate bounce: no baseline evidence recorded for this experiential task — no spec was written. Baseline evidence must be captured before the spec.\" }.\n" +
807
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
808
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": \"Map gate bounce — baseline evidence missing, returning to Capture\" }.",
809
- { key: "record-Map-bounce" + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""), label: "Recording Map gate bounce", schema: { type: "object" } }
1213
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
1214
+ task_id: taskId,
1215
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Map gate bounce: no baseline evidence recorded for this experiential task — no spec was written. Baseline evidence must be captured before the spec." },
1216
+ event: { task_id: taskId, type: "failed", identity: step.identity, message: "Map gate bounce — baseline evidence missing, returning to Capture" }
1217
+ }),
1218
+ { key: "record-Map-bounce" + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""), label: "Recording Map gate bounce" }
810
1219
  );
811
1220
  mapGateBounceCount++;
812
1221
  i = CAPTURE_INDEX;
@@ -851,6 +1260,9 @@ while (i < STEPS.length) {
851
1260
  instructions = "STEP 1: Prepare your worktree.\n" +
852
1261
  "Run: "+ LIFECYCLE_ENV + " prepare " + taskId + "\n" +
853
1262
  "If the output says CREATED or REUSED, proceed. If it says ERROR, stop and report the failure clearly.\n\n" +
1263
+ "HEARTBEAT: Start a background heartbeat loop NOW (before STEP 2) to signal you are still alive during this build. Run this once:\n" +
1264
+ "(while node " + CREW_API + " --crew-home " + crewHome + " heartbeat-session --json '{\"id\": \"" + activeSessionId + "\"}' >/dev/null 2>&1; do sleep 900; done) &\n" +
1265
+ "The loop heartbeats every 15 minutes and exits on its own when the session ends. This prevents the dispatcher from mistaking a long build for a dead session and launching a duplicate.\n\n" +
854
1266
  "STEP 2: Edit source files to implement the mapper's spec below.\n" +
855
1267
  (mapperSpec ? "MAPPER'S SPEC (implement exactly this):\n" + mapperSpec + "\n\n" : "") +
856
1268
  "Your working directory: " + WORKTREE_HINT + "/\n" +
@@ -869,7 +1281,7 @@ while (i < STEPS.length) {
869
1281
  "If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your report, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
870
1282
  (rejectionNotes ? "This is REWORK after rejection. Address these specific issues:\n" + rejectionNotes + "\n\n" : "") +
871
1283
  "Report back in plain prose: what you built and the outcome." +
872
- (PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
1284
+ (PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
873
1285
 
874
1286
  } else if (step.name === "Review") {
875
1287
  instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
@@ -905,14 +1317,19 @@ while (i < STEPS.length) {
905
1317
  "R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
906
1318
  "R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
907
1319
  "R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
908
- "ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
1320
+ "ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
909
1321
  "- If it contains ERROR, something else failed. Report the error, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
910
1322
  "\n" +
911
1323
  "STEP 2: Push the merged main to the remote repository.\n" +
1324
+ "First, refresh the merge lock — a slow push must not let the 600s lease expire under you: " + LIFECYCLE_ENV + LIFECYCLE + " refresh-lock " + taskId + ". If the refresh fails, report 'merge lock lost before push' and end your report with exactly this line: VERDICT: FAIL. Do not push without the lock.\n" +
912
1325
  "Run: cd " + REPO_PATH + " && git push origin main\n" +
913
1326
  "- If the push succeeds, report the merged commit hash.\n" +
914
1327
  "- If the push is rejected as non-fast-forward (the remote has commits not present locally),\n" +
915
- " NEVER force-push. Do not run any --force variant. Report 'git push origin main rejected as non-fast-forward — remote main has diverged; manual resolution required' in your notes, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1328
+ " NEVER force-push. Do not run any --force variant. The lifecycle's `integrate` already reconciled origin/main under the merge lock, so a rejection means the remote advanced after that reconcile — recover mechanically while you still hold the lock:\n" +
1329
+ " 1. Run: cd " + REPO_PATH + " && git fetch origin main && git merge --no-edit origin/main -m \"merge: push-time reconcile (" + taskId + ")\".\n" +
1330
+ " 2. If the merge conflicts, abort it (git merge --abort), report 'push-time reconcile conflicted — manual resolution required', then end your report with exactly this line: VERDICT: FAIL.\n" +
1331
+ " 3. If it merges cleanly, retry the push exactly once: git push origin main. If the second push still rejects, report 'push rejected twice as non-fast-forward', then end your report with exactly this line: VERDICT: FAIL.\n" +
1332
+ " 4. If the retry succeeds, report the merged commit hash.\n\n" +
916
1333
  "Report back in plain prose — what happened at each step — and end your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
917
1334
 
918
1335
  } else if (step.name === "Publish") {
@@ -946,15 +1363,22 @@ while (i < STEPS.length) {
946
1363
  // provenance was stamped — prose-trusted side effects, the same failure
947
1364
  // class as the npm double-skip (bb739316). The npm path already runs one
948
1365
  // deterministic script; the artifact path now has the same shape. Lock
949
- // refresh, rebuild trigger, build-completion poll, provenance stamp, and
950
- // post-deploy are narrow schema'd bookkeeping calls owned by the
951
- // workflow — the work agent reports on the mechanical outcome and
952
- // cannot skip what it never owned. Any step failing parks with an
953
- // honest, step-specific reason (fail-closed). The post-hoc
954
- // getprovenance-vs-HEAD verification below stays as the final gate.
1366
+ // refresh, rebuild trigger, build-completion poll, and post-deploy are
1367
+ // narrow schema'd bookkeeping calls owned by the workflow — the work
1368
+ // agent reports on the mechanical outcome and cannot skip what it never
1369
+ // owned. Any step failing parks with an honest, step-specific reason
1370
+ // (fail-closed). There is deliberately NO workflow-side provenance
1371
+ // stamp: the builder's applied-report is circular (canary run 8,
1372
+ // 2026-09-11), so the stamp moved to the parent — after the build
1373
+ // lands, the workflow triggers an independent artifact_inspect
1374
+ // read-back, records the session completed, and parks with
1375
+ // "publish: verification-requested". The parent stamps provenance only
1376
+ // after the read-back confirms the content (docs/publish-verification.md);
1377
+ // QA's provenance check enforces the stamp mechanically.
955
1378
  var artifactPublish = null;
956
1379
  var publishLockRefreshed = false;
957
1380
  var publishSkippedNoLock = false;
1381
+ var publishBuildLanded = false; // true once the artifact build poll completes: content exists to verify; the provenance stamp is deferred to the parent (docs/publish-verification.md)
958
1382
  try {
959
1383
  // STEP 0 (mechanical): read the merge-lock state explicitly — never
960
1384
  // infer it from prose. An empty-diff Integrate (MERGED_EMPTY)
@@ -977,31 +1401,297 @@ while (i < STEPS.length) {
977
1401
  }
978
1402
  publishLockRefreshed = true;
979
1403
  }
980
- // STEP 1 (mechanical): trigger the rebuild with one narrow call.
1404
+ // STEP 1 (mechanical): carry the merged change to the artifact
1405
+ // builder. The builder's source tree is NOT the crew's repo —
1406
+ // canary run 4 (2026-09-11) proved it: Publish asked for "rebuild
1407
+ // from current source. Do not modify any source files" and the
1408
+ // builder rebuilt a stale copy predating the canary's changes, then
1409
+ // the workflow stamped the new commit hash on the stale build.
1410
+ // Provenance fiction; all eight phases passed. The merge diff is
1411
+ // embedded in the edit request; the builder applies it to its own
1412
+ // tree and reports the applied changes; the workflow verifies the
1413
+ // report matches the diff BEFORE stamping provenance. A mismatch
1414
+ // parks without stamping — the stamp must never certify a build
1415
+ // whose content was not verified.
981
1416
  // Skipped entirely when no lock was held — nothing merged, nothing
982
1417
  // to ship.
983
1418
  if (!publishSkippedNoLock) {
984
- // (below) the rebuild trigger, bounded poll, and provenance stamp
985
- // agent only makes the artifact_edit call and reports whether it was
986
- // accepted — no prose claim to trust. If the artifact tool namespace
1419
+ // (below) the diff computation, rebuild trigger, application
1420
+ // verification, bounded poll, and provenance stamp. The builder
1421
+ // only makes the artifact_edit call and reports the applied
1422
+ // changes — no prose claim to trust. If the artifact tool namespace
987
1423
  // is missing from this child it reports honestly and the workflow
988
1424
  // retries once with a fresh key (bounded); anything else parks.
1425
+ var diffResult = await agent(
1426
+ "Run: cd " + REPO_PATH + " && git rev-parse HEAD && echo '---PARENT---' && git rev-parse HEAD^1 && echo '---DIFF---' && git diff HEAD^1 HEAD && echo '---NAMES---' && git diff-tree --no-commit-id --name-only -r HEAD\n" +
1427
+ "Return JSON { \"commit\": \"<HEAD trimmed>\", \"parent\": \"<HEAD^1 trimmed>\", \"diff\": \"<raw unified diff, may be multi-line>\", \"files\": \"<newline-separated paths>\" } and nothing else.",
1428
+ { key: attemptKey("publish-artifact-diff-" + taskId, totalReworkCount), label: "Computing merged diff for publish",
1429
+ schema: { type: "object", properties: { commit: { type: "string" }, parent: { type: "string" }, diff: { type: "string" }, files: { type: "string" } }, required: ["commit", "diff"] } }
1430
+ );
1431
+ var mergeCommitForPublish = (diffResult.commit || "").trim();
1432
+ var mergeParentForPublish = (diffResult.parent || "").trim();
1433
+ var mergeDiff = diffResult.diff || "";
1434
+ if (!mergeDiff.trim()) {
1435
+ return await parkTask("Publish diff is empty for commit " + (mergeCommitForPublish || "unknown") + " — a merge lock was held but there is no change to carry. Human attention needed.");
1436
+ }
1437
+ if (/^Binary files /m.test(mergeDiff)) {
1438
+ return await parkTask("Publish diff contains binary files — the text diff transport cannot carry them. Human attention needed.");
1439
+ }
1440
+ if (/^rename from /m.test(mergeDiff)) {
1441
+ return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
1442
+ }
1443
+ var mergeDiffLines = mergeDiff.split("\n").length;
1444
+ if (mergeDiffLines > 200) {
1445
+ return await parkTask("Publish diff is " + mergeDiffLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
1446
+ }
1447
+ var expectedChanges = parseUnifiedDiff(mergeDiff);
1448
+ if (expectedChanges.length === 0) {
1449
+ return await parkTask("Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.");
1450
+ }
1451
+ // Pre-publish base observation (diagnostic, 2026-09-12): the builder
1452
+ // applies the diff to its own source tree, whose base state is
1453
+ // unrecorded. Compute the trustworthy expected base — the sha256 of
1454
+ // each touched file at the merge parent commit — so the builder's
1455
+ // self-reported pre-edit hashes (see buildPreHashInstruction) can be
1456
+ // compared against it. Observation only: a mismatch is logged loudly
1457
+ // but never parks. The observation tells us what the publish actually
1458
+ // reads, so the subsequent fix can require the right base.
1459
+ var expectedBaseHashes = {};
1460
+ try {
1461
+ // Shell-quote helper (no regex-with-quote: the test parser does not
1462
+ // understand regex literals containing quotes).
1463
+ var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
1464
+ var baseHashResult = await agent(
1465
+ "Run: cd " + REPO_PATH + " && parent=" + sq(mergeParentForPublish) + " && for f in " + expectedChanges.map(function(f) { return sq(f.path); }).join(" ") + "; do printf '%s:' \"$f\"; git show \"$parent:$f\" 2>/dev/null | sha256sum | cut -d' ' -f1; done\n" +
1466
+ "Return JSON { \"hashes\": \"<newline-separated <path>:<sha256> lines, empty hash means the file is new in this diff>\" } and nothing else.",
1467
+ { key: attemptKey("publish-base-hashes-" + taskId, totalReworkCount), label: "Computing expected base content hashes",
1468
+ schema: { type: "object", properties: { hashes: { type: "string" } }, required: ["hashes"] } }
1469
+ );
1470
+ (baseHashResult.hashes || "").split("\n").forEach(function(line) {
1471
+ var m = /^([^:]+):([0-9a-f]*)$/.exec(line.trim());
1472
+ if (m) expectedBaseHashes[m[1]] = m[2] || "NEW-FILE";
1473
+ });
1474
+ log("Publish expected base hashes for task " + taskId + " (merge parent " + (mergeParentForPublish || "unknown").slice(0, 12) + "): " + JSON.stringify(expectedBaseHashes));
1475
+ } catch (e) {
1476
+ log("Publish expected base hash computation failed for task " + taskId + " (non-fatal, observation degraded): " + (e && e.message ? e.message : e));
1477
+ }
989
1478
  var rebuildPrompt =
990
1479
  "Call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
991
- "'Rebuild the application from current source. Do not modify any source files — just rebuild and deploy what is on disk.'\n" +
992
- "If the artifact_edit tool is not available in this session, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_edit unavailable\" }.\n" +
993
- "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\" } and nothing else.";
1480
+ "'Apply the following change to your source tree, then rebuild and deploy.\n" +
1481
+ "\n" +
1482
+ "UNIFIED DIFF (relative to your source tree):\n" +
1483
+ "```diff\n" + mergeDiff + "\n```\n" +
1484
+ "\n" +
1485
+ "Rules:\n" +
1486
+ "- For each file in the diff, apply its hunks to the same path in your source tree (use git apply or equivalent).\n" +
1487
+ "- For a new file (--- /dev/null), create it with the added (+) lines as its full content.\n" +
1488
+ "- For a deleted file (+++ /dev/null), delete it.\n" +
1489
+ "- If any hunk does not apply cleanly, STOP and report the failure — do not improvise or skip hunks.\n" +
1490
+ "- Do not make any other source changes.\n" +
1491
+ "- After applying, rebuild and deploy.\n" +
1492
+ "- Report, for each file you changed: its path, the exact lines you added, and the exact lines you removed.\n" +
1493
+ buildPreHashInstruction(expectedChanges) + "'\n" +
1494
+ ARTIFACT_LOAD_PREAMBLE +
1495
+ "If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
1496
+ "Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
994
1497
  var rebuildSchema =
995
- { type: "object", properties: { edit_started: { type: "boolean" }, error: { type: "string" } }, required: ["edit_started"] };
996
- var rebuildTrigger = await agent(rebuildPrompt,
997
- { key: attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount), label: "Triggering artifact rebuild", schema: rebuildSchema });
998
- if (!rebuildTrigger.edit_started && rebuildTrigger.error === "artifact_edit unavailable") {
999
- log("Publish rebuild trigger: artifact_edit unavailable — one bounded retry with a fresh key");
1498
+ { type: "object",
1499
+ properties: {
1500
+ edit_started: { type: "boolean" },
1501
+ error: { type: "string" },
1502
+ applied: {
1503
+ type: "array",
1504
+ items: {
1505
+ type: "object",
1506
+ properties: {
1507
+ path: { type: "string" },
1508
+ added: { type: "array", items: { type: "string" } },
1509
+ removed: { type: "array", items: { type: "string" } }
1510
+ },
1511
+ required: ["path", "added", "removed"]
1512
+ }
1513
+ },
1514
+ pre_hashes: {
1515
+ type: "object",
1516
+ description: "Diagnostic (2026-09-12): sha256 of each touched file's content BEFORE the builder applied the diff, as reported by the builder. Compared against the workflow-computed expected base hashes (merge parent) — observation only, never gating."
1517
+ }
1518
+ },
1519
+ required: ["edit_started", "applied"] };
1520
+ var rebuildTrigger = null;
1521
+ var rebuildReportMissing = false; // true if the edit went through but the agent returned no applied report (structured-output failure) — the smoke-check is skipped; the parent's independent read-back is the verification
1522
+ // The trigger key of the attempt that last ran, for the publish ledger.
1523
+ // Minted once here (not re-minted per use site) so the ledger always
1524
+ // records the exact key that was issued — and so a re-minted duplicate
1525
+ // can never drift from it.
1526
+ var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
1527
+ // The artifact build's agent_id, captured from artifact_status when the
1528
+ // trigger's outcome is ambiguous (structured-output failure). The
1529
+ // agent_id is the artifact system's in-flight correlation ID (not durable post-completion)
1530
+ // (research 2026-09-12): artifact.edit returns pending_init with NO
1531
+ // agent_id, but artifact_status exposes build.agent_id immediately
1532
+ // after acceptance, stable across polls. Recorded in the ledger so an
1533
+ // ambiguous attempt correlates to the exact builder run; null when no
1534
+ // build was ever observed.
1535
+ var rebuildAgentId = null;
1536
+ // The builder's applied-report is an observation, not a gate
1537
+ // (2026-09-12, task 23ca8f3f): computed once the trigger outcome is
1538
+ // known, logged loudly, never a park.
1539
+ var publishAppliedObservation = null; // "match" | "mismatch: <reason>" | "missing-report" — observation only, never a park
1540
+ try {
1000
1541
  rebuildTrigger = await agent(rebuildPrompt,
1001
- { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry1", totalReworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
1542
+ { key: rebuildAttemptKey, label: "Triggering artifact rebuild", schema: rebuildSchema });
1543
+ } catch (rebuildErr) {
1544
+ // Structured-output failure (canary run 9, 2026-09-11): the agent
1545
+ // called artifact_edit (tool_call_count > 0) but returned prose
1546
+ // instead of JSON. The side effect may have happened — the outcome
1547
+ // is UNKNOWN, not "did not go through". The old code asked a child
1548
+ // for derived booleans and retried on all-false; that check issued
1549
+ // the DUPLICATE artifact_edit on 2026-09-12.
1550
+ //
1551
+ // Build-ID research (2026-09-12) corrected the model: artifact.edit
1552
+ // returns pending_init with NO agent_id, but artifact_status exposes
1553
+ // the build's agent_id (the artifact system's durable build
1554
+ // identifier, stable across polls) immediately after acceptance.
1555
+ // So the recovery no longer asks the child to derive booleans —
1556
+ // the layer where the 2026-09-12 signal was lost. It reads the RAW
1557
+ // build object and extracts build.agent_id mechanically in the
1558
+ // workflow script. An observed agent_id is positive evidence the
1559
+ // edit went through; no build after a bounded poll is still
1560
+ // inconclusive (unknown), never proof the edit failed. Mechanical
1561
+ // rule: never blind-retry on unknown — record the attempt and park
1562
+ // fail-closed; correlate via the ledger, never by re-issuing.
1563
+ log("Publish rebuild trigger: structured-output failure (" + (rebuildErr && rebuildErr.message ? rebuildErr.message : rebuildErr) + ") — checking build state before deciding; outcome unknown until state confirms it");
1564
+ var buildState = null;
1565
+ var buildStateFailed = false;
1566
+ try {
1567
+ buildState = await agent(
1568
+ ARTIFACT_LOAD_PREAMBLE +
1569
+ "Call artifact_status with slug \"" + PUBLISH_SLUG + "\".\n" +
1570
+ "Poll up to 3 times, about 20 seconds apart, until the response shows a build (the \"build\" value is an object, not null). " +
1571
+ "Return the raw \"build\" value verbatim as JSON — the build object exactly as returned, with its agent_id, operation, status, and any other fields untouched. " +
1572
+ "Do not summarize, interpret, or derive booleans from it. " +
1573
+ "If no build appears after 3 polls, return null. " +
1574
+ "Return JSON { \"build\": <the raw build object or null> } and nothing else.",
1575
+ { key: attemptKey("publish-artifact-buildcheck-" + taskId, totalReworkCount), label: "Reading artifact build state after trigger failure",
1576
+ schema: { type: "object", properties: { build: { type: ["object", "null"] } }, required: ["build"] } }
1577
+ );
1578
+ } catch (buildCheckErr) {
1579
+ buildStateFailed = true;
1580
+ log("Publish rebuild trigger: build-state check itself failed (" + (buildCheckErr && buildCheckErr.message ? buildCheckErr.message : buildCheckErr) + ") — treating the outcome as unknown");
1581
+ }
1582
+ var acceptedAgentId = (buildState && buildState.build && typeof buildState.build.agent_id === "string" && buildState.build.agent_id) || null;
1583
+ if (!buildStateFailed && acceptedAgentId) {
1584
+ // The edit went through — the agent just failed to return JSON.
1585
+ // The build's agent_id is positive evidence: it appears in
1586
+ // artifact_status immediately after our accepted edit (pending_init
1587
+ // acceptance is followed by a visible build with a stable agent_id,
1588
+ // per the 2026-09-12 research). No applied report to smoke-check;
1589
+ // the parent's independent read-back (docs/publish-verification.md)
1590
+ // is the real verification, not the circular applied-report. The
1591
+ // agent_id is recorded in the ledger so this attempt correlates to
1592
+ // the exact builder run, not just commit + attempt key.
1593
+ log("Publish rebuild trigger: artifact_status shows build " + acceptedAgentId + " for slug " + PUBLISH_SLUG + " — the edit went through despite the structured-output failure. Skipping applied-report smoke-check; parent read-back is the verification.");
1594
+ rebuildTrigger = { edit_started: true, error: "", applied: null };
1595
+ rebuildReportMissing = true;
1596
+ rebuildAgentId = acceptedAgentId;
1597
+ } else {
1598
+ // No build observed — but that proves nothing (a fast-completing
1599
+ // build can finish between polls, or the check itself failed). The
1600
+ // outcome is UNKNOWN. No retry: re-issuing the edit here duplicated
1601
+ // it on 2026-09-12. Record the attempt durably and park fail-closed;
1602
+ // correlate via the ledger, never by guessing from a blind poll.
1603
+ log("Publish rebuild trigger: no build observed after structured-output failure — outcome UNKNOWN. Recording the attempt and parking fail-closed; no blind retry.");
1604
+ await recordPublishLedger({
1605
+ commit: mergeCommitForPublish,
1606
+ attempt: rebuildAttemptKey,
1607
+ agent_id: null,
1608
+ applied_report: null,
1609
+ outcome: "unknown",
1610
+ detail: "structured-output failure on rebuild trigger; build-state poll saw no build (or the check itself failed); edit may have been accepted as pending_init"
1611
+ }, totalReworkCount);
1612
+ return await parkTask("Publish outcome unknown: the rebuild trigger's child did not return JSON, and the follow-up build-state poll could not observe a build for slug " + PUBLISH_SLUG + ". The edit may have been accepted as pending_init, so no retry was issued — a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate the accepted edit via the ledger and the builder's eventual completion before re-driving Publish. Fail-closed.");
1613
+ }
1614
+ }
1615
+ if (!rebuildReportMissing && !rebuildTrigger.edit_started && rebuildTrigger.error === "artifact_tools missing after load") {
1616
+ log("Publish rebuild trigger: artifact_tools missing after load — one bounded retry with a fresh key");
1617
+ rebuildTrigger = await agent(rebuildPrompt,
1618
+ { key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
1619
+ rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", totalReworkCount);
1620
+ }
1621
+ // Durable publish-attempt ledger: record the trigger outcome while the
1622
+ // attempt key and commit are in scope. Every attempt lands here with
1623
+ // its outcome — submitted, rejected, or unknown (unknown is recorded
1624
+ // at the park site above). A later run or human matches commit hash +
1625
+ // attempt key against the builder's eventual completion.
1626
+ if (rebuildTrigger && rebuildTrigger.edit_started) {
1627
+ // Applied-report observation (2026-09-12, task 23ca8f3f): the
1628
+ // builder's applied-report is logged as observation only — it
1629
+ // never parks. The report is derived from the carried diff, so a
1630
+ // "match" certifies nothing (canary run 8); and it has a
1631
+ // demonstrated false-negative mode (applied:[] for a diff the
1632
+ // builder had actually applied). The independent read-back below
1633
+ // plus the parent protocol (docs/publish-verification.md) are the
1634
+ // verification — this block always proceeds to them.
1635
+ publishAppliedObservation = rebuildReportMissing
1636
+ ? "missing-report"
1637
+ : (function () { var c = verifyAppliedChanges(expectedChanges, rebuildTrigger.applied); return c.ok ? "match" : "mismatch: " + c.reason; })();
1638
+ log("Publish applied-report observation for task " + taskId + ": " + publishAppliedObservation + " — observation only, never a park: the applied report is derived from the carried diff and proved unreliable in the false-negative direction (task 23ca8f3f, 2026-09-12: applied:[] for a diff the builder had applied). The independent read-back below plus the parent protocol are the verification.");
1639
+ await recordPublishLedger({
1640
+ commit: mergeCommitForPublish,
1641
+ attempt: rebuildAttemptKey,
1642
+ agent_id: rebuildAgentId,
1643
+ applied_report: publishAppliedObservation,
1644
+ outcome: "submitted",
1645
+ detail: rebuildReportMissing
1646
+ ? "edit confirmed via build-state poll after structured-output failure (build " + (rebuildAgentId || "agent_id unknown") + "); builder applied-report missing"
1647
+ : "edit accepted; builder applied-report received"
1648
+ }, totalReworkCount);
1649
+ } else if (rebuildTrigger) {
1650
+ await recordPublishLedger({
1651
+ commit: mergeCommitForPublish,
1652
+ attempt: rebuildAttemptKey,
1653
+ applied_report: null,
1654
+ outcome: "rejected",
1655
+ detail: "edit not accepted: " + (rebuildTrigger.error || "no error detail")
1656
+ }, totalReworkCount);
1002
1657
  }
1003
1658
  var publishFailure = null;
1004
1659
  if (rebuildTrigger.edit_started) {
1660
+ // STEP 1b (observation only): publishAppliedObservation was
1661
+ // computed and logged above, inside the ledger block — the
1662
+ // builder's applied-report never parks and never blocks the stamp.
1663
+ // Task 23ca8f3f (2026-09-12) proved its false-negative mode:
1664
+ // applied:[] for a diff the builder had actually applied, which
1665
+ // parked a successful publish as unverified. A "match" certifies
1666
+ // nothing either — the report is derived from the carried diff
1667
+ // (canary run 8). The flow proceeds to the build poll and the
1668
+ // independent read-back trigger regardless of what the report
1669
+ // claimed; real verification is the parent's read-back
1670
+ // (docs/publish-verification.md) before the provenance stamp.
1671
+ // Pre-publish base observation (diagnostic, 2026-09-12): compare
1672
+ // the builder's self-reported pre-edit hashes against the
1673
+ // workflow-computed expected base (merge parent). This tells us
1674
+ // what base state the publish actually read. OBSERVATION ONLY —
1675
+ // a mismatch is logged loudly but never parks and never blocks
1676
+ // the stamp. If the tree was dirty or drifted, the evidence is
1677
+ // here; the fix (requiring the right base) comes after we see it.
1678
+ try {
1679
+ var preHashes = (rebuildTrigger && rebuildTrigger.pre_hashes) || {};
1680
+ var baseLines = expectedChanges.map(function(f) {
1681
+ var expected = expectedBaseHashes[f.path];
1682
+ var actual = preHashes[f.path];
1683
+ var expShort = (expected || "UNKNOWN").slice(0, 12);
1684
+ var actShort = String(actual || "NOT-REPORTED").slice(0, 12);
1685
+ var match = (expected !== undefined && actual !== undefined) ? (expected === actual) : "unknown";
1686
+ return " " + f.path + ": expected_base=" + expShort + " builder_pre=" + actShort + " match=" + match;
1687
+ });
1688
+ var anyMismatch = expectedChanges.some(function(f) {
1689
+ return expectedBaseHashes[f.path] !== undefined && preHashes[f.path] !== undefined && expectedBaseHashes[f.path] !== preHashes[f.path];
1690
+ });
1691
+ log("Publish pre-tree base observation for task " + taskId + (anyMismatch ? " — BASE MISMATCH DETECTED (tree was not at the expected merge-parent state when the diff was applied):" : " — base matches expected merge-parent state:") + "\n" + baseLines.join("\n"));
1692
+ } catch (e) {
1693
+ log("Publish pre-tree base observation failed for task " + taskId + " (non-fatal): " + (e && e.message ? e.message : e));
1694
+ }
1005
1695
  // STEP 1b (mechanical): bounded poll for build completion, chunked so
1006
1696
  // the merge-lock lease is refreshed before it can expire. The 600s
1007
1697
  // lease is shorter than the worst-case 10-minute build poll, so the
@@ -1031,7 +1721,7 @@ while (i < STEPS.length) {
1031
1721
  ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
1032
1722
  : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1033
1723
  buildPoll = await agent(
1034
- "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1724
+ "First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1035
1725
  "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1036
1726
  { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1037
1727
  schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
@@ -1043,22 +1733,20 @@ while (i < STEPS.length) {
1043
1733
  buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1044
1734
  }
1045
1735
  if (buildPoll.build_done) {
1046
- // STEP 1c (mechanical): stamp provenance from workflow-computed values.
1047
- var provStamp = await agent(
1048
- "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this SRC.\n" +
1049
- "Run: basename $(readlink " + crewHome + "/current) — call this REL.\n" +
1050
- "Run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS.\n" +
1051
- "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"setprovenance\", args:\n" +
1052
- "{ \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\", \"task_id\": \"" + taskId + "\" }.\n" +
1053
- "Return JSON { \"stamped\": <true if the setprovenance response contains ok: true, false otherwise>, \"source_commit\": \"<SRC trimmed>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
1054
- { key: attemptKey("publish-artifact-stamp-" + taskId, totalReworkCount), label: "Stamping artifact provenance",
1055
- schema: { type: "object", properties: { stamped: { type: "boolean" }, source_commit: { type: "string" }, crew_release: { type: "string" }, published_at: { type: "string" } }, required: ["stamped", "source_commit", "crew_release", "published_at"] } }
1056
- );
1057
- if (provStamp.stamped) {
1058
- artifactPublish = { source_commit: provStamp.source_commit, crew_release: provStamp.crew_release, published_at: provStamp.published_at };
1059
- } else {
1060
- publishFailure = "Provenance stamp failed after a completed build (source_commit " + (provStamp.source_commit || "unknown") + "). The build landed but is unstamped — fail-closed.";
1061
- }
1736
+ // STEP 1c (mechanical): NO provenance stamp here. Canary run 8
1737
+ // (2026-09-11) proved the stamp cannot certify content: the
1738
+ // builder's applied-report is derived from the carried diff, so
1739
+ // verifyAppliedChanges above is circular — a fabricated report
1740
+ // passes by construction, and every phase went green on a hollow
1741
+ // build. The stamp moves to the parent (docs/publish-verification.md):
1742
+ // after an independent artifact_inspect read-back confirms the
1743
+ // artifact's actual content matches the merged diff, the parent
1744
+ // stamps provenance and re-queues; QA's provenance check then
1745
+ // enforces the stamp mechanically, so an unverified publish fails
1746
+ // loudly in QA instead of passing silently here.
1747
+ publishBuildLanded = true;
1748
+ artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
1749
+ log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1062
1750
  } else {
1063
1751
  publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
1064
1752
  }
@@ -1089,9 +1777,9 @@ while (i < STEPS.length) {
1089
1777
  : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
1090
1778
  }
1091
1779
  if (!postDeploy.deployed) {
1092
- return await parkTask("Post-deploy failed after a stamped publish: " + (postDeploy.output || "no output") + ". The publish landed but worktree cleanup and lock release are unknown — human attention needed.");
1780
+ return await parkTask("Post-deploy failed after the artifact build landed: " + (postDeploy.output || "no output") + ". The build may have landed but worktree cleanup and lock release are unknown — human attention needed.");
1093
1781
  }
1094
- log("Deterministic artifact publish completed for task " + taskId + ": provenance at " + artifactPublish.source_commit);
1782
+ log("Deterministic artifact publish completed for task " + taskId + ": build at " + artifactPublish.source_commit + ", provenance PENDING parent content verification");
1095
1783
  }
1096
1784
  } catch (pubErr) {
1097
1785
  // Best-effort cleanup: if the lock was refreshed, try to release it
@@ -1101,8 +1789,7 @@ while (i < STEPS.length) {
1101
1789
  await agent(
1102
1790
  "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
1103
1791
  "Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise> } and nothing else.",
1104
- { key: attemptKey("publish-postdeploy-cleanup-" + taskId, totalReworkCount), label: "Releasing lock after publish failure",
1105
- schema: { type: "object", properties: { deployed: { type: "boolean" } }, required: ["deployed"] } }
1792
+ { key: attemptKey("publish-postdeploy-cleanup-" + taskId, totalReworkCount), label: "Releasing lock after publish failure" }
1106
1793
  );
1107
1794
  } catch (cleanupErr) {
1108
1795
  log("Publish cleanup post-deploy also failed: " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
@@ -1122,10 +1809,10 @@ while (i < STEPS.length) {
1122
1809
  instructions = "Publish the merged code to the live artifact.\n\n" +
1123
1810
  "The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
1124
1811
  "For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
1125
- "Mechanical outcome (every step succeeded and was verified by the workflow):\n" +
1812
+ "Mechanical outcome (the workflow's mechanical steps; content verification is the parent's, still pending):\n" +
1126
1813
  "- merge lock refreshed: yes\n" +
1127
1814
  "- artifact rebuild triggered and completed: yes\n" +
1128
- "- provenance stamped: yes — source_commit " + artifactPublish.source_commit + ", crew_release " + artifactPublish.crew_release + ", published_at " + artifactPublish.published_at + "\n" +
1815
+ "- provenance stamped: NO — not yet, and you must NOT stamp it. The stamp moved to the parent, which stamps it only after an independent content read-back confirms the artifact actually contains the merged change (docs/publish-verification.md). A stamped-but-hollow build is exactly how canary run 8 (2026-09-11) went green on stale content.\n" +
1129
1816
  "- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
1130
1817
  "POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
1131
1818
  "The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
@@ -1143,7 +1830,7 @@ while (i < STEPS.length) {
1143
1830
  // declared release: yes, QA verifies the registry actually moved. A silent
1144
1831
  // publish skip becomes a loud QA failure with evidence, not a pass.
1145
1832
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
1146
- ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published — UNLESS it was skipped deterministically on an empty-diff Integrate. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
1833
+ ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published — UNLESS it was skipped deterministically on an empty-diff Integrate. Find this task's recorded Publish result: run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
1147
1834
  "CHECK THE SKIP PATH FIRST: if the notes contain a line matching skipped: no-lock-held, Publish was skipped deterministically — Integrate reported MERGED_EMPTY (no commits ahead of main), so no merge lock was taken and there was nothing to ship. Verify the notes contain that skip-marker line and do NOT contain a line matching published: muse-crew@. Do NOT run npm view and do NOT demand registry movement — nothing was supposed to ship. Report 'npm publish check: Publish skipped deterministically (empty-diff Integrate — nothing to ship)' and PASS this check.\n" +
1148
1835
  "Only when the notes contain no skip marker must the publish have landed — run the full verification below.\n" +
1149
1836
  "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version> (the workflow appends it to the Publish notes, so it is always present). If the line is missing, report 'npm publish verification failed: Publish notes did not carry the computed target version', then end your report with exactly this line: VERDICT: FAIL.\n" +
@@ -1160,27 +1847,32 @@ while (i < STEPS.length) {
1160
1847
  " repair_authorized: false\n" +
1161
1848
  " verbatim_request: \"Verify task: " + safeTitle + ". " + safeDesc + "\"\n\n" +
1162
1849
  "This call is asynchronous — it fires the inspection but results arrive outside this workflow. That is expected and correct.\n\n" +
1163
- "STEP 2: Verify data integrity via the dashboard API.\n" +
1164
- "Use artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\" with read-only actions (e.g. gettasks, getagentsessions) to check the task's data-level effects.\n" +
1850
+ "STEP 2: Verify data integrity via the crew API.\n" +
1851
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1852
+ "Use the returned tasks, sessions, and events to check the task's data-level effects.\n" +
1165
1853
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
1166
- "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1854
+ "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
1167
1855
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1168
1856
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1169
- "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] not found in release registry\" }.\n" +
1857
+ "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from get-provenance] not found in release registry\" }.\n" +
1170
1858
  "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1171
- "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1859
+ "Otherwise the check is NOT failed yet: the parent stamps provenance AFTER post-deploy (docs/publish-verification.md), and post-deploy may commit an artifact-builder staging commit (\"rebuild: <task_id>\"), so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1172
1860
  "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
1173
1861
  "2. Run: cd " + REPO_PATH + " && git log --format=%s <provenance.source_commit>..LIVE_HEAD (substitute real values). Every subject line MUST start with \"rebuild: \". If any line does not, report 'provenance mismatch: live HEAD moved past the stamped commit with non-rebuild source commits: [paste the offending subject lines]', then end your report with exactly this line: VERDICT: FAIL.\n" +
1174
1862
  "If both pass, the source check passes — the only drift since the stamp is builder staging output committed by post-deploy. Continue to STEP 3.\n\n" +
1175
1863
  "STEP 3: File follow-up tasks for any related issues you discover.\n" +
1176
- "Use artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for each issue.\n\n" +
1864
+ "For each issue, run in shell:\n" +
1865
+ "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '{\"title\": \"<issue title>\", \"description\": \"<issue details>\", \"project\": \"" + LAUNCH_PROJECT_ID + "\", \"workflow\": \"bugfix\", \"filed_by\": \"hazel\"}'\n" +
1866
+ "(replace <issue title> and <issue details> with the real values).\n\n" +
1177
1867
  "Report back in plain prose — what checks you ran and their results. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1178
1868
  } else {
1179
1869
  instructions = "Test from a user's perspective. You are CODE-BLIND — do NOT read source code.\n" +
1180
1870
  "Public docs (API.md, README) are NOT source code — read them freely, exactly as a user would.\n" +
1181
1871
  "Verify the change is working as described in the task.\n" +
1182
1872
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
1183
- "File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues found.\n\n" +
1873
+ "File follow-up tasks for related issues found by running in shell:\n" +
1874
+ "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '{\"title\": \"<issue title>\", \"description\": \"<issue details>\", \"project\": \"" + LAUNCH_PROJECT_ID + "\", \"workflow\": \"bugfix\", \"filed_by\": \"hazel\"}'\n" +
1875
+ "(replace <issue title> and <issue details> with the real values).\n\n" +
1184
1876
  npmPublishCheck +
1185
1877
  "Report back in plain prose — what you tested and found. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1186
1878
  }
@@ -1194,20 +1886,23 @@ while (i < STEPS.length) {
1194
1886
  "VISUAL VERDICT OWNERSHIP: this task is experiential. The visual verdict is NOT yours to issue — it is produced after the rendered post-change inspection results arrive, by Hazel with the baseline and post-change evidence in hand.\n" +
1195
1887
  "Your VERDICT below covers the MECHANICAL CHECKS only. Do NOT call artifact_inspect (async; the parent triggers the post-change capture after your step).\n\n" +
1196
1888
  "MECHANICAL CHECKS:\n" +
1197
- "STEP 2: Verify data integrity via the dashboard API.\n" +
1198
- "Use artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\" with read-only actions (e.g. gettasks, getagentsessions) to check the task's data-level effects.\n" +
1889
+ "STEP 2: Verify data integrity via the crew API.\n" +
1890
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
1891
+ "Use the returned tasks, sessions, and events to check the task's data-level effects.\n" +
1199
1892
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
1200
- "PROVENANCE CHECK: Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}.\n" +
1893
+ "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
1201
1894
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
1202
1895
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
1203
- "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from getprovenance] not found in release registry\" }.\n" +
1896
+ "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from get-provenance] not found in release registry\" }.\n" +
1204
1897
  "If provenance.source_commit equals LIVE_HEAD, the source check passes — continue to STEP 3.\n" +
1205
- "Otherwise the check is NOT failed yet: post-deploy commits an artifact-builder staging commit (\"rebuild: <task_id>\") AFTER the stamp, so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1898
+ "Otherwise the check is NOT failed yet: the parent stamps provenance AFTER post-deploy (docs/publish-verification.md), and post-deploy may commit an artifact-builder staging commit (\"rebuild: <task_id>\"), so LIVE_HEAD may sit ahead of the stamped commit ONLY IF every commit in between is such a rebuild marker. Verify exactly:\n" +
1206
1899
  "1. Run: cd " + REPO_PATH + " && git merge-base --is-ancestor <provenance.source_commit> LIVE_HEAD && echo ANCESTOR_OK (substitute the real stamped hash and LIVE_HEAD; do not run the literal placeholders). If this command fails, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: stamped source_commit is not an ancestor of live HEAD\" }.\n" +
1207
1900
  "2. Run: cd " + REPO_PATH + " && git log --format=%s <provenance.source_commit>..LIVE_HEAD (substitute real values). Every subject line MUST start with \"rebuild: \". If any line does not, report 'provenance mismatch: live HEAD moved past the stamped commit with non-rebuild source commits: [paste the offending subject lines]', then end your report with exactly this line: VERDICT: FAIL.\n" +
1208
1901
  "If both pass, the source check passes — the only drift since the stamp is builder staging output committed by post-deploy. Continue to STEP 3.\n\n" +
1209
1902
  "STEP 3: File follow-up tasks for any related issues you discover.\n" +
1210
- "Use artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for each issue.\n\n" +
1903
+ "For each issue, run in shell:\n" +
1904
+ "node " + CREW_API + " --crew-home " + crewHome + " create-task --json '{\"title\": \"<issue title>\", \"description\": \"<issue details>\", \"project\": \"" + LAUNCH_PROJECT_ID + "\", \"workflow\": \"bugfix\", \"filed_by\": \"hazel\"}'\n" +
1905
+ "(replace <issue title> and <issue details> with the real values).\n\n" +
1211
1906
  "BASELINE SANITY: in the event history you fetched, the task's note events must contain a message starting with `baseline: captured` or `baseline: none`. If no message starts with either prefix, report 'baseline evidence missing at QA — the Map gate was bypassed', then end your report with exactly this line: VERDICT: FAIL.\n\n" +
1212
1907
  "File follow-up tasks as today.\n" +
1213
1908
  "Report back in plain prose — what checks you ran and their results. End your report with exactly one line: VERDICT: PASS or VERDICT: FAIL on the mechanical checks.";
@@ -1221,7 +1916,7 @@ while (i < STEPS.length) {
1221
1916
  var eventPreamble = "";
1222
1917
  if (step.name !== "Review" && step.name !== "Publish") {
1223
1918
  eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
1224
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
1919
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId }) + "\n" +
1225
1920
  "The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
1226
1921
  }
1227
1922
 
@@ -1232,13 +1927,14 @@ while (i < STEPS.length) {
1232
1927
  // string. The verdict is still extracted deterministically from the report
1233
1928
  // text by extractVerdict below — never by an agent.
1234
1929
  var workPromptBase =
1930
+ TOOL_CHECK_PREAMBLE +
1235
1931
  "Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
1236
1932
  "## Your Assignment\n\n" +
1237
1933
  "Task: " + taskTitle + "\n" +
1238
1934
  "Task ID: " + taskId + "\n" +
1239
1935
  "Description: " + taskDescription + "\n" +
1240
1936
  "Step: " + step.name + "\n" +
1241
- (step.name !== "Review" ? "Dashboard slug: " + DASHBOARD_SLUG + "\n" : "") +
1937
+ (step.name !== "Review" ? "Crew API: node " + CREW_API + " --crew-home " + crewHome + " <command> --json '<args>'\n" : "") +
1242
1938
  "\n## Instructions\n\n" + eventPreamble + instructions + "\n\n" +
1243
1939
  "CONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\n" +
1244
1940
  "Stay in character. Do the work thoroughly.\n\n" +
@@ -1249,7 +1945,8 @@ while (i < STEPS.length) {
1249
1945
  var workAttempts = [];
1250
1946
  for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
1251
1947
  var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), workAttempt);
1252
- var retryReason = workAttempt === 0 ? null : (workAttempts[workAttempt - 1].threw ? "discarded" : "empty");
1948
+ var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
1949
+ var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
1253
1950
  try {
1254
1951
  workerResult = await agent(
1255
1952
  workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
@@ -1264,6 +1961,22 @@ while (i < STEPS.length) {
1264
1961
  // always fails (regression shipped in ecef136 when Date.now() was
1265
1962
  // removed from this loop).
1266
1963
  if (typeof workerResult === "string" && workerResult.trim()) {
1964
+ // Bug 3472bf36: a worker whose TOOL CHECK reports artifact_tools: missing
1965
+ // (or shell_transport: unavailable) gets a fresh launch - the load is
1966
+ // per-launch - instead of a useless report.
1967
+ var toolSignals = parseToolSignals(workerResult);
1968
+ if (toolSignals.artifactTools === "missing") {
1969
+ workAttempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
1970
+ log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 reported artifact_tools: missing — retrying with a fresh launch");
1971
+ workerResult = null;
1972
+ continue;
1973
+ }
1974
+ if (toolSignals.shellTransport === "unavailable") {
1975
+ workAttempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
1976
+ log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 reported shell_transport: unavailable — retrying with a fresh launch");
1977
+ workerResult = null;
1978
+ continue;
1979
+ }
1267
1980
  if (workAttempt > 0) log(step.name + " work agent transport retry " + workAttempt + " returned a machine-readable report");
1268
1981
  break;
1269
1982
  }
@@ -1286,11 +1999,12 @@ while (i < STEPS.length) {
1286
1999
  log(step.name + " " + workFailure.notes + " — marking failed for retry");
1287
2000
  await agent(
1288
2001
  "Record work failure.\n" +
1289
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
1290
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"" + workFailure.notes + "\" }.\n" +
1291
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
1292
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + workFailure.eventMessage + "\" }.",
1293
- { key: "record-block-" + step.name, label: "Recording work failure", schema: { type: "object" } }
2002
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2003
+ task_id: taskId,
2004
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: workFailure.notes },
2005
+ event: { task_id: taskId, type: "failed", message: workFailure.eventMessage }
2006
+ }),
2007
+ { key: "record-block-" + step.name, label: "Recording work failure" }
1294
2008
  );
1295
2009
  return {
1296
2010
  __hatchWorkflowControl: "blocked",
@@ -1324,11 +2038,12 @@ while (i < STEPS.length) {
1324
2038
  log(step.name + " verdict re-ask exhausted — marking failed for retry");
1325
2039
  await agent(
1326
2040
  "Record verdict failure.\n" +
1327
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
1328
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)\" }.\n" +
1329
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
1330
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry\" }.",
1331
- { key: "record-block-" + step.name, label: "Recording verdict failure", schema: { type: "object" } }
2041
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2042
+ task_id: taskId,
2043
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
2044
+ event: { task_id: taskId, type: "failed", message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" }
2045
+ }),
2046
+ { key: "record-block-" + step.name, label: "Recording verdict failure" }
1332
2047
  );
1333
2048
  return {
1334
2049
  __hatchWorkflowControl: "blocked",
@@ -1342,6 +2057,38 @@ while (i < STEPS.length) {
1342
2057
  verdictPassed = verdict.passed;
1343
2058
  }
1344
2059
 
2060
+
2061
+ // Worktree confinement (Build only): the declared worktree path must
2062
+ // match WORKTREE_HINT exactly. A builder that worked in any other
2063
+ // checkout fails the phase here — the dispatcher retries Build under
2064
+ // its consecutive-failure cap, and the retry re-runs prepare against
2065
+ // the configured repo. Missing or mismatched lines fail closed.
2066
+ if (step.name === "Build" && verdictPassed === true) {
2067
+ var wt = extractWorktree(workerText);
2068
+ if (!wt.ok || wt.path !== WORKTREE_HINT) {
2069
+ log("Build worktree confinement failed — declared: " + (wt.ok ? wt.path : "<none>") + ", expected: " + WORKTREE_HINT + " — marking failed for retry");
2070
+ await agent(
2071
+ "Record worktree confinement failure.\n" +
2072
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2073
+ task_id: taskId,
2074
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
2075
+ notes: "Build declared worktree " + (wt.ok ? wt.path : "<none>") + " — expected " + WORKTREE_HINT + ". The builder worked outside the configured repo checkout; phase failed for retry" },
2076
+ event: { task_id: taskId, type: "failed", message: "Build worktree confinement failed — builder worked outside " + WORKTREE_HINT + ", phase failed, dispatcher will retry" }
2077
+ }),
2078
+ { key: "record-worktree-fail-" + step.name, label: "Recording worktree confinement failure" }
2079
+ );
2080
+ return {
2081
+ __hatchWorkflowControl: "blocked",
2082
+ result: {
2083
+ blocked_reason: "Build worked outside the configured repo checkout",
2084
+ message: "The builder declared worktree " + (wt.ok ? wt.path : "<none>") + " but the task's worktree is " + WORKTREE_HINT + ". The phase is marked failed; the dispatcher will retry Build against the configured repo.",
2085
+ task_id: taskId
2086
+ }
2087
+ };
2088
+ }
2089
+ log("Build worktree confinement passed: " + wt.path);
2090
+ }
2091
+
1345
2092
  // Deterministic closeout: no formatter agent. The verdict is mechanical
1346
2093
  // (extractVerdict above); the summary is the worker's report truncated.
1347
2094
  // For verdict steps passed comes from the verdict; for non-verdict steps
@@ -1436,13 +2183,13 @@ while (i < STEPS.length) {
1436
2183
  // dashboard QA source check).
1437
2184
  try {
1438
2185
  var provRefresh = await agent(
1439
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getprovenance\", args: {}. " +
2186
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
1440
2187
  "If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
1441
2188
  "Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
1442
2189
  "run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
1443
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"setprovenance\", args: " +
1444
- "{ \"source_commit\": \"<existing provenance.source_commit>\", \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\", \"task_id\": \"" + taskId + "\" }. " +
1445
- "Return JSON { \"refreshed\": <true if the setprovenance response contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
2190
+ "Then run in shell:\n" + crewCmd("set-provenance", { source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2191
+ "(substitute the real existing source_commit, REL, and TS for the placeholders). " +
2192
+ "Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
1446
2193
  { key: attemptKey("publish-provenance-refresh-" + taskId, totalReworkCount), label: "Refreshing dashboard provenance after crew release",
1447
2194
  schema: { type: "object", properties: { refreshed: { type: "boolean" }, reason: { type: "string" }, crew_release: { type: "string" }, published_at: { type: "string" } }, required: ["refreshed"] } }
1448
2195
  );
@@ -1463,43 +2210,51 @@ while (i < STEPS.length) {
1463
2210
  } // end: !npmPublishSkipped — a skipped publish has nothing to verify
1464
2211
  }
1465
2212
 
1466
- // Artifact publish verification: the worker cannot self-certify a deploy.
1467
- // The workflow reads the artifact's provenance and confirms it points at the
1468
- // integrated commit. A stale or missing provenance means the publish did not
1469
- // land — fail closed, do not trust the worker's prose.
2213
+ // Publish content verification — parent-owned (docs/publish-verification.md).
2214
+ // The old block read back the workflow's OWN provenance stamp and compared
2215
+ // it to HEAD: that verifies the stamp, not the content. Canary run 8
2216
+ // (2026-09-11) passed it with a hollow build — the stamp was honest, the
2217
+ // artifact was stale, all eight phases green. The stamp now moves to the
2218
+ // parent: trigger an independent artifact_inspect read-back of the changed
2219
+ // regions here; the parent stamps provenance only after mechanically
2220
+ // confirming the artifact's actual content matches the merged diff. QA's
2221
+ // provenance check enforces the stamp — an unverified publish fails loudly
2222
+ // there instead of passing silently here.
2223
+ // Skip-aware (park 2026-09-11): an empty-diff Integrate takes no merge
2224
+ // lock, and the deterministic publish path skips rebuild/stamp entirely —
2225
+ // there is no new content to verify, so verification is vacuous.
2226
+ // publishSkippedNoLock is workflow-computed state from the explicit
2227
+ // lock-status read in STEP 0, not agent prose.
2228
+ var publishVerifyInspect = { triggered: false, inspection_id: "", error: "" };
1470
2229
  if (step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG) {
1471
- // Skip-aware (park 2026-09-11): an empty-diff Integrate takes no merge
1472
- // lock, and the deterministic publish path skips rebuild/stamp entirely —
1473
- // there is no new provenance to compare against HEAD, so verification is
1474
- // vacuous. publishSkippedNoLock is workflow-computed state from the
1475
- // explicit lock-status read in STEP 0, not agent prose.
1476
2230
  if (publishSkippedNoLock) {
1477
- log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): artifact verification vacuous, nothing was shipped");
2231
+ log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): content verification vacuous, nothing was shipped");
2232
+ } else if (!publishBuildLanded) {
2233
+ log("Publish build did not land for task " + taskId + " — no content to verify (the failure park above already fired)");
1478
2234
  } else {
1479
2235
  try {
1480
- var headResult = await agent(
1481
- "Run: cd " + REPO_PATH + " && git rev-parse HEAD. Return JSON { \"head\": \"<output trimmed>\" } and nothing else.",
1482
- { key: attemptKey("verify-publish-head-" + taskId, totalReworkCount), label: "Reading integrated commit",
1483
- schema: { type: "object", properties: { head: { type: "string" } }, required: ["head"] } }
1484
- );
1485
- var expectedCommit = (headResult.head || "").trim();
1486
- var provResult = await agent(
1487
- "Call artifact_invoke_action on slug \"" + PUBLISH_SLUG + "\", action \"getprovenance\", args: {}. " +
1488
- "Return JSON { \"source_commit\": \"<provenance.source_commit>\", \"published_at\": \"<provenance.published_at>\" } and nothing else.",
1489
- { key: attemptKey("verify-publish-prov-" + taskId, totalReworkCount), label: "Verifying artifact provenance",
1490
- schema: { type: "object", properties: { source_commit: { type: "string" }, published_at: { type: "string" } }, required: ["source_commit"] } }
2236
+ var inspectResult = await agent(
2237
+ ARTIFACT_LOAD_PREAMBLE +
2238
+ "Call artifact_inspect with slug \"" + PUBLISH_SLUG + "\", repair_authorized false, and verbatim_request exactly as follows:\n" +
2239
+ "<<<READBACK_REQUEST\n" + buildPublishReadbackRequest(taskId, mergeCommitForPublish, mergeDiff, rebuildAgentId) + "\nREADBACK_REQUEST\n" +
2240
+ "If artifact_inspect is still not available after the load, do NOT improvise — return { \"triggered\": false, \"inspection_id\": \"\", \"error\": \"artifact_tools missing after load\" } and nothing else.\n" +
2241
+ "Return JSON { \"triggered\": <true if the inspection started, false otherwise>, \"inspection_id\": \"<the inspection id, or empty string>\", \"error\": \"<details or empty string>\" } and nothing else.",
2242
+ { key: attemptKey("publish-verify-inspect-" + taskId, totalReworkCount), label: "Triggering publish content read-back",
2243
+ schema: { type: "object", properties: { triggered: { type: "boolean" }, inspection_id: { type: "string" }, error: { type: "string" } }, required: ["triggered"] } }
1491
2244
  );
1492
- var provCommit = (provResult.source_commit || "").trim();
1493
- if (!provCommit || provCommit !== expectedCommit) {
1494
- return await parkTask("Publish verification failed: artifact provenance shows source_commit '" + provCommit +
1495
- "' but the integrated HEAD is '" + expectedCommit + "'. The publish did not land or provenance was not stamped.");
2245
+ publishVerifyInspect.triggered = !!(inspectResult && inspectResult.triggered);
2246
+ publishVerifyInspect.inspection_id = (inspectResult && inspectResult.inspection_id) || "";
2247
+ publishVerifyInspect.error = (inspectResult && inspectResult.error) || "";
2248
+ if (publishVerifyInspect.triggered) {
2249
+ log("Publish content read-back inspection triggered for task " + taskId + ": " + publishVerifyInspect.inspection_id);
2250
+ } else {
2251
+ log("Publish content read-back inspect trigger failed for task " + taskId + ": " + (publishVerifyInspect.error || "not started") + " — the park below asks the parent to trigger it manually");
1496
2252
  }
1497
- log("Publish verified for task " + taskId + ": artifact provenance at " + provCommit);
1498
- publishVerified = true;
1499
2253
  } catch (e) {
1500
- return await parkTask("Publish verification failed: could not read artifact provenance (" + (e && e.message ? e.message : e) + "). Fail-closed.");
2254
+ publishVerifyInspect.error = (e && e.message ? e.message : String(e)).slice(0, 200);
2255
+ log("Publish content read-back inspect trigger threw for task " + taskId + ": " + publishVerifyInspect.error + " — the park below asks the parent to trigger it manually");
1501
2256
  }
1502
- } // end: !publishSkippedNoLock — a skipped publish has nothing to verify
2257
+ } // end: !publishSkippedNoLock && publishBuildLanded — a skipped or failed publish has nothing to verify
1503
2258
  }
1504
2259
 
1505
2260
  // Session notes. Machine-readable marker lines are extracted from the full
@@ -1551,14 +2306,14 @@ while (i < STEPS.length) {
1551
2306
  // Record session result
1552
2307
  await agent(
1553
2308
  "Update the session and log the event.\n" +
1554
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
1555
- "{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"" + status + "\", \"notes\": " + JSON.stringify(summary) + " }.\n" +
1556
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
1557
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"" + status + "\", \"identity\": \"" + step.identity + "\", \"message\": \"" + step.name + " " + status + " by " + step.identity + "\" }.",
2309
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
2310
+ task_id: taskId,
2311
+ session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary },
2312
+ event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
2313
+ }),
1558
2314
  {
1559
2315
  key: "record-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
1560
- label: "Recording " + step.name + " result",
1561
- schema: { type: "object" }
2316
+ label: "Recording " + step.name + " result"
1562
2317
  }
1563
2318
  );
1564
2319
 
@@ -1589,7 +2344,11 @@ while (i < STEPS.length) {
1589
2344
  log("Visual verdict FAIL — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
1590
2345
  continue;
1591
2346
  } else {
1592
- return await parkTask("Visual verdict pending — parent: run the post-change capture + visual verdict protocol in docs/visual-verdict.md (capture plan is in the QA session notes)");
2347
+ if (!VISUAL_PROTOCOL_AVAILABLE) {
2348
+ log("Visual verdict protocol not available (VISUAL_PROTOCOL_AVAILABLE=false) for task " + taskId + " — skipping visual gate, QA mechanical checks already passed");
2349
+ } else {
2350
+ return await parkTask("Visual verdict pending — parent: run the post-change capture + visual verdict protocol in docs/visual-verdict.md (capture plan is in the QA session notes)");
2351
+ }
1593
2352
  }
1594
2353
  }
1595
2354
 
@@ -1621,16 +2380,32 @@ while (i < STEPS.length) {
1621
2380
  return { status: "failed", task_id: taskId, reason: "Publish failed: " + summary };
1622
2381
  }
1623
2382
 
2383
+ // Publish verification park: the build landed and post-deploy finalized,
2384
+ // but provenance is UNSTAMPED until the parent's independent read-back
2385
+ // (docs/publish-verification.md) confirms the artifact's actual content
2386
+ // matches the merged diff. The parent stamps provenance, then re-queues;
2387
+ // the dispatcher resumes at QA, whose provenance check enforces the stamp
2388
+ // mechanically. A failed Publish never reaches this park — it returned
2389
+ // failed above and retries under the dispatcher's cap. The merge lock is
2390
+ // already released (post-deploy), so the parked task holds no resources.
2391
+ if (passed && step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG && !publishSkippedNoLock && publishBuildLanded) {
2392
+ return await parkTask("publish: verification-requested " + mergeCommitForPublish +
2393
+ " (build " + (rebuildAgentId || "agent_id unobserved") + ")" +
2394
+ " — artifact build landed, post-deploy finalized, provenance NOT stamped. Parent: run docs/publish-verification.md" +
2395
+ (publishVerifyInspect.triggered
2396
+ ? " (content read-back inspection " + publishVerifyInspect.inspection_id + " already triggered)."
2397
+ : " (read-back inspect trigger failed: " + (publishVerifyInspect.error || "not started") + " — parent: trigger artifact_inspect manually)."));
2398
+ }
2399
+
1624
2400
  i++;
1625
2401
  }
1626
2402
 
1627
2403
  // All steps complete — mark task done
1628
2404
  await agent(
1629
2405
  "Mark this task as done.\n" +
1630
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"done\" }.\n" +
1631
- "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
1632
- "{ \"task_id\": \"" + taskId + "\", \"type\": \"completed\", \"message\": \"All standard workflow steps complete.\" }.",
1633
- { key: "task-done", label: "Completing task: " + taskTitle, schema: { type: "object" } }
2406
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", { id: taskId, state: "done" }) + "\n" +
2407
+ "Then run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "completed", message: "All standard workflow steps complete." }),
2408
+ { key: "task-done", label: "Completing task: " + taskTitle }
1634
2409
  );
1635
2410
 
1636
2411
  log("Standard workflow complete for task " + taskId);
@@ -1638,7 +2413,7 @@ log("Standard workflow complete for task " + taskId);
1638
2413
  // Clean up pinned lifecycle scripts
1639
2414
  await agent(
1640
2415
  "Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB,
1641
- { key: "cleanup-pins", label: "Cleaning pinned scripts", schema: { type: "object" } }
2416
+ { key: "cleanup-pins", label: "Cleaning pinned scripts" }
1642
2417
  );
1643
2418
 
1644
2419
  return { status: "ok", task_id: taskId, message: "Standard workflow complete for " + taskTitle };