muse-crew 0.14.3 → 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.
Files changed (46) hide show
  1. package/AGENTS.md +2 -2
  2. package/docs/decisions/AGENTS.md +1 -0
  3. package/docs/decisions/publish-path.md +96 -3
  4. package/docs/guide.md +2 -2
  5. package/docs/publish-unknown-recovery.md +10 -3
  6. package/docs/publish-verification.md +126 -62
  7. package/docs/release-integrity.md +62 -0
  8. package/docs/reviews/critic-0144.md +83 -0
  9. package/docs/reviews/critic-0145.md +106 -0
  10. package/lib/AGENTS.md +12 -4
  11. package/lib/advance-publish-base.js +8 -0
  12. package/lib/append-ooda-step.js +12 -3
  13. package/lib/build-readback-request.js +10 -0
  14. package/lib/build-registry.js +8 -3
  15. package/lib/check-intent-freshness.js +101 -0
  16. package/lib/classify-publish-absence.js +11 -0
  17. package/lib/classify-surface.js +12 -2
  18. package/lib/commit-scaffold.js +22 -6
  19. package/lib/compose-evidence-caption.js +15 -4
  20. package/lib/compute-publish-diff.js +41 -11
  21. package/lib/crew-api.js +478 -26
  22. package/lib/crew-release.sh +233 -1
  23. package/lib/gitignore.js +23 -5
  24. package/lib/package.json +1 -0
  25. package/lib/publish-note-vocabulary.js +64 -0
  26. package/lib/read-ooda-verdict.js +11 -3
  27. package/lib/readback-disk.js +9 -0
  28. package/lib/render-html.js +17 -7
  29. package/lib/repo-orchestration.js +21 -5
  30. package/lib/retry-publish.js +116 -72
  31. package/lib/sample-project.js +22 -6
  32. package/lib/scaffold-crew.js +11 -2
  33. package/lib/see-act.js +17 -8
  34. package/lib/serve-artifact.js +12 -6
  35. package/lib/setup-project-repo.js +26 -7
  36. package/lib/update-watch.js +34 -17
  37. package/lib/ux-doctrine.js +31 -6
  38. package/lib/verify-publish.js +44 -6
  39. package/lib/write-ooda-verdict.js +12 -3
  40. package/package.json +1 -1
  41. package/seed/cron-body-template.md +50 -16
  42. package/workflows/bugfix.js +110 -643
  43. package/workflows/chore.js +110 -643
  44. package/workflows/crew-dispatch.js +36 -0
  45. package/workflows/standard.js +118 -633
  46. package/workflows/upgrade.js +4 -2
package/lib/crew-api.js CHANGED
@@ -30,6 +30,13 @@ import { execFileSync } from "node:child_process";
30
30
  import { DatabaseSync } from "node:sqlite";
31
31
  import { homedir } from "node:os";
32
32
  import { classifySurface } from "./classify-surface.js";
33
+ // Terminal-note vocabulary (D7, 2026-09-19): the scan's terminal branch
34
+ // consults this closed registry instead of an inline pattern list, so a
35
+ // recognized terminal note is skipped as terminal with its meaning —
36
+ // never as unrecognized. Pin-adjacent: every runtime-relative dependency
37
+ // of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
38
+ // mechanically verified by tests/pin-closure.test.js.
39
+ import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS } from "./publish-note-vocabulary.js";
33
40
 
34
41
  // ---------------------------------------------------------------------------
35
42
  // Errors
@@ -1704,10 +1711,10 @@ function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false
1704
1711
  }
1705
1712
 
1706
1713
  // The unknown-recovery state machine reads two note shapes: the machine's
1707
- // own "publish: <transition>" notes, and the workflow's initial
1714
+ // own "publish: <transition>" notes, and the workflow's legacy (pre-2026-09-20)
1708
1715
  // "Publish outcome unknown …" park note — which carries no "publish:"
1709
- // marker (composeUnattributedParkReason), so the prefilter must match it
1710
- // explicitly. LIKE is ASCII case-insensitive in SQLite.
1716
+ // marker, so the prefilter must match it explicitly. LIKE is ASCII
1717
+ // case-insensitive in SQLite.
1711
1718
  const PUBLISH_NOTE_PREFILTER =
1712
1719
  `(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
1713
1720
 
@@ -1721,22 +1728,35 @@ function latestPublishNote(db, taskId) {
1721
1728
  ).get(taskId) || null;
1722
1729
  }
1723
1730
 
1724
- // The trigger anchor for an unknown attempt: the OLDEST "submitted" ledger
1725
- // entry for the same attempt (the trigger issuance). Oldest binds the trigger
1726
- // instant — verify-publish.js and the decision doc bind oldest; binding newest
1727
- // misclassifies builds that complete between issuance and receipt (A2/R2/O4).
1728
- // Returns the entry or null. A null means the trigger instant is unknowable
1729
- // (or the trigger was never issued) — classification cannot run; the task
1730
- // stays parked for human attention. Fail closed, never guess the anchor.
1731
+ // The trigger anchor for an unknown attempt: the ISSUER-WRITTEN "submitted"
1732
+ // ledger entry for the same attempt (2026-09-20, one-party worker-owned
1733
+ // publish: only the session-carrying tick worker performs issuance, and it
1734
+ // stamps its writes issuer="tick-worker"). The workflow never issues and
1735
+ // never writes "submitted" — a workflow-written "submitted" (issuer absent,
1736
+ // the pre-one-party shape) is a phantom claim and must NOT bind. Within one
1737
+ // attempt the anchor is the OLDEST by ts (D1: the bound instant is
1738
+ // issued_at || ts, the same fallback verify-publish.js uses).
1739
+ // Returns the entry or null. A null means the trigger
1740
+ // instant is unknowable (or the trigger was never issued) — classification
1741
+ // cannot run; the task stays parked for human attention. Fail closed, never
1742
+ // guess the anchor.
1731
1743
  function findTriggerEntry(entries, commit, attempt) {
1744
+ // D1 (2026-09-19; entry_kind cut 2026-09-20; issuer anchor 2026-09-20):
1745
+ // the anchor is the OLDEST tick-worker "submitted" entry by ts and the
1746
+ // bound instant is its issued_at || ts — the same fallback
1747
+ // verify-publish.js uses. A "submitted" without issuer (the retired
1748
+ // workflow write) is not an issuance and never binds.
1749
+ const matches = [];
1732
1750
  for (let i = 0; i < entries.length; i++) {
1733
1751
  const e = entries[i];
1734
- if (e.outcome === "submitted" && e.commit === commit &&
1752
+ if (e.outcome === "submitted" && e.issuer === "tick-worker" &&
1753
+ e.commit === commit &&
1735
1754
  (e.attempt || null) === (attempt || null)) {
1736
- return e;
1755
+ matches.push(e);
1737
1756
  }
1738
1757
  }
1739
- return null;
1758
+ matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
1759
+ return matches[0] || null;
1740
1760
  }
1741
1761
 
1742
1762
  // The unknown ledger entry's detail says whether the trigger went out.
@@ -1887,10 +1907,16 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
1887
1907
  // "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
1888
1908
  // "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
1889
1909
  // verification-requested
1890
- // "publish: ambiguous …" / "publish: → terminal; ignored
1891
- // retry-superseded" / "publish:
1892
- // retry-refused" / "publish: verified" /
1893
- // "publish: superseded"
1910
+ // "publish: publish-requested" (one-party → atomic 1h claim → intent bucket: the tick issues
1911
+ // intent, 2026-09-20 blocker 22) the edit directly in its own turn (no child).
1912
+ // An issuer-written "submitted" already present →
1913
+ // mirror verification-requested, NEVER re-issue.
1914
+ // "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it); expired:
1915
+ // re-claim → intent (the tick runs the
1916
+ // manifest-freshness check first)
1917
+ // terminal notes (the closed registry in → terminal; skipped with the
1918
+ // lib/publish-note-vocabulary.js — the reason named, never
1919
+ // scan names the recognized note) "unrecognized"
1894
1920
  // "publish: verification-requested" → already routed; ignored (the verification scan owns it)
1895
1921
  //
1896
1922
  // Unknown parks whose trigger was never issued (preflight/toolcheck
@@ -1907,9 +1933,11 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1907
1933
 
1908
1934
  const waiting = [];
1909
1935
  const due = [];
1936
+ const intent = [];
1910
1937
  const retryDue = [];
1911
1938
  const mirrored = [];
1912
1939
  const skipped = [];
1940
+ const requeued = []; // F-A4: stale-base intents re-queued to the workflow's Publish
1913
1941
 
1914
1942
  const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
1915
1943
 
@@ -1925,7 +1953,9 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1925
1953
  return;
1926
1954
  }
1927
1955
  const trigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
1928
- if (!trigger || !trigger.ts) {
1956
+ // D1 (2026-09-19): the anchor instant is the trigger-issuance instant
1957
+ // (issued_at), not the ledger-write instant (ts) — issued_at || ts.
1958
+ if (!trigger || !(trigger.issued_at || trigger.ts)) {
1929
1959
  skip(taskId, "no-trigger-anchor",
1930
1960
  "no submitted ledger entry for this attempt — the trigger instant is unknowable; stays parked for human attention");
1931
1961
  return;
@@ -1959,7 +1989,7 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1959
1989
  }
1960
1990
  due.push({
1961
1991
  task_id: taskId, commit: derived.commit, base,
1962
- trigger_ts: trigger.ts, park_ts: parkNote.timestamp,
1992
+ trigger_ts: trigger.issued_at || trigger.ts, park_ts: parkNote.timestamp,
1963
1993
  slug: derived.slug, repo_path: derived.repoPath,
1964
1994
  project_id: derived.projectId, claim_expiry: expiry,
1965
1995
  ledger_path: derived.ledgerPath,
@@ -1972,6 +2002,160 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1972
2002
  });
1973
2003
  };
1974
2004
 
2005
+ // One-party publish intent (2026-09-20, blocker 22): the workflow parks at
2006
+ // intent — the merged change is staged as a checksummed diff and issuance
2007
+ // belongs to the session-carrying tick worker (no child — blocker 22
2008
+ // proved children cannot reach artifact_edit). The intent ledger entry
2009
+ // (outcome "publish-intent", issuer "workflow") is the durable anchor: the
2010
+ // tick verifies the staged diff's sha256 against it and regenerates from
2011
+ // base..commit when the file is missing. reclaimed=true when a previous
2012
+ // tick's claim expired: the tick runs the manifest-freshness check before
2013
+ // issuing, because the dead tick may have issued and died before
2014
+ // recording it — a re-claim never blindly re-issues.
2015
+ const claimForIntent = (taskId, intentNote, reclaimed, anchorNote) => {
2016
+ const m = intentNote.message.match(/publish: publish-requested\s+([0-9a-f]{40})\s+(\S+)/);
2017
+ if (!m) {
2018
+ skip(taskId, "intent-note-unparseable",
2019
+ "the publish-requested note carries no commit hash — stays parked for human attention");
2020
+ return;
2021
+ }
2022
+ const commit = m[1];
2023
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
2024
+ if (!task || task.state !== "parked") {
2025
+ skip(taskId, "not-parked", `task is not parked (state=${task && task.state}) — the intent claim requires a parked task`);
2026
+ return;
2027
+ }
2028
+ const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
2029
+ const slug = project && project.deploy_slug;
2030
+ if (!slug) {
2031
+ skip(taskId, "no-deploy-slug", "project has no deploy_slug — stays parked for human attention");
2032
+ return;
2033
+ }
2034
+ // (2026-09-20, critic-0145 F-R5) A null repo_path fails closed at
2035
+ // emission: the tick can neither regenerate nor verify the diff. The
2036
+ // task stays parked for human attention — no claim, no tick work.
2037
+ const repoPath = project && project.repo_path;
2038
+ if (!repoPath) {
2039
+ skip(taskId, "no-repo-path",
2040
+ "project has no repo_path — the intent diff cannot be regenerated or verified; fails closed at emission; stays parked for human attention");
2041
+ return;
2042
+ }
2043
+ const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
2044
+ let entries = [];
2045
+ try {
2046
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
2047
+ .filter((l) => l.trim().length > 0)
2048
+ .map((l) => JSON.parse(l))
2049
+ .filter((e) => e.task_id === taskId);
2050
+ } catch (e) {
2051
+ skip(taskId, "ledger-unreadable", `cannot read publish ledger: ${e.message} — stays parked for human attention`);
2052
+ return;
2053
+ }
2054
+ // Latest intent wins: a reworked task re-parks a fresh intent.
2055
+ const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
2056
+ if (!intentEntry) {
2057
+ skip(taskId, "no-intent-entry",
2058
+ "no publish-intent ledger entry for this task — the intent anchor is missing; stays parked for human attention");
2059
+ return;
2060
+ }
2061
+ if (intentEntry.commit !== commit) {
2062
+ skip(taskId, "intent-commit-mismatch",
2063
+ `intent ledger entry commits ${intentEntry.commit} but the park note names ${commit} — stays parked for human attention`);
2064
+ return;
2065
+ }
2066
+ if (typeof intentEntry.diff_path !== "string" || !intentEntry.diff_path ||
2067
+ !/^[0-9a-f]{64}$/.test(intentEntry.diff_sha256 || "")) {
2068
+ skip(taskId, "intent-diff-unusable",
2069
+ "the intent entry carries no usable diff_path/diff_sha256 — stays parked for human attention");
2070
+ return;
2071
+ }
2072
+ // The tick died after issuing but before mirroring: an issuer-written
2073
+ // "submitted" for this commit+attempt exists. NEVER re-issue — mirror
2074
+ // the missing verification request and let content verification be the
2075
+ // arbiter (the same rule as the retry-intended crash window).
2076
+ const issued = entries.some((e) =>
2077
+ e.outcome === "submitted" && e.issuer === "tick-worker" &&
2078
+ e.commit === commit && (e.attempt || null) === (intentEntry.attempt || null));
2079
+ if (issued) {
2080
+ writePublishNote(db, taskId,
2081
+ `publish: verification-requested ${commit} attempt=${intentEntry.attempt} — mirrored by scan-publish-unknown: a tick-worker issuance entry exists without a mirrored request (the tick died between the writes). Waiting on the manual read-back in docs/publish-verification.md.`);
2082
+ mirrored.push({ task_id: taskId, commit, attempt: intentEntry.attempt || null, not_before: null, reason: "intent-issued-without-mirror" });
2083
+ return;
2084
+ }
2085
+ // Freshness CAS: the latest publish: note must still be the note we
2086
+ // branched on — for a first claim that is the intent note; for a
2087
+ // re-claim it is the expired claim note.
2088
+ const fresh = latestPublishNote(db, taskId);
2089
+ if (!fresh || !anchorNote || fresh.message !== anchorNote.message) { skip(taskId, "raced"); return; }
2090
+ const manifestBefore = (intentEntry.manifest_before && typeof intentEntry.manifest_before === "object")
2091
+ ? intentEntry.manifest_before : null;
2092
+ const claimedAt = new Date(nowMs).toISOString();
2093
+ const expiry = new Date(nowMs + claimLeaseMs).toISOString();
2094
+ writePublishNote(db, taskId,
2095
+ `publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit}; claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
2096
+ claimedAt);
2097
+ // The classifier-base convention: last verified publish, or the empty
2098
+ // tree on a first publish — the tick needs it to regenerate the diff
2099
+ // byte-identically when the staged file is missing. A query error is
2100
+ // not "no provenance" — fail closed.
2101
+ let base;
2102
+ try {
2103
+ const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
2104
+ if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
2105
+ base = prov.provenance_source_commit;
2106
+ } else {
2107
+ base = EMPTY_TREE; // truly no provenance value: first publish
2108
+ }
2109
+ } catch (e) {
2110
+ skip(taskId, "provenance-query-failed",
2111
+ `provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
2112
+ return;
2113
+ }
2114
+ // (2026-09-20, critic-0145 F-A4) The intent's recorded base must match
2115
+ // stamped provenance: another publish may have landed after the intent
2116
+ // was staged. A stale base means the staged diff is against a superseded
2117
+ // base — re-queue the task (next_phase routes it straight back to the
2118
+ // workflow's Publish, which re-prepares the intent against the new base);
2119
+ // never issue the stale diff. Legacy intents with no recorded base
2120
+ // re-queue exactly once, then re-prepare with a base.
2121
+ const intentBase = (typeof intentEntry.base === "string" && /^[0-9a-f]{40}$/.test(intentEntry.base))
2122
+ ? intentEntry.base : null;
2123
+ if (!intentBase || intentBase !== base) {
2124
+ writePublishNote(db, taskId,
2125
+ `publish: publish-base-stale ${commit} — the intent's staged diff was computed against base ` +
2126
+ `${intentBase ? intentBase.slice(0, 7) : "(none recorded)"} but stamped provenance now reads ` +
2127
+ `${base.slice(0, 7)}; issuing the stale diff risks a spurious content-mismatch. Re-queued: ` +
2128
+ `the workflow's Publish re-prepares the intent against the new base.`,
2129
+ claimedAt);
2130
+ db.prepare("UPDATE tasks SET next_phase = 'Publish', state = 'in_progress', updated_at = ? WHERE id = ?")
2131
+ .run(now(), taskId);
2132
+ requeued.push({ task_id: taskId, intent_base: intentBase, provenance_base: base });
2133
+ return;
2134
+ }
2135
+ // (2026-09-20, critic-0145 F-R3) Re-claim with no manifest baseline: the
2136
+ // tick could neither prove a dead tick's issuance nor safely re-issue —
2137
+ // fail closed with a terminal human-attention park; never blind-issue,
2138
+ // never loop indefinitely.
2139
+ if (reclaimed && !manifestBefore) {
2140
+ writePublishNote(db, taskId,
2141
+ `publish: intent-unverifiable ${commit} — one-party intent terminal: this re-claim arrived with ` +
2142
+ `no pre-issuance manifest baseline, so the freshness comparison is unperformable; the tick can ` +
2143
+ `neither prove a dead tick's issuance nor safely re-issue. Parked for human attention.`,
2144
+ claimedAt);
2145
+ skip(taskId, "terminal",
2146
+ "re-claimed intent with a null manifest baseline — 'publish: intent-unverifiable'; parked for human attention");
2147
+ return;
2148
+ }
2149
+ intent.push({
2150
+ task_id: taskId, commit, attempt: intentEntry.attempt || null,
2151
+ slug, repo_path: repoPath,
2152
+ project_id: project.id, ledger_path: ledgerPath, base,
2153
+ diff_path: intentEntry.diff_path, diff_sha256: intentEntry.diff_sha256,
2154
+ manifest_before: manifestBefore,
2155
+ claimed_at: claimedAt, claim_expiry: expiry, reclaimed,
2156
+ });
2157
+ };
2158
+
1975
2159
  const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
1976
2160
 
1977
2161
  db.exec("BEGIN");
@@ -1989,11 +2173,13 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1989
2173
  const has = (s) => msg.includes(s);
1990
2174
  const historyHas = (s) => notes.some((n) => n.message.includes(s));
1991
2175
 
1992
- // Terminal states — the machine never revisits these.
1993
- if (has("publish: ambiguous") || has("publish: retry-superseded") ||
1994
- has("publish: retry-refused") || has("publish: verified") ||
1995
- has("publish: superseded")) {
1996
- skip(taskId, "terminal");
2176
+ // Terminal states — the machine never revisits these. The vocabulary is
2177
+ // explicit state (lib/publish-note-vocabulary.js); a recognized terminal
2178
+ // note is skipped as terminal with its meaning, never as unrecognized.
2179
+ const terminalNote = matchTerminalPublishNote(msg);
2180
+ if (terminalNote) {
2181
+ skip(taskId, "terminal",
2182
+ `recognized terminal note '${terminalNote}' — ${TERMINAL_NOTE_MEANINGS[terminalNote]}`);
1997
2183
  continue;
1998
2184
  }
1999
2185
  // Already routed into the verification pipeline — the verification
@@ -2065,12 +2251,31 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2065
2251
  task_id: taskId, commit: derived.commit, attempt: derived.attempt,
2066
2252
  slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
2067
2253
  ledger_path: derived.ledgerPath, base: retryBase,
2068
- trigger_ts: (retryTrigger && retryTrigger.ts) || null,
2254
+ trigger_ts: (retryTrigger && (retryTrigger.issued_at || retryTrigger.ts)) || null,
2069
2255
  manifest_before: (retryTrigger && retryTrigger.manifest_before &&
2070
2256
  typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
2071
2257
  });
2072
2258
  continue;
2073
2259
  }
2260
+ // One-party publish intent (2026-09-20, blocker 22): the workflow
2261
+ // parked at intent; the session-carrying tick worker issues the edit
2262
+ // directly from the intent bucket — no child, no trigger.
2263
+ if (has("publish: publish-intent-claimed")) {
2264
+ const m = msg.match(/publish: publish-intent-claimed\s+(\S+)/);
2265
+ const expiryMs = m ? Date.parse(m[1]) : NaN;
2266
+ if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
2267
+ // Expired claim: the tick died mid-issuance. Re-derive from the
2268
+ // original intent note and re-claim — the re-claiming tick runs the
2269
+ // manifest-freshness check before issuing, never blindly re-issues.
2270
+ const intentNote = notes.find((n) => n.message.includes("publish: publish-requested"));
2271
+ if (!intentNote) { skip(taskId, "no-intent-note", "expired intent claim with no publish-requested note in history"); continue; }
2272
+ claimForIntent(taskId, intentNote, true, latest);
2273
+ continue;
2274
+ }
2275
+ if (has("publish: publish-requested")) {
2276
+ claimForIntent(taskId, latest, false, latest);
2277
+ continue;
2278
+ }
2074
2279
  // A previous tick claimed this task for classification.
2075
2280
  // A previous tick claimed this task for classification.
2076
2281
  if (has("publish: unknown-recovery-claimed")) {
@@ -2106,7 +2311,243 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2106
2311
  db.exec("COMMIT");
2107
2312
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2108
2313
 
2109
- return { waiting, due, retry_due: retryDue, mirrored, skipped };
2314
+ return { waiting, due, intent, retry_due: retryDue, mirrored, skipped, requeued };
2315
+ };
2316
+
2317
+ // One-party intent issuance record (2026-09-20, blocker 22). The
2318
+ // session-carrying tick worker issues the artifact_edit DIRECTLY in its own
2319
+ // turn (no child — blocker 22 proved children cannot reach artifact_edit)
2320
+ // and ferries the outcome here. Code decides: every note and ledger line
2321
+ // the outcome implies is written here, atomically, never free-formed by
2322
+ // the tick.
2323
+ //
2324
+ // Args: { task_id, claim_expiry, edit_outcome, refusal_text?, freshness_evidence? } where
2325
+ // edit_outcome is "accepted" (the direct artifact_edit call completed
2326
+ // without error and without an explicit refusal — and ONLY then; tool
2327
+ // unavailable, timeout, or any ambiguous result is not accepted and is
2328
+ // never recorded here, the tick logs it and lets the claim expire),
2329
+ // "recovered" (a re-claiming tick ran lib/check-intent-freshness.js and the
2330
+ // manifest advanced past the intent's baseline — the dead tick issued and
2331
+ // died before recording it), or "refused" (the platform explicitly refused
2332
+ // the edit).
2333
+ //
2334
+ // The claim_expiry is a compare-and-swap: the record lands only while the
2335
+ // latest publish: note is still this tick's intent claim. A stale tick (its
2336
+ // claim expired and a newer tick re-claimed) gets { recorded: false } and
2337
+ // writes nothing.
2338
+ //
2339
+ // Routing:
2340
+ // accepted / recovered → issuer-written "submitted" ledger entry
2341
+ // (issuer "tick-worker"; issued_at = the command's clock — the upper
2342
+ // bound on issuance — for "accepted"; for "recovered", the dead tick's
2343
+ // original claim time — the earliest matching publish-intent-claimed
2344
+ // note for this commit — which lower-bounds the unobserved issuance
2345
+ // instant, 2026-09-20 critic-0145 F-O1) + the tick's freshness evidence
2346
+ // recorded on the "recovered" entry + mirrored
2347
+ // `publish: verification-requested` (the verification scan claims it).
2348
+ // refused → issuer-written "rejected" ledger entry carrying
2349
+ // the platform's refusal text + terminal `publish: publish-refused`
2350
+ // (parked for human attention — no re-issue, no park-unknown).
2351
+ //
2352
+ // A "recovered" record without machine freshness evidence is refused: pass
2353
+ // freshness_evidence (the check-intent-freshness.js JSON, advanced:true) —
2354
+ // an unproven recovery is not recorded (2026-09-20 critic-0145 F-O3/F-R2).
2355
+ commands["record-intent-issuance"] = (db, args, ctx) => {
2356
+ const taskId = args.task_id;
2357
+ const claimExpiry = args.claim_expiry;
2358
+ const editOutcome = args.edit_outcome;
2359
+ if (!taskId) throw usageError("task_id is required.");
2360
+ if (!claimExpiry) throw usageError("claim_expiry is required.");
2361
+ if (!["accepted", "recovered", "refused"].includes(editOutcome)) {
2362
+ throw usageError(`unknown edit outcome: ${editOutcome}.`);
2363
+ }
2364
+
2365
+ db.exec("BEGIN");
2366
+ try {
2367
+ // CAS: the latest publish: note must still be this tick's claim.
2368
+ const latest = latestPublishNote(db, taskId);
2369
+ if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
2370
+ !latest.message.includes(claimExpiry)) {
2371
+ db.exec("ROLLBACK");
2372
+ return { recorded: false, reason: "claim no longer current — another tick owns this task" };
2373
+ }
2374
+ // The intent anchor: the workflow's issuer-written intent entry.
2375
+ const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
2376
+ const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
2377
+ const slug = project && project.deploy_slug;
2378
+ if (!slug) {
2379
+ db.exec("ROLLBACK");
2380
+ return { recorded: false, reason: "project has no deploy_slug" };
2381
+ }
2382
+ const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
2383
+ let entries = [];
2384
+ try {
2385
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
2386
+ .filter((l) => l.trim().length > 0)
2387
+ .map((l) => JSON.parse(l))
2388
+ .filter((e) => e.task_id === taskId);
2389
+ } catch (e) {
2390
+ db.exec("ROLLBACK");
2391
+ return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
2392
+ }
2393
+ const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
2394
+ if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
2395
+ db.exec("ROLLBACK");
2396
+ return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
2397
+ }
2398
+ const ts = now();
2399
+ const commit = intentEntry.commit;
2400
+ const attempt = intentEntry.attempt || null;
2401
+ // (2026-09-20, critic-0145 F-O1) A recovered issuance's instant is
2402
+ // unobserved, but it is LOWER-BOUNDED by the dead tick's original
2403
+ // claim: the earliest publish-intent-claimed note for this commit (not
2404
+ // the reclaimer's later claim). Binding issued_at to that time keeps
2405
+ // the verifier's staleness check honest — the old null fell back to
2406
+ // the recovery record's later timestamp and made a genuine
2407
+ // already-completed build look stale.
2408
+ let recoveredIssuedAt = null;
2409
+ if (editOutcome === "recovered") {
2410
+ const deadClaim = db.prepare(
2411
+ `SELECT timestamp FROM events
2412
+ WHERE task_id = ? AND type = 'note' AND message LIKE '%publish: publish-intent-claimed%'
2413
+ AND message LIKE ?
2414
+ ORDER BY timestamp ASC LIMIT 1`
2415
+ ).get(taskId, `%${commit}%`);
2416
+ recoveredIssuedAt = (deadClaim && deadClaim.timestamp) || null;
2417
+ }
2418
+ // (2026-09-20, critic-0145 F-O3/F-R2) A recovery must carry machine
2419
+ // evidence: the check-intent-freshness.js JSON with advanced:true. An
2420
+ // unproven recovery is not recorded — the command fails the call so the
2421
+ // tick cannot write an honest-looking "recovered" on prose.
2422
+ let freshnessEvidence = null;
2423
+ if (editOutcome === "recovered") {
2424
+ const ev = args.freshness_evidence;
2425
+ const evObj = typeof ev === "string" ? (() => { try { return JSON.parse(ev); } catch { return null; } })() : ev;
2426
+ if (!evObj || typeof evObj !== "object" || evObj.ok !== true || evObj.advanced !== true) {
2427
+ throw usageError("recovered requires freshness_evidence — the check-intent-freshness.js JSON with advanced:true.");
2428
+ }
2429
+ freshnessEvidence = evObj;
2430
+ }
2431
+ const ledgerLine = {
2432
+ ts, task_id: taskId, workflow: "intent-issuance", slug,
2433
+ commit, attempt, agent_id: null, applied_report: null,
2434
+ issuer: "tick-worker",
2435
+ diff_sha256: intentEntry.diff_sha256 || null,
2436
+ manifest_before: intentEntry.manifest_before || null,
2437
+ freshness_evidence: freshnessEvidence,
2438
+ };
2439
+ let note;
2440
+ if (editOutcome === "refused") {
2441
+ const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
2442
+ ledgerLine.outcome = "rejected";
2443
+ ledgerLine.issued_at = null;
2444
+ ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}`;
2445
+ // Terminal: parked for human attention. "publish: publish-refused"
2446
+ // is registered in lib/publish-note-vocabulary.js (the closed
2447
+ // registry — the writer-side guard pattern from D7).
2448
+ note = `publish: publish-refused ${commit} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
2449
+ } else {
2450
+ ledgerLine.outcome = "submitted";
2451
+ ledgerLine.issued_at = editOutcome === "accepted" ? ts : recoveredIssuedAt;
2452
+ ledgerLine.detail = editOutcome === "accepted"
2453
+ ? "edit issued directly by the session-carrying tick worker (one-party publish)"
2454
+ : `recovered: the manifest advanced past the intent's baseline (freshness evidence recorded) — the dead tick issued and died before recording it; issuance attributed to the dead tick, issued_at lower-bounded by the dead tick's original claim at ${recoveredIssuedAt || "unknown"}`;
2455
+ note = `publish: verification-requested ${commit} attempt=${attempt} — issued directly by the session-carrying tick worker (one-party publish). Waiting on the manual read-back in docs/publish-verification.md.`;
2456
+ }
2457
+ try {
2458
+ appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
2459
+ } catch (e) {
2460
+ db.exec("ROLLBACK");
2461
+ return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
2462
+ }
2463
+ writePublishNote(db, taskId, note, ts);
2464
+ db.exec("COMMIT");
2465
+ return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, note };
2466
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
2467
+ };
2468
+
2469
+ // Record a one-party intent as unissuable (2026-09-20, critic-0145 F-O2).
2470
+ // The session-carrying tick worker calls this when the staged diff cannot
2471
+ // be (re)generated byte-identically — the staged file is missing and
2472
+ // compute-publish-diff --commit fails, or the regenerated hash does not
2473
+ // match the intent's diff_sha256. Code decides: the terminal
2474
+ // `publish: publish-unissuable` note and an issuer-written ledger entry
2475
+ // (outcome "unissuable", issuer "tick-worker") are written here,
2476
+ // atomically, never free-formed by the tick.
2477
+ //
2478
+ // Args: { task_id, claim_expiry, reason } where reason is a short
2479
+ // machine-readable cause (missing-staged-diff, diff-unregenerable,
2480
+ // hash-mismatch-after-regeneration).
2481
+ //
2482
+ // The claim_expiry is a compare-and-swap: the record lands only while the
2483
+ // latest publish: note is still this tick's intent claim. A stale tick gets
2484
+ // { recorded: false } and writes nothing. The terminal note means the scan
2485
+ // never re-claims — no hourly reclaim loop on an unissuable diff.
2486
+ commands["record-intent-unissuable"] = (db, args, ctx) => {
2487
+ const taskId = args.task_id;
2488
+ const claimExpiry = args.claim_expiry;
2489
+ const reason = String(args.reason || "").split("\n")[0].slice(0, 200);
2490
+ if (!taskId) throw usageError("task_id is required.");
2491
+ if (!claimExpiry) throw usageError("claim_expiry is required.");
2492
+ if (!reason) throw usageError("reason is required.");
2493
+
2494
+ db.exec("BEGIN");
2495
+ try {
2496
+ // CAS: the latest publish: note must still be this tick's claim.
2497
+ const latest = latestPublishNote(db, taskId);
2498
+ if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
2499
+ !latest.message.includes(claimExpiry)) {
2500
+ db.exec("ROLLBACK");
2501
+ return { recorded: false, reason: "claim no longer current — another tick owns this task" };
2502
+ }
2503
+ const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
2504
+ const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
2505
+ const slug = project && project.deploy_slug;
2506
+ if (!slug) {
2507
+ db.exec("ROLLBACK");
2508
+ return { recorded: false, reason: "project has no deploy_slug" };
2509
+ }
2510
+ const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
2511
+ let entries = [];
2512
+ try {
2513
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
2514
+ .filter((l) => l.trim().length > 0)
2515
+ .map((l) => JSON.parse(l))
2516
+ .filter((e) => e.task_id === taskId);
2517
+ } catch (e) {
2518
+ db.exec("ROLLBACK");
2519
+ return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
2520
+ }
2521
+ const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
2522
+ if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
2523
+ db.exec("ROLLBACK");
2524
+ return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
2525
+ }
2526
+ const ts = now();
2527
+ const commit = intentEntry.commit;
2528
+ const attempt = intentEntry.attempt || null;
2529
+ const ledgerLine = {
2530
+ ts, task_id: taskId, workflow: "intent-issuance", slug,
2531
+ commit, attempt, agent_id: null, applied_report: null,
2532
+ issuer: "tick-worker", outcome: "unissuable", issued_at: null,
2533
+ diff_sha256: intentEntry.diff_sha256 || null,
2534
+ manifest_before: intentEntry.manifest_before || null,
2535
+ detail: `intent diff unissuable: ${reason} — the staged diff cannot be (re)generated byte-identically; parked for human attention`,
2536
+ };
2537
+ try {
2538
+ appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
2539
+ } catch (e) {
2540
+ db.exec("ROLLBACK");
2541
+ return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
2542
+ }
2543
+ // Terminal: "publish: publish-unissuable" is registered in
2544
+ // lib/publish-note-vocabulary.js (the closed registry — the writer-side
2545
+ // guard pattern from D7).
2546
+ const note = `publish: publish-unissuable ${commit} — the intent's staged diff cannot be (re)generated byte-identically (${reason}). Never re-claim, never loop; parked for human attention.`;
2547
+ writePublishNote(db, taskId, note, ts);
2548
+ db.exec("COMMIT");
2549
+ return { recorded: true, task_id: taskId, commit, note };
2550
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
2110
2551
  };
2111
2552
 
2112
2553
  // Record the unknown-recovery classifier's decision (2026-09-18, blocker
@@ -2802,6 +3243,17 @@ function main() {
2802
3243
  }
2803
3244
  }
2804
3245
 
3246
+ const CREW_API_USAGE =
3247
+ "usage: node crew-api.js --crew-home <path> <command> [--json <json>] [flags...]\n" +
3248
+ "the crew-owned task-service API (API.md): every state-machine invariant lives in the schema.\n" +
3249
+ "run with a command and no --help for command-specific usage; exit 0 ok · 2 usage · 3 not found · 4 conflict/guard";
3250
+
3251
+ // --help: before required-arg parsing (shebang⇔CLI contract).
3252
+ if (process.argv.slice(2).includes("--help")) {
3253
+ console.log(CREW_API_USAGE);
3254
+ process.exit(0);
3255
+ }
3256
+
2805
3257
  try {
2806
3258
  main();
2807
3259
  } catch (e) {