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.
package/lib/crew-api.js CHANGED
@@ -1711,10 +1711,10 @@ function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false
1711
1711
  }
1712
1712
 
1713
1713
  // The unknown-recovery state machine reads two note shapes: the machine's
1714
- // own "publish: <transition>" notes, and the workflow's initial
1714
+ // own "publish: <transition>" notes, and the workflow's legacy (pre-2026-09-20)
1715
1715
  // "Publish outcome unknown …" park note — which carries no "publish:"
1716
- // marker (composeUnattributedParkReason), so the prefilter must match it
1717
- // 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.
1718
1718
  const PUBLISH_NOTE_PREFILTER =
1719
1719
  `(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
1720
1720
 
@@ -1728,28 +1728,29 @@ function latestPublishNote(db, taskId) {
1728
1728
  ).get(taskId) || null;
1729
1729
  }
1730
1730
 
1731
- // The trigger anchor for an unknown attempt: the issuance-kind "submitted"
1732
- // ledger entry for the same attempt (the trigger issuance), falling back to
1733
- // the oldest by ts for pre-D1 ledgers. The issuance entry binds the trigger
1734
- // instant — verify-publish.js and the decision doc prefer it the same way;
1735
- // binding newest misclassifies builds that complete between issuance and
1736
- // receipt — binding newest misclassifies builds that complete between
1737
- // issuance and receipt (see docs/reviews/critic-0143.md findings A2/R2/O4).
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).
1738
1739
  // Returns the entry or null. A null means the trigger
1739
1740
  // instant is unknowable (or the trigger was never issued) — classification
1740
1741
  // cannot run; the task stays parked for human attention. Fail closed, never
1741
1742
  // guess the anchor.
1742
1743
  function findTriggerEntry(entries, commit, attempt) {
1743
- // D1 (2026-09-19; entry_kind cut 2026-09-20): the anchor is the OLDEST
1744
- // submitted entry by ts and the bound instant is its issued_at || ts —
1745
- // the same fallback verify-publish.js uses. The retired entry_kind
1746
- // preference filter could never change the bound anchor (issuance and
1747
- // receipt entries carried the same captured issued_at), so oldest-by-ts
1748
- // is the whole mechanism.
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
1749
  const matches = [];
1750
1750
  for (let i = 0; i < entries.length; i++) {
1751
1751
  const e = entries[i];
1752
- if (e.outcome === "submitted" && e.commit === commit &&
1752
+ if (e.outcome === "submitted" && e.issuer === "tick-worker" &&
1753
+ e.commit === commit &&
1753
1754
  (e.attempt || null) === (attempt || null)) {
1754
1755
  matches.push(e);
1755
1756
  }
@@ -1906,6 +1907,13 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
1906
1907
  // "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
1907
1908
  // "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
1908
1909
  // verification-requested
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)
1909
1917
  // terminal notes (the closed registry in → terminal; skipped with the
1910
1918
  // lib/publish-note-vocabulary.js — the reason named, never
1911
1919
  // scan names the recognized note) "unrecognized"
@@ -1925,9 +1933,11 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1925
1933
 
1926
1934
  const waiting = [];
1927
1935
  const due = [];
1936
+ const intent = [];
1928
1937
  const retryDue = [];
1929
1938
  const mirrored = [];
1930
1939
  const skipped = [];
1940
+ const requeued = []; // F-A4: stale-base intents re-queued to the workflow's Publish
1931
1941
 
1932
1942
  const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
1933
1943
 
@@ -1992,6 +2002,160 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1992
2002
  });
1993
2003
  };
1994
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
+
1995
2159
  const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
1996
2160
 
1997
2161
  db.exec("BEGIN");
@@ -2093,6 +2257,25 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2093
2257
  });
2094
2258
  continue;
2095
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
+ }
2096
2279
  // A previous tick claimed this task for classification.
2097
2280
  // A previous tick claimed this task for classification.
2098
2281
  if (has("publish: unknown-recovery-claimed")) {
@@ -2128,7 +2311,243 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2128
2311
  db.exec("COMMIT");
2129
2312
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2130
2313
 
2131
- 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; }
2132
2551
  };
2133
2552
 
2134
2553
  // Record the unknown-recovery classifier's decision (2026-09-18, blocker
@@ -22,11 +22,28 @@ export const TERMINAL_PUBLISH_NOTES = [
22
22
  "publish: retry-refused",
23
23
  "publish: verified",
24
24
  "publish: superseded",
25
+ // One-party publish refusal (2026-09-20, blocker 22): the session-carrying
26
+ // tick worker issued the edit directly and the platform refused it.
27
+ // Honest terminal state — no re-issue, no park-unknown; the task stays
28
+ // parked for human attention.
29
+ "publish: publish-refused",
25
30
  // Verification pipeline's terminal content verdict (verify-publish.js).
26
31
  // The task stays parked for human attention; unknown-recovery never
27
32
  // re-enters it. Added 2026-09-19 (Room #23 J1/J3: was mislogged as
28
33
  // unrecognized-publish-note).
29
34
  "publish: verification-failed",
35
+ // (2026-09-20, critic-0145 F-O2) One-party intent terminal: the diff for
36
+ // the committed intent cannot be (re)generated byte-identically — the
37
+ // staged file is missing or corrupt, or the base..commit range cannot be
38
+ // regenerated. Never re-claim, never loop on an hourly reclaim; parked
39
+ // for human attention.
40
+ "publish: publish-unissuable",
41
+ // (2026-09-20, critic-0145 F-R3) One-party intent terminal: a re-claimed
42
+ // intent arrived with no pre-issuance manifest baseline, so the freshness
43
+ // comparison is unperformable — the tick can neither prove a dead tick's
44
+ // issuance nor safely re-issue. Never blind-issue, never loop
45
+ // indefinitely; parked for human attention.
46
+ "publish: intent-unverifiable",
30
47
  ];
31
48
  export const TERMINAL_NOTE_MEANINGS = {
32
49
  "publish: ambiguous": "unknown-recovery terminal: could not prove the drop; parked for human attention",
@@ -34,7 +51,10 @@ export const TERMINAL_NOTE_MEANINGS = {
34
51
  "publish: retry-refused": "retry protocol terminal: platform refused the re-issued edit; parked for human attention",
35
52
  "publish: verified": "publish verified and stamped; task re-queued (terminal for the scan)",
36
53
  "publish: superseded": "HEAD moved past the attempt; recovery aborted; parked for human attention",
54
+ "publish: publish-refused": "one-party publish terminal: the platform refused the tick worker's directly-issued edit; parked for human attention",
37
55
  "publish: verification-failed": "verification pipeline's terminal content verdict — parked for human attention",
56
+ "publish: publish-unissuable": "one-party intent terminal: the intent diff cannot be (re)generated byte-identically; parked for human attention",
57
+ "publish: intent-unverifiable": "one-party intent terminal: re-claimed intent with no manifest baseline — freshness unprovable; parked for human attention",
38
58
  };
39
59
  export function matchTerminalPublishNote(message) {
40
60
  for (const note of TERMINAL_PUBLISH_NOTES) {