muse-crew 0.14.4 → 0.14.5

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.
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5).
2
+ // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5;
3
+ // ported 2026-09-20: node:sqlite + crew-state.db (blocker 23) and direct
4
+ // tick-worker issuance (blocker 22 — children cannot reach artifact_edit).
3
5
  //
4
6
  // The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
5
7
  // lock handling, journaling, diff generation, ledger writes, mirroring, and
@@ -8,19 +10,20 @@
8
10
  //
9
11
  // This command owns all deterministic transitions with try/finally lock
10
12
  // release. Only the unavoidable `artifact_edit` tool call remains
11
- // agent-mediated (it requires the artifact namespace, which this process
12
- // cannot load).
13
+ // tick-mediated (it requires the artifact namespace, which this process
14
+ // cannot load): the session-carrying tick worker issues the edit directly
15
+ // in its own turn — NEVER a child (blocker 22, room #24).
13
16
  //
14
17
  // Two phases:
15
18
  // Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
16
19
  // journal intent, generate diff. Outputs JSON with the diff path/sha
17
- // for the agent to feed to artifact_edit. The lock is HELD on success
18
- // (the agent must call phase 2 to release it).
20
+ // for the tick worker's direct artifact_edit call. The lock is HELD on
21
+ // success (the tick must call phase 2 to release it).
19
22
  // Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
20
23
  // write the outcome notes, append the ledger entry, mirror verification,
21
24
  // and RELEASE the lock in a finally block.
22
25
  //
23
- // Crash safety: if the agent dies between phases, the lock expires after
26
+ // Crash safety: if the tick dies between phases, the lock expires after
24
27
  // 600s and the note journal (`publish: retry-intended` without
25
28
  // `publish: retry-issued`) lets the next scan mirror verification-requested
26
29
  // without re-triggering — exactly one retry, enforced from notes.
@@ -95,25 +98,24 @@ function writePublishNote(db, taskId, message) {
95
98
  ).run(uuid(), taskId, message, nowISO());
96
99
  }
97
100
 
98
- // Load the crew DB. We use better-sqlite3 via the crew-api's own dependency
99
- // resolution — fall back to a clear error if unavailable.
100
- let Database;
101
- try {
102
- Database = (await import("better-sqlite3")).default;
103
- } catch {
104
- fail("db-unavailable", "better-sqlite3 is not available to retry-publish.js");
105
- }
106
- const dbPath = join(crewHome, "crew.db");
101
+ // Load the crew DB via node:sqlite (the runtime's built-in — 2026-09-20,
102
+ // blocker 23: better-sqlite3 is not available and the old code pointed at
103
+ // the wrong database name). crew-api.js opens the same path the same way.
104
+ import { DatabaseSync } from "node:sqlite";
105
+ const dbPath = join(crewHome, "crew-state.db");
107
106
  if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
108
- const db = new Database(dbPath);
107
+ const db = new DatabaseSync(dbPath);
109
108
 
110
109
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
111
110
 
112
- function runMergeLock(action) {
111
+ // The merge lock is per-repo (merge-lock.sh fails closed without
112
+ // CREW_REPO — 2026-09-20: the old code never set it, so the lock could
113
+ // never be acquired). The retry protocol locks the task's own repo.
114
+ function runMergeLock(action, repoPath) {
113
115
  const lockScript = join(crewHome, "lib", "merge-lock.sh");
114
116
  try {
115
117
  execFileSync(lockScript, [action, taskId], {
116
- env: { ...process.env, CREW_HOME: crewHome },
118
+ env: { ...process.env, CREW_HOME: crewHome, CREW_REPO: repoPath },
117
119
  encoding: "utf8",
118
120
  timeout: 30000,
119
121
  });
@@ -126,7 +128,7 @@ function runMergeLock(action) {
126
128
  // --- Phase 1: prepare --------------------------------------------------------
127
129
  // Acquires the lock, re-checks HEAD, re-classifies, journals intent,
128
130
  // generates the diff. On success the lock is HELD and the output carries
129
- // the diff path/sha for the agent's artifact_edit child.
131
+ // the diff path/sha for the tick worker's direct artifact_edit call.
130
132
 
131
133
  if (phase === "prepare") {
132
134
  // Derive the unknown attempt (same derivation as scan-publish-unknown).
@@ -138,6 +140,7 @@ if (phase === "prepare") {
138
140
  const slug = project && project.deploy_slug;
139
141
  if (!slug) fail("no-slug", "project has no deploy_slug");
140
142
  const repoPath = (project && project.repo_path) || null;
143
+ if (!repoPath) fail("no-repo", "project has no repo_path — the retry protocol locks the task's repo");
141
144
 
142
145
  const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
143
146
  let entries = [];
@@ -174,7 +177,7 @@ if (phase === "prepare") {
174
177
  }
175
178
 
176
179
  // 2. Acquire the merge lock.
177
- const lock = runMergeLock("acquire");
180
+ const lock = runMergeLock("acquire", repoPath);
178
181
  if (!lock.ok) {
179
182
  process.stdout.write(JSON.stringify({
180
183
  ok: true, phase: "prepare", outcome: "lock-held",
@@ -188,7 +191,7 @@ if (phase === "prepare") {
188
191
  let lockHeld = true;
189
192
  const releaseLock = () => {
190
193
  if (lockHeld) {
191
- runMergeLock("release");
194
+ runMergeLock("release", repoPath);
192
195
  lockHeld = false;
193
196
  }
194
197
  };
@@ -205,15 +208,19 @@ if (phase === "prepare") {
205
208
  }
206
209
 
207
210
  const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
208
- // Find the trigger entry for the manifest baseline: the OLDEST submitted
209
- // entry by ts (D1, 2026-09-19; entry_kind cut 2026-09-20 — the retired
210
- // kind-preference could never change the bound anchor) — it binds
211
- // issued_at (falling back to its ts when the capture was unobserved),
212
- // the same anchor verify-publish.js and findTriggerEntry use.
211
+ // Find the trigger entry for the manifest baseline: the OLDEST
212
+ // tick-worker "submitted" entry by ts (D1, 2026-09-19; entry_kind cut
213
+ // 2026-09-20; issuer anchor 2026-09-20, one-party worker-owned publish —
214
+ // only the session-carrying tick worker performs issuance, so only its
215
+ // stamped writes bind; a workflow-written "submitted" without issuer is
216
+ // a phantom claim, never a baseline source) — it binds issued_at
217
+ // (falling back to its ts when the capture was unobserved), the same
218
+ // anchor verify-publish.js and findTriggerEntry use.
213
219
  // agent_id is null on the issuance entry (no receipt observed at trigger
214
220
  // time) — the baseline comes from manifest_before, never agent_id.
215
221
  const submitted = entries.filter((e) =>
216
- e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
222
+ e.outcome === "submitted" && e.issuer === "tick-worker" &&
223
+ e.commit === commit && (attempt == null || e.attempt === attempt));
217
224
  submitted.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
218
225
  const trigger = submitted[0] || null;
219
226
  const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
@@ -295,8 +302,8 @@ if (phase === "prepare") {
295
302
  fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
296
303
  }
297
304
 
298
- // Success: lock remains HELD. The agent spawns the artifact_edit child
299
- // with the diff, then calls phase 2 (complete) to finish.
305
+ // Success: lock remains HELD. The tick worker issues the edit directly
306
+ // in its own turn with the diff, then calls phase 2 (complete) to finish.
300
307
  // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
301
308
  // but phase 2 needs the exact commit/attempt/baseline/ledger path).
302
309
  const statePath = retryStatePath();
@@ -313,7 +320,7 @@ if (phase === "prepare") {
313
320
  task_id: taskId, commit, attempt,
314
321
  diff_path: diffPath, diff_sha256: diffMeta.sha256,
315
322
  slug,
316
- message: "lock held; spawn the artifact_edit child with the diff, then call phase 2 (complete)",
323
+ message: "lock held; issue the artifact_edit directly in this tick (the session-carrying tick worker owns issuance), then call phase 2 (complete)",
317
324
  }) + "\n");
318
325
  process.exit(0);
319
326
  } catch (e) {
@@ -339,12 +346,16 @@ if (phase === "complete") {
339
346
  fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
340
347
  }
341
348
  const state = JSON.parse(readFileSync(statePath, "utf8"));
342
- const { commit, attempt, slug, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
349
+ const { commit, attempt, slug, repoPath, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
350
+ // (2026-09-20, one-party publish) The phase-1 pin carries repoPath; the
351
+ // merge lock is repo-scoped, so a missing value is a hard failure — the
352
+ // finally's release must never run against an undefined repo.
353
+ if (!repoPath) fail("no-repo-in-state", "phase-1 state has no repoPath — cannot release the merge lock safely; the lock lease expires on its own");
343
354
 
344
355
  let lockHeld = true;
345
356
  const releaseLock = () => {
346
357
  if (lockHeld) {
347
- runMergeLock("release");
358
+ runMergeLock("release", repoPath);
348
359
  lockHeld = false;
349
360
  }
350
361
  };
@@ -356,7 +367,7 @@ if (phase === "complete") {
356
367
  `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
357
368
  process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
358
369
  } else {
359
- // The child ended after the edit call with no explicit refusal —
370
+ // The tick worker's direct call ended with no explicit refusal —
360
371
  // fire-and-forget, same shape as the first attempt.
361
372
  writePublishNote(db, taskId,
362
373
  `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
@@ -381,6 +392,7 @@ if (phase === "complete") {
381
392
  agent_id: null,
382
393
  applied_report: null,
383
394
  outcome: "submitted",
395
+ issuer: "tick-worker",
384
396
  manifest_before: retryBaseline,
385
397
  detail: "retry trigger re-issued after provably-dropped classification",
386
398
  };
@@ -326,26 +326,28 @@ if (head !== commit) {
326
326
  // 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
327
327
  // attempt is known — the original and retry attempts share a commit, and
328
328
  // positional selection is luck. Within one attempt, select the OLDEST
329
- // submitted entry (the trigger issuance; a receipt-bearing second "submitted"
330
- // may follow it — the oldest binds the trigger instant, the latest carries
331
- // the observed agent_id).
329
+ // tick-worker "submitted" entry (the worker's issuance; issuer anchor
330
+ // 2026-09-20). A workflow-written "submitted" (issuer absent, the
331
+ // pre-one-party shape) is a phantom claim — never a baseline source, never
332
+ // an anchor — so skip it silently, exactly as findTriggerEntry does.
332
333
  // D1 (2026-09-19; entry_kind cut 2026-09-20): the trigger instant is the
333
334
  // ISSUANCE instant, not the ledger-write instant — a slow ledger write must
334
335
  // not make a fresh build look stale (room #23 J1). The anchor is the OLDEST
335
- // submitted entry by ts and the bound instant is its issued_at (falling back
336
- // to its ts when the capture was unobserved). One anchor, one field — the
337
- // retired entry_kind preference filter could never change the bound anchor
338
- // (issuance and receipt entries carried the same captured issued_at), so the
339
- // oldest-by-ts selection is the whole mechanism.
336
+ // tick-worker "submitted" entry by ts and the bound instant is its issued_at
337
+ // (falling back to its ts when the capture was unobserved). One anchor, one
338
+ // field — the retired entry_kind preference filter could never change the
339
+ // bound anchor (issuance and receipt entries carried the same captured
340
+ // issued_at), so the oldest-by-ts selection is the whole mechanism.
340
341
  let triggerTs = null;
341
342
  let manifestBefore = null;
342
343
  try {
343
344
  const ledgerText = readFileSync(join(crewHome, ".publish-ledger", slug + ".jsonl"), "utf8");
344
345
  const entries = ledgerText.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
345
- // Collect all submitted entries matching (task_id, commit[, attempt]).
346
+ // Collect the tick-worker's issuance entries matching (task_id, commit[, attempt]).
346
347
  const candidates = entries.filter((e) =>
347
348
  e.task_id === taskId &&
348
349
  e.outcome === "submitted" &&
350
+ e.issuer === "tick-worker" &&
349
351
  e.commit === commit &&
350
352
  (attempt == null || e.attempt === attempt)
351
353
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -2,6 +2,15 @@
2
2
 
3
3
  You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher workflow and launch its claims autonomously.
4
4
 
5
+ ### Known blockers — read first, do not re-derive or re-report
6
+
7
+ The failures below are settled and recorded in the Gate 1 OODA state. The authoritative list lives at `~/workspace/goals/gate-1-onboarding-canary-passes/hidden_files/ooda/known-blockers.md` — if this copy ever disagrees with that file, the file wins. Their fixes are already queued in the main repo BUILD. If you observe one, log exactly one line — `known-blocker: <id> — <one-line observation>` — and move on. Do NOT write root-cause analysis and do NOT write "follow-up needed"; the follow-up is already owned.
8
+
9
+ - **Blocker 22 — wrong publication caller (settled 2026-09-20):** workflow children cannot call `artifact_edit` (the builder requires a parent-conversation session id they don't have). The 0.14.x trigger/observation path is dead by architecture; `submitted` writes from it are phantom. Fix in BUILD: one-party worker-owned artifact publication.
10
+ - **Blocker 23 — retry harness cannot access Crew state (settled 2026-09-20):** `lib/retry-publish.js` imported `better-sqlite3` (not shipped; the release uses `node:sqlite`) and targeted `crew.db` instead of the real `crew-state.db`. Every retry prepare returned `db-unavailable` and failed closed before any lock, edit, or journal write. Fixed in BUILD (0.14.5): ported to `node:sqlite` + `$CREW_HOME/crew-state.db`, the missing `CREW_REPO` for the merge lock supplied, and the trigger step converted from a spawned child to the session-carrying tick worker's direct `artifact_edit` call (blocker 22). The retry path is worker-owned issuance now; it is subsumed under one-party publish, not a separate queue.
11
+
12
+ (Maintainer note: this snapshot is refreshed from known-blockers.md at release time. The poller always treats the file as authoritative.)
13
+
5
14
  ### Steps
6
15
 
7
16
  0. **Check for platform workflow failures (opacity killer):** The platform records workflow run failures in `runtime.workflow_runs` — these are invisible in the crew DB unless you check. A workflow that dies on a fatal `agent()` error (e.g. "subagent bootstrap is no longer authorized") leaves its task stranded with no explanation.
@@ -39,7 +48,7 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
39
48
 
40
49
  4.4. **Unknown-recovery (docs/publish-unknown-recovery.md):** tasks parked with "Publish outcome unknown" are the receipt-less trigger gap (2026-09-18, blocker 15). The note is the state machine; deterministic code owns every transition; you are the ferry (scan → classifier → record / retry protocol). Run this BEFORE Step 4.5 so a re-triggered build's mirrored verification-requested is visible to the verification scan on a later tick. Log every list the scan returns.
41
50
  - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-publish-unknown`
42
- Returns `{ waiting: [...], due: [...], retry_due: [...], mirrored: [...], skipped: [...] }`. `waiting` = parked < 30m — leave alone, no claim. `mirrored`/`skipped` = already handled by code — log only, take no action.
51
+ Returns `{ waiting: [...], due: [...], intent: [...], retry_due: [...], mirrored: [...], skipped: [...], requeued: [...] }`. `waiting` = parked < 30m — leave alone, no claim. `mirrored`/`skipped` = already handled by code — log only, take no action. `requeued` = stale-base intents sent back to the workflow's Publish for re-preparation — log only, take no action. `intent` = claimed publish intents — handle in Step 4.4b.
43
52
  - For each entry in `due`: classify (code), then record the decision (code). Use the entry's fields verbatim.
44
53
  - `node {crewHome}/lib/classify-publish-absence.js --repo-path "<repo_path>" --commit <commit> --base <base> --slug "<slug>" --trigger-ts "<trigger_ts>" --park-ts "<park_ts>" --task-id <task_id> --manifest-before '<manifest_before JSON>'`
45
54
  Serialize the entry's `manifest_before` field to compact JSON for the `--manifest-before` value; when the entry's `manifest_before` is null, omit the flag (the classifier falls back to the time-based advance check and the Step 4.5 verifier fails closed without a baseline).
@@ -56,12 +65,8 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
56
65
  - Exit 0 with `{"ok": true, "outcome": "superseded"|"lock-held"|"not-dropped", ...}`: terminal for this tick. Do NOT proceed to the trigger. If `outcome` is `not-dropped`, the output carries the full `classification` — pass it to `record-retry-recheck`: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-retry-recheck --json '{"task_id": "<task_id>", "decision": <classification JSON>}'` and STOP for this task.
57
66
  - Exit non-zero: the command failed (lock released via try/finally). Log the stderr line and STOP — do NOT trigger.
58
67
 
59
- Trigger (agent-mediated — the ONLY prose-owned step):
60
- Spawn ONE child (subagent) whose first instruction loads the artifact namespace (`tool_search.load_tool_namespace` with paths `["artifact"]`), with this exact brief — the same shape as the workflow's first attempt:
61
- "The change to apply is the unified diff in the file \"<diff_path>\" (sha256 <diff_sha256>).
62
- 1. Verify the file: run sha256sum on it. If the printed hash is not exactly <diff_sha256>, STOP and end your turn — do not call artifact_edit.
63
- 2. Read the file's full content.
64
- 3. Call artifact_edit with slug \"<slug>\" and verbatim_request:
68
+ Trigger (tick-mediated — the ONLY prose-owned step):
69
+ Load the artifact namespace in YOUR OWN turn (`tool_search.load_tool_namespace` with paths `["artifact"]`) and call `artifact_edit` directly — NEVER spawn a child (blocker 22, room #24: children cannot reach artifact_edit). First verify the staged diff: `sha256sum "<diff_path>"` must print exactly `<diff_sha256>`; if it does not, STOP and end this task's retry (log loudly — the claim expires, the next tick re-claims; a mismatched diff never becomes an edit). Read the verified file's full content and call artifact_edit with slug `"<slug>"` and this verbatim_request:
65
70
  'Apply the following change to your source tree, then rebuild and deploy.
66
71
 
67
72
  UNIFIED DIFF (relative to your source tree):
@@ -76,22 +81,51 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
76
81
  - If any hunk does not apply cleanly, STOP and report the failure — do not improvise or skip hunks.
77
82
  - Do not make any other source changes.
78
83
  - After applying, rebuild and deploy.'
79
- Edit-request contract (read carefully):
80
- - Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — end your turn.
81
- - If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: end your turn with exactly one line and nothing else: ARTIFACT_EDIT_REFUSED: <the refusal text, one line>.
82
- - If artifact_edit is not available after the load, do NOT improvise — end your turn.
83
- - You do NOT call setprovenance, artifact_inspect, or post-deploy yourself.
84
- No report is needed: do not return JSON, do not summarize what you did, do not echo the diff. End your turn after the artifact_edit call."
85
- Wait for the child to finish. The child's final text is your only signal.
84
+ Edit-request contract:
85
+ - Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — proceed to the complete phase below.
86
+ - If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: record the first line of the refusal for the complete phase below.
87
+ - If artifact_edit is not available after the load, do NOT improvise — log it and proceed to the complete phase with `refused` and the refusal text "artifact tool namespace unavailable in tick".
86
88
 
87
89
  Phase 2 — complete (deterministic):
88
90
  `node {crewHome}/lib/retry-publish.js --crew-home {crewHome} --task-id <task_id> --phase complete --edit-outcome <accepted|refused> [--refusal-text "<first line>"]`
89
- - If the child's final text starts with `ARTIFACT_EDIT_REFUSED`: `--edit-outcome refused --refusal-text "<first line of the refusal>"`.
91
+ - If artifact_edit explicitly refused the edit: `--edit-outcome refused --refusal-text "<first line of the refusal>"`.
90
92
  - Otherwise: `--edit-outcome accepted`.
91
- - The command writes the outcome notes, appends the `-retry1` ledger entry (with the pre-retry `manifest_before` baseline), mirrors verification-requested, and RELEASES the lock in a finally block. Exit 0 means the protocol completed.
93
+ - The command writes the outcome notes, appends the `-retry1` ledger entry (issuer `tick-worker`, with the pre-retry `manifest_before` baseline), mirrors verification-requested, and RELEASES the lock in a finally block. Exit 0 means the protocol completed.
92
94
 
93
95
  - Crash rules (fail closed, never re-trigger blind): if this tick dies anywhere in the retry protocol, the note journal is the recovery state. `publish: retry-intended` without `retry-issued` → the next scan mirrors `verification-requested` (never re-triggers). `publish: retry-issued` without a mirrored request → the next scan mirrors it. A leaked merge lock expires after 600s. The next tick's scan never emits `retry_due` for a task with `retry-issued` in history — exactly one retry, enforced from notes, not from the ledger.
94
96
 
97
+ 4.4b. **One-party intent issuance (2026-09-20, blocker 22):** the workflow parks at Publish with a `publish: publish-requested` note and a checksummed `publish-intent` ledger entry; issuance belongs to YOU, the session-carrying tick worker, calling `artifact_edit` directly in this turn. The Step 4.4 scan returns the `intent: [...]` bucket — each entry is claimed for this tick (1-hour lease) and carries `task_id`, `commit`, `attempt`, `slug`, `repo_path`, `base`, `diff_path`, `diff_sha256`, `manifest_before`, `claim_expiry`, and `reclaimed`.
98
+ - For each intent entry:
99
+ 1. **Verify the staged diff (code):** `sha256sum "<diff_path>"` must print exactly `diff_sha256`. If the file is missing, regenerate deterministically and re-verify:
100
+ `node {crewHome}/lib/compute-publish-diff.js --repo-path "<repo_path>" --base <base> --commit <commit> --out "<diff_path>"`
101
+ then re-run the sha256sum. The `--commit` flag is honored: the script diffs `base..<commit>` (never an implicit HEAD) and reports that target as `commit`. If the regeneration exits non-zero, or the hash still does not match after regeneration, the diff is UNISSUABLE — record it terminally (code, never prose):
102
+ `node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-intent-unissuable --json '{"task_id": "<task_id>", "claim_expiry": "<claim_expiry>", "reason": "<missing-staged-diff|diff-unregenerable|hash-mismatch-after-regeneration>"}'`
103
+ (copy `claim_expiry` verbatim from the scan entry — it is the compare-and-swap). This writes the terminal `publish: publish-unissuable` note and an issuer-stamped ledger entry — parked for human attention. Never re-claim, never loop on an hourly reclaim; a diff that cannot be checksummed never becomes an edit.
104
+ 2. **Freshness check on a re-claimed intent (code, never prose):** when the entry's `reclaimed` is true, a previous tick died mid-issuance and may have issued the edit without recording it. Run the deterministic freshness helper — it reads the on-disk artifact manifest at `~/workspace/ts-spaces/<slug>/.space-build/manifest.json` (the same path the verifier uses) and compares `content_sha256`/`built_at` against the entry's `manifest_before`:
105
+ `node {crewHome}/lib/check-intent-freshness.js --slug "<slug>" --manifest-before '<manifest_before JSON>'`
106
+ (copy the entry's `manifest_before` verbatim as JSON; use the entry's `slug` verbatim). The helper prints exactly one JSON object: `{ok:true, advanced:<true|false>, baseline:{...}, current:{...}}`. If `advanced` is true → the edit is already live: record `recovered` in step 4 below (passing the helper's stdout JSON as `freshness_evidence`) and NEVER re-issue. If `advanced` is false → the manifest still shows the pre-edit state: proceed to issuance. If the helper exits non-zero or prints `ok:false` → the freshness question is unanswerable: record NOTHING, log loudly in one line, and let the claim expire — the next scan re-claims. Never decide this from prose, from `artifact_status`, or from an inspector's summary.
107
+ 3. **Issue the edit directly — in this turn, never in a child.** Blocker 22 is proven: workflow children cannot reach `artifact_edit` (room #24's four journeys died on exactly this). Load the artifact namespace in your own turn and call `artifact_edit` with the entry's `slug` and this verbatim_request (paste the verified diff file's full content verbatim):
108
+ 'Apply the following change to your source tree, then rebuild and deploy.
109
+
110
+ UNIFIED DIFF (relative to your source tree):
111
+ ```diff
112
+ <the full content of the verified file, pasted verbatim>
113
+ ```
114
+
115
+ Rules:
116
+ - For each file in the diff, apply its hunks to the same path in your source tree (use git apply or equivalent).
117
+ - For a new file (--- /dev/null), create it with the added (+) lines as its full content.
118
+ - For a deleted file (+++ /dev/null), delete it.
119
+ - If any hunk does not apply cleanly, STOP and report the failure — do not improvise or skip hunks.
120
+ - Do not make any other source changes.
121
+ - After applying, rebuild and deploy.'
122
+ Call artifact_edit exactly once. Never retry it yourself. If the call is explicitly refused, note the first line of the refusal text.
123
+ `accepted` means exactly one thing: the direct `artifact_edit` call completed without error and without an explicit refusal. Tool unavailable (the artifact namespace would not load), a timeout, a transport error, or any otherwise ambiguous result is NOT accepted and NOT refused — it is inconclusive: record NOTHING for this task, log the ambiguity in one line, and let the claim expire; the next scan re-claims and step 2's freshness check decides. An unrecorded outcome is the only honest record of an unknown one.
124
+ 4. **Record the outcome (code):**
125
+ `node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-intent-issuance --json '{"task_id": "<task_id>", "claim_expiry": "<claim_expiry>", "edit_outcome": "<accepted|refused|recovered>", "refusal_text": "<first line>"}'`
126
+ (omit `refusal_text` unless refused). For `recovered`, pass `freshness_evidence` with the step-2 helper's stdout JSON verbatim — the command refuses a recovery without machine evidence. Copy `claim_expiry` verbatim from the scan entry — it is the compare-and-swap. If the command returns `recorded: false`, log the reason and stop: another tick owns the task. `accepted`/`recovered` writes the issuer-stamped `submitted` ledger entry and mirrors `publish: verification-requested` (the Step 4.5 scan claims it on this or a later tick); `refused` writes `rejected` and the terminal `publish: publish-refused` note. There is no outcome for an inconclusive call (step 3) — do not call record-intent-issuance at all when the edit's fate is unknown.
127
+ - Crash rules (fail closed, never blind re-issue): if this tick dies between the artifact_edit call and the record step, the next scan re-claims with `reclaimed: true` and step 2's manifest-freshness check decides between `recovered` and a fresh issuance. The issuance contract is at-most-one RECORDED issuance per attempt: a duplicate physical issuance remains possible across a lease expiry when the first attempt's fate went unrecorded (the dead tick issued but died before recording), but it is content-identical — the same checksummed diff — and the verifier's content check bounds it. If the tick dies before issuing, the claim simply expires and the next tick claims it.
128
+
95
129
  4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects, reads back, and certifies; you are only the ferry between the deterministic steps (scan → sensor → verifier).
96
130
  - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-verification-pending`
97
131
  This atomically claims each verification-pending task (1-hour lease, so a second tick cannot double-verify) and reconciles verified-but-still-parked tasks to `in_progress`. It returns `{ to_verify: [...], reconciled: [...] }`. Log both lists. If the scan exits 2 (e.g. the active release cannot be resolved), log the error loudly and continue — do NOT work around it.