muse-crew 0.14.5 → 0.14.6

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
@@ -16,8 +16,9 @@
16
16
  // create-project, update-project, delete-project, list-projects,
17
17
  // get-project, kill-switch, get-config, update-config,
18
18
  // set-provenance, get-provenance, acknowledge-poll,
19
- // scan-verification-pending, resolve-publish-unknown,
20
- // scan-publish-unknown, record-unknown-classification, record-retry-recheck,
19
+ // scan-publish-intent, scan-ack-pending,
20
+ // record-intent-issuance, record-intent-unissuable,
21
+ // record-builder-report, record-version-ack, record-publish-note,
21
22
  // record-phase (composite), migrate (one-time import)
22
23
  //
23
24
  // Output is JSON on stdout. Errors are JSON on stderr.
@@ -36,7 +37,7 @@ import { classifySurface } from "./classify-surface.js";
36
37
  // never as unrecognized. Pin-adjacent: every runtime-relative dependency
37
38
  // of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
38
39
  // mechanically verified by tests/pin-closure.test.js.
39
- import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS } from "./publish-note-vocabulary.js";
40
+ import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS, assertWritablePublishNote, assertCodeWritablePublishNote } from "./publish-note-vocabulary.js";
40
41
 
41
42
  // ---------------------------------------------------------------------------
42
43
  // Errors
@@ -1425,6 +1426,17 @@ commands["log-event"] = (db, args) => {
1425
1426
  if (!valid.includes(args.type)) throw usageError(`bad event type: ${args.type}.`);
1426
1427
  const message = (args.message ?? "").trim();
1427
1428
  if (message.length < 1 || message.length > 1000) throw usageError("message is required (1-1000 chars).");
1429
+ // (2026-09-20 REVIEW) State-carrying `publish: <verb>` notes are a closed
1430
+ // set: they are read by the ack/intent scans from the events table, so a
1431
+ // prose log-event with type "note" would bypass the vocabulary guard.
1432
+ // Route them through `record-publish-note` (prose) or the guarded code
1433
+ // writer — never log-event.
1434
+ if (args.type === "note" && /publish:\s*[a-z0-9-]+/.test(message)) {
1435
+ throw usageError(
1436
+ "state-carrying publish notes are closed-vocabulary (lib/publish-note-vocabulary.js) — " +
1437
+ "write them via the `record-publish-note` command, not log-event."
1438
+ );
1439
+ }
1428
1440
  if (args.task_id) requireTask(db, args.task_id);
1429
1441
  const event = {
1430
1442
  id: uuid(), type: args.type, task_id: args.task_id ?? null,
@@ -1445,36 +1457,15 @@ commands["get-events"] = (db, args) => {
1445
1457
  return { events: rows.map(mapEvent) };
1446
1458
  };
1447
1459
 
1448
- // Parent publish verification: scan + atomic claim + reconcile.
1449
- //
1450
- // Finds parked tasks whose latest parent publish note is
1451
- // "publish: verification-requested" (the publisher parks instead of
1452
- // stamping, per docs/publish-verification.md) with no terminal parent
1453
- // verdict and no unexpired verification claim. Each returned task is
1454
- // ATOMICALLY claimed by logging a "publish: verification-claimed"
1455
- // note-event with a lease expiry, so two concurrent scanners (or a
1456
- // retrying tick) cannot start a second verification for the same task.
1457
- // The claim is a note, not a state change: the task stays parked, so the
1458
- // dispatcher never dispatches QA mid-verification.
1459
- //
1460
- // Also reconciles the verified-but-still-parked gap: a parked task whose
1461
- // latest parent verdict is "publish: verified" is re-queued to in_progress
1462
- // (the verdict was recorded but the re-queue was lost to a crash).
1463
- // Failure verdicts are left parked for human attention (fail-closed).
1460
+ // Active-release resolution helpers (2026-09-18, room #15).
1464
1461
  //
1465
- // Returns { to_verify: [...], reconciled: [...] }.
1466
- // Each to_verify entry: { task_id, commit, build_agent_id, project_id,
1467
- // repo_path, deploy_slug, crew_release, claim_expires_at }.
1468
- //
1469
- // crew_release is resolved through the crew home's `current` symlink (the
1470
- // immutable active release the release manager maintains) and cross-checked
1471
- // against this code's own realpath (the release it is actually running
1472
- // from). Unresolvable or disputed => throw fail-closed BEFORE the claim
1473
- // transaction: provenance is never stamped with an unknown release.
1474
- //
1475
- // Active-release resolution is split in two (room #15, 2026-09-18):
1476
1462
  // resolveActiveReleaseName (symlink only — for writers) below, and
1477
1463
  // resolveActiveRelease (symlink + self-path cross-check — for verifiers).
1464
+ // The old "Parent publish verification: scan + atomic claim + reconcile"
1465
+ // block (scan-verification-pending, publish: verification-requested /
1466
+ // verification-claimed / verified) was deleted with the 0.14.6
1467
+ // version-acknowledgement rewrite — these helpers now serve the ack scan's
1468
+ // provenance writers only.
1478
1469
  function resolveActiveReleaseName(crewHome) {
1479
1470
  let linkTarget;
1480
1471
  try {
@@ -1486,240 +1477,45 @@ function resolveActiveReleaseName(crewHome) {
1486
1477
  }
1487
1478
  return basename(resolve(crewHome, linkTarget));
1488
1479
  }
1489
- // The strict resolver for verifiers (scan-verification-pending): the symlink
1490
- // answer PLUS the self-path cross-check — the running code must provably be
1491
- // the active release's own crew-api.js before it stamps verification claims.
1492
- // Stale cron bodies and mid-deploy swaps throw fail-closed here.
1480
+ // The strict resolver for provenance writers (scan-ack-pending and
1481
+ // record-version-ack): the symlink answer PLUS the self-path cross-check —
1482
+ // the running code must provably be the active release's own crew-api.js
1483
+ // before it stamps acknowledgement claims. Stale cron bodies and mid-deploy
1484
+ // swaps throw fail-closed here.
1493
1485
  function resolveActiveRelease(crewHome) {
1494
1486
  const activeRelease = resolveActiveReleaseName(crewHome);
1495
1487
  const selfPath = new URL(import.meta.url).pathname;
1496
1488
  const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
1497
1489
  if (!selfMatch) {
1498
1490
  throw usageError(
1499
- `scan-verification-pending: running code is not inside a releases/<name>/lib path (${selfPath}). Refusing to verify.`
1491
+ `ack/intent scan: running code is not inside a releases/<name>/lib path (${selfPath}). Refusing to stamp.`
1500
1492
  );
1501
1493
  }
1502
1494
  if (selfMatch[1] !== activeRelease) {
1503
1495
  throw usageError(
1504
- `scan-verification-pending: running release ${selfMatch[1]} != active release ${activeRelease} (stale cron body or mid-deploy). Refusing to verify.`
1496
+ `ack/intent scan: running release ${selfMatch[1]} != active release ${activeRelease} (stale cron body or mid-deploy). Refusing to stamp.`
1505
1497
  );
1506
1498
  }
1507
1499
  return activeRelease;
1508
1500
  }
1509
- commands["scan-verification-pending"] = (db, args, ctx) => {
1510
- const CLAIM_LEASE_MS = 60 * 60 * 1000; // 1 hour
1511
- const nowMs = Date.now();
1512
- const claimExpiry = new Date(nowMs + CLAIM_LEASE_MS).toISOString();
1513
- const releaseName = resolveActiveRelease(ctx.crewHome);
1514
-
1515
- const parked = db.prepare(
1516
- `SELECT t.id AS task_id, t.project AS project_id,
1517
- p.repo_path AS repo_path, p.deploy_slug AS deploy_slug
1518
- FROM tasks t LEFT JOIN projects p ON p.id = t.project
1519
- WHERE t.state = 'parked'`
1520
- ).all();
1521
-
1522
- const toVerify = [];
1523
- const reconciled = [];
1524
- const deferred = []; // not-before in the future (unknown-recovery retry) — claimed by a later tick
1525
-
1526
- db.exec("BEGIN");
1527
- try {
1528
- for (const row of parked) {
1529
- const notes = db.prepare(
1530
- `SELECT message, timestamp FROM events
1531
- WHERE task_id = ? AND type = 'note' AND message LIKE '%publish:%'
1532
- ORDER BY timestamp DESC LIMIT 20`
1533
- ).all(row.task_id);
1534
- if (notes.length === 0) continue;
1535
- const latest = notes[0].message;
1536
-
1537
- // Reconcile first: verdict recorded but the re-queue was lost to a
1538
- // crash (verified-but-still-parked). Failure verdicts stay parked.
1539
- // Contained-string matching: the workflow's parkTask prepends
1540
- // "Parked: " to the reason, so a verification request is stored as
1541
- // "Parked: publish: verification-requested ..." (observed 2026-09-14).
1542
- // Prefix anchors would never match; see tests/verify-publish.test.js.
1543
- if (latest.includes("publish: verified")) {
1544
- db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?")
1545
- .run(now(), row.task_id);
1546
- db.prepare(
1547
- `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1548
- VALUES (?, 'note', ?, NULL, ?, ?)`
1549
- ).run(uuid(), row.task_id,
1550
- "publish: reconciled verified-but-parked -> in_progress (re-queue lost to a crash)",
1551
- now());
1552
- reconciled.push({ task_id: row.task_id });
1553
- continue;
1554
- }
1555
-
1556
- // Terminal parent verdicts (anything the parent decided, except a
1557
- // fresh request, a procedural error, or an active claim) — never re-verify.
1558
- const isRequest = latest.includes("publish: verification-requested");
1559
- const isClaim = latest.includes("publish: verification-claimed");
1560
- // Procedural errors (worker botched the procedure, not a content
1561
- // failure) are retryable — treat like a fresh request. Content
1562
- // failures (verification-failed) stay terminal.
1563
- const isProceduralError = latest.includes("publish: verification-procedural-error");
1564
- if (!isRequest && !isClaim && !isProceduralError) continue; // failed / blocked / mismatch: terminal, fail-closed
1565
-
1566
- if (isClaim) {
1567
- // Unexpired claim -> another verifier owns it. Expired -> re-claim below.
1568
- const m = latest.match(/publish: verification-claimed (\S+)/);
1569
- if (m && Date.parse(m[1]) > nowMs) continue;
1570
- }
1571
-
1572
- // Candidate: find the original verification-requested note to extract
1573
- // the commit and build agent id (the latest note may be an expired claim
1574
- // or a procedural error).
1575
- const req = notes.find((n) => n.message.includes("publish: verification-requested"));
1576
- if (!req) continue;
1577
- const commitMatch = req.message.match(/verification-requested ([0-9a-f]{40})/);
1578
- // Fallback: procedural error note carries the commit too.
1579
- const procMatch = !commitMatch ? latest.match(/verification-procedural-error ([0-9a-f]{40})/) : null;
1580
- const commit = commitMatch ? commitMatch[1] : (procMatch ? procMatch[1] : null);
1581
- const buildMatch = req.message.match(/\(build ([0-9a-f-]{36})\)/);
1582
- // Attempt binding (2026-09-18, blocker 4): the verifier must select the
1583
- // submitted ledger entry for the exact (task_id, commit, attempt) —
1584
- // not just (task_id, commit) — or a retry's trigger is indistinguishable
1585
- // from the original's.
1586
- const attemptMatch = req.message.match(/attempt=([^\s]+)/);
1587
- const attempt = attemptMatch ? attemptMatch[1] : null;
1588
- if (!commit) continue; // malformed request: cannot verify mechanically, leave parked
1589
- // not-before (2026-09-18, blocker 15): the unknown-recovery retry
1590
- // protocol mirrors verification-requested with a not-before so the
1591
- // re-triggered build gets 20 minutes to land before the verification
1592
- // scan may claim it. Absent = verify immediately (backward compatible).
1593
- const nbMatch = req.message.match(/not-before=([0-9T:.\-Z+]+)/);
1594
- const notBeforeMs = nbMatch ? Date.parse(nbMatch[1]) : NaN;
1595
- if (notBeforeMs > nowMs) {
1596
- deferred.push({ task_id: row.task_id, commit, not_before: nbMatch[1] });
1597
- continue;
1598
- }
1599
- db.prepare(
1600
- `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1601
- VALUES (?, 'note', ?, NULL, ?, ?)`
1602
- ).run(uuid(), row.task_id, `publish: verification-claimed ${claimExpiry}`, now());
1603
- toVerify.push({
1604
- task_id: row.task_id,
1605
- commit: commit,
1606
- attempt: attempt,
1607
- build_agent_id: buildMatch ? buildMatch[1] : null,
1608
- project_id: row.project_id,
1609
- repo_path: row.repo_path,
1610
- deploy_slug: row.deploy_slug,
1611
- crew_release: releaseName,
1612
- claim_expires_at: claimExpiry,
1613
- });
1614
- }
1615
- db.exec("COMMIT");
1616
- } catch (e) { db.exec("ROLLBACK"); throw e; }
1617
- return { to_verify: toVerify, reconciled, deferred };
1618
- };
1619
-
1620
- // Recovery contract for publish attempts parked with an UNKNOWN outcome
1621
- // (2026-09-14). The structured-output fallback parked the task because no
1622
- // build could be observed — but the edit may still have gone through (the
1623
- // in-flight-only fallback could not see a completed build; fixed
1624
- // in-workflow the same day with the durable audit-dir check). When durable
1625
- // evidence later shows the build DID complete — a platform audit directory
1626
- // created inside the publish window (between Integrate completion and the
1627
- // park event) — this action routes the task to the parent's independent
1628
- // content verification WITHOUT re-issuing the edit and WITHOUT stamping
1629
- // provenance. The original unknown ledger entry and park event are
1630
- // preserved; the resolution is appended to the ledger and the event log.
1631
- // The parent's read-back (docs/publish-verification.md) remains the real
1632
- // verification and can still fail terminally. Never: blind retry, manual
1633
- // stamp, silent state change.
1634
1501
  // ---------------------------------------------------------------------------
1635
- // Shared publish-unknown attempt derivation (2026-09-18, blocker 15).
1636
- //
1637
- // The publish-unknown recovery state machine (scan-publish-unknown, the
1638
- // manual resolve-publish-unknown, record-unknown-classification) all start
1639
- // from the same durable anchors: the parked task, its project's deploy_slug,
1640
- // and the latest publish ledger entry for the task, which must be outcome
1641
- // "unknown" with a usable commit hash. This helper reads those anchors once
1642
- // so the three commands cannot drift apart.
1502
+ // Publish helpers (0.14.6 version-acknowledgement architecture).
1643
1503
  //
1644
- // Returns { ok: true, task, slug, repoPath, projectId, commit, attempt,
1645
- // unknownEntry, unknownTs, ledgerPath, entries }
1646
- // or { ok: false, reason } — the reason strings are the manual command's
1647
- // exact messages (pinned by tests/resolve-publish-unknown.test.js).
1504
+ // Publication is a handoff with strong suggestions: the workflow stages a
1505
+ // checksummed diff carrying a per-attempt version; the session-carrying tick
1506
+ // worker issues it via artifact_edit; acknowledgement is the EXACT version
1507
+ // string observed on the artifact's disk or in a version-echoing builder
1508
+ // report. Eric's contract: "If we hear that the artifact acknowledges our
1509
+ // version, that's it. We don't verify against content." Provenance certifies
1510
+ // that an issuance request was ACKNOWLEDGED — never that bytes matched.
1648
1511
  // ---------------------------------------------------------------------------
1649
- function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false) {
1650
- const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
1651
- if (!task) return { ok: false, reason: "unknown task" };
1652
- if (task.state !== "parked") {
1653
- return { ok: false, reason: `task is not parked (state=${task.state})` };
1654
- }
1655
- const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
1656
- const slug = project && project.deploy_slug;
1657
- if (!slug) return { ok: false, reason: "project has no deploy_slug" };
1658
- const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
1659
- let entries = [];
1660
- try {
1661
- entries = readFileSync(ledgerPath, "utf8").split("\n")
1662
- .filter((l) => l.trim().length > 0)
1663
- .map((l) => JSON.parse(l))
1664
- .filter((e) => e.task_id === taskId);
1665
- } catch (e) {
1666
- return { ok: false, reason: `cannot read publish ledger: ${e.message}` };
1667
- }
1668
- if (entries.length === 0) return { ok: false, reason: "no publish ledger entries for this task" };
1669
- // 2026-09-18, should-fix 6: for crash recovery (requireLatest=false),
1670
- // derive by the most recent unknown attempt, not the ledger tail. Retry
1671
- // or observability entries appended after the unknown must not break
1672
- // recovery. For manual resolve (requireLatest=true), the unknown must be
1673
- // the latest entry.
1674
- let unknownEntry = null;
1675
- if (requireLatest) {
1676
- // Strict mode: latest entry must be unknown.
1677
- const latest = entries[entries.length - 1];
1678
- if (latest.outcome === "unknown-resolved") {
1679
- return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
1680
- }
1681
- if (latest.outcome !== "unknown") {
1682
- return { ok: false, reason: `latest ledger outcome is '${latest.outcome}', not unknown` };
1683
- }
1684
- unknownEntry = latest;
1685
- } else {
1686
- // Lenient mode: find the most recent unknown, skipping later non-unknown
1687
- // entries (retry, observability). If an unknown-resolved follows the
1688
- // unknown, the attempt is resolved.
1689
- for (let i = entries.length - 1; i >= 0; i--) {
1690
- const e = entries[i];
1691
- if (e.outcome === "unknown-resolved") {
1692
- return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
1693
- }
1694
- if (e.outcome === "unknown") {
1695
- unknownEntry = e;
1696
- break;
1697
- }
1698
- }
1699
- if (!unknownEntry) {
1700
- return { ok: false, reason: "no unresolved unknown ledger entry for this task" };
1701
- }
1702
- }
1703
- if (!unknownEntry.commit || !/^[0-9a-f]{40}$/.test(unknownEntry.commit)) {
1704
- return { ok: false, reason: "unknown ledger entry carries no usable commit hash" };
1705
- }
1706
- return {
1707
- ok: true, task, slug, repoPath: (project && project.repo_path) || null, projectId: project.id,
1708
- commit: unknownEntry.commit, attempt: unknownEntry.attempt || null,
1709
- unknownEntry, unknownTs: unknownEntry.ts || null, ledgerPath, entries,
1710
- };
1711
- }
1712
1512
 
1713
- // The unknown-recovery state machine reads two note shapes: the machine's
1714
- // own "publish: <transition>" notes, and the workflow's legacy (pre-2026-09-20)
1715
- // "Publish outcome unknown …" park note — which carries no "publish:"
1716
- // marker, so the prefilter must match it explicitly. LIKE is ASCII
1717
- // case-insensitive in SQLite.
1718
- const PUBLISH_NOTE_PREFILTER =
1719
- `(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
1513
+ // The ack/intent scans read the note journal through this prefilter. LIKE
1514
+ // is ASCII case-insensitive in SQLite.
1515
+ const PUBLISH_NOTE_PREFILTER = `(message LIKE '%publish:%')`;
1720
1516
 
1721
- // Latest publish: note for a task (the unknown-recovery state machine's
1722
- // state), or null when the task has no publish notes yet.
1517
+ // Latest publish: note for a task (the publish state machines' state), or
1518
+ // null when the task has no publish notes yet.
1723
1519
  function latestPublishNote(db, taskId) {
1724
1520
  return db.prepare(
1725
1521
  `SELECT message, timestamp FROM events
@@ -1728,46 +1524,6 @@ function latestPublishNote(db, taskId) {
1728
1524
  ).get(taskId) || null;
1729
1525
  }
1730
1526
 
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.
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 = [];
1750
- for (let i = 0; i < entries.length; i++) {
1751
- const e = entries[i];
1752
- if (e.outcome === "submitted" && e.issuer === "tick-worker" &&
1753
- e.commit === commit &&
1754
- (e.attempt || null) === (attempt || null)) {
1755
- matches.push(e);
1756
- }
1757
- }
1758
- matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
1759
- return matches[0] || null;
1760
- }
1761
-
1762
- // The unknown ledger entry's detail says whether the trigger went out.
1763
- // Unknown parks from preflight/toolcheck inconclusives never issued the
1764
- // trigger ("the trigger was NOT issued") — there is no dropped edit to
1765
- // recover, so the unknown-recovery machine must not classify or retry them.
1766
- function triggerWasIssued(unknownEntry) {
1767
- const detail = String((unknownEntry && unknownEntry.detail) || "");
1768
- return !/trigger was NOT issued|no trigger issued/i.test(detail);
1769
- }
1770
-
1771
1527
  function writePublishNote(db, taskId, message, ts) {
1772
1528
  db.prepare(
1773
1529
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
@@ -1775,254 +1531,175 @@ function writePublishNote(db, taskId, message, ts) {
1775
1531
  ).run(uuid(), taskId, message, ts || now());
1776
1532
  }
1777
1533
 
1778
- commands["resolve-publish-unknown"] = (db, args, ctx) => {
1779
- const taskId = args.task_id;
1780
- if (!taskId) throw usageError("task_id is required.");
1781
- requireTask(db, taskId);
1782
- // Shared attempt anchors (2026-09-18, blocker 15): the task must be
1783
- // parked, the project must carry a deploy_slug, and the latest publish
1784
- // ledger entry for the task must be outcome "unknown" with a usable
1785
- // commit. derivePublishUnknownAttempt keeps the exact reason strings the
1786
- // recovery contract pins (tests/resolve-publish-unknown.test.js).
1787
- // requireLatest=true: resolve is a manual action on the current state —
1788
- // a later non-unknown entry means the state has moved on.
1789
- const derived = derivePublishUnknownAttempt(db, ctx.crewHome, taskId, true);
1790
- if (!derived.ok) return { resolved: false, reason: derived.reason };
1791
- const slug = derived.slug;
1792
- const ledgerPath = derived.ledgerPath;
1793
- const latest = derived.unknownEntry;
1794
-
1795
- // The publish window: Integrate completion -> the unknown-outcome park.
1796
- // Nothing else touches the artifact during Publish, so an audit dir
1797
- // created inside this window belongs to this publish attempt.
1798
- const events = db.prepare(
1799
- "SELECT type, message, timestamp FROM events WHERE task_id = ? ORDER BY timestamp ASC"
1800
- ).all(taskId);
1801
- const integrateDone = [...events].reverse().find((e) =>
1802
- e.type === "completed" && /integrate completed/i.test(e.message || ""));
1803
- const parkEvent = [...events].reverse().find((e) =>
1804
- e.type === "note" && (e.message || "").includes("Publish outcome unknown"));
1805
- if (!integrateDone || !parkEvent) {
1806
- return { resolved: false, reason: "cannot determine publish window (missing integrate-completion or unknown-outcome park event)" };
1807
- }
1808
- const winStart = Date.parse(integrateDone.timestamp);
1809
- const winEnd = Date.parse(parkEvent.timestamp);
1810
- if (!(winStart < winEnd)) {
1811
- return { resolved: false, reason: "publish window is empty or inverted" };
1812
- }
1813
-
1814
- // Audit dirs are named <UTC-timestamp>-<id>; the timestamp is the build's.
1815
- const auditsDir = join(homedir(), "workspace", "ts-spaces", slug, "audits");
1816
- let evidenceDirs = [];
1534
+ // 0.14.6 §1.1 — the per-attempt publish version: "<commit>:<attempt>".
1535
+ // A plain version number, not a hash (2026-09-20: the sha256 "nonce"
1536
+ // defended against a forger that doesn't exist — the builder's failure mode
1537
+ // is omission, never forging our receipts — while the artifact's real failure
1538
+ // mode, an unfaithful carrier, defeats a hash and a counter identically. We
1539
+ // own the code, so we count our own attempts; we don't own the artifact, so
1540
+ // verification stays observation of its state, never its attestation).
1541
+ // Deterministic: no wall clock, no randomness. Byte-identical to
1542
+ // lib/compute-publish-diff.js's derivation (which synthesizes the
1543
+ // `.crew-publish-version-<task_id>` receipt into the staged diff), so the
1544
+ // workflow's staging, the tick's regeneration, and these scans all agree on
1545
+ // the string. The commit binds the version to the exact code (a stale receipt
1546
+ // from an earlier commit can never equal a fresh version); the attempt
1547
+ // counts re-issues of that commit. `attempt` is the 1-based publish attempt;
1548
+ // String(attempt) matches the argv string the diff script concatenates.
1549
+ function derivePublishVersion(commit, attempt) {
1550
+ return commit + ":" + String(attempt);
1551
+ }
1552
+
1553
+ // A well-formed publish version: "<40-hex commit>:<positive-integer attempt>".
1554
+ function isPublishVersion(s) {
1555
+ return typeof s === "string" && /^[0-9a-f]{40}:[1-9][0-9]*$/.test(s);
1556
+ }
1557
+
1558
+ // 0.14.6 §1.5 — the closed publish-note writer. Every state-carrying
1559
+ // publish-note write by deterministic code in this file goes through
1560
+ // writeGuardedPublishNote, which asserts the verb against the code-writable
1561
+ // registry (transitional + current terminals) in
1562
+ // lib/publish-note-vocabulary.js before writing. Tick prose mints notes
1563
+ // only through the `record-publish-note` command, which asserts the
1564
+ // prose-writable registry (transitional only — prose can never mint a
1565
+ // terminal state). A registry violation is a caller bug (usage).
1566
+ function writeGuardedPublishNote(db, taskId, message, ts) {
1817
1567
  try {
1818
- evidenceDirs = readdirSync(auditsDir).filter((name) => {
1819
- const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z-/.exec(name);
1820
- if (!m) return false;
1821
- const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
1822
- return t > winStart && t <= winEnd;
1823
- }).sort();
1568
+ assertCodeWritablePublishNote(message);
1824
1569
  } catch (e) {
1825
- return { resolved: false, reason: `cannot list audit dirs: ${e.message}` };
1826
- }
1827
- if (evidenceDirs.length === 0) {
1828
- return { resolved: false, reason: "no audit build completed inside the publish window — still unknown" };
1829
- }
1830
- // (N3) Ambiguity guard: multiple builds in the recovery window have the
1831
- // same attribution ambiguity as the workflow's immediate verdict — no
1832
- // single dir can be attributed to this attempt, so resolution stays
1833
- // unknown. Only when exactly one dir remains is evidenceDirs[0] safe.
1834
- if (evidenceDirs.length > 1) {
1835
- return { resolved: false, reason: `audit-dir ambiguity: ${evidenceDirs.length} audit dirs inside the publish window — still unknown` };
1570
+ throw usageError(e.message);
1836
1571
  }
1837
- const evidenceDir = evidenceDirs[0];
1572
+ writePublishNote(db, taskId, message, ts);
1573
+ }
1838
1574
 
1839
- // Corroboration, observation only: the audit harness's own verdict.
1840
- let auditOk = null;
1575
+ // The publish ledger is a per-slug JSONL file under the crew home
1576
+ // ($CREW_HOME/.publish-ledger/<slug>.jsonl): best-effort observability plus
1577
+ // the durable anchors the scans read (intent entries, submitted entries).
1578
+ function readTaskLedgerEntries(crewHome, slug, taskId) {
1579
+ const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
1841
1580
  try {
1842
- auditOk = JSON.parse(readFileSync(join(auditsDir, evidenceDir, "report.json"), "utf8")).ok === true;
1843
- } catch (e) { auditOk = null; }
1581
+ return {
1582
+ ok: true, ledgerPath,
1583
+ entries: readFileSync(ledgerPath, "utf8").split("\n")
1584
+ .filter((l) => l.trim().length > 0)
1585
+ .map((l) => JSON.parse(l))
1586
+ .filter((e) => e.task_id === taskId),
1587
+ };
1588
+ } catch (e) {
1589
+ return { ok: false, ledgerPath, reason: `cannot read publish ledger: ${e.message}` };
1590
+ }
1591
+ }
1844
1592
 
1845
- const ts = now();
1846
- // The verification-requested note must sort strictly after the
1847
- // unknown-resolved note: scan-verification-pending reads the LATEST
1848
- // publish: note, and equal timestamps would leave the order undefined.
1849
- const tsLater = new Date(Date.parse(ts) + 1000).toISOString();
1850
- db.exec("BEGIN");
1851
- try {
1852
- db.prepare(
1853
- "INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
1854
- ).run(uuid(), taskId,
1855
- `publish: unknown-resolved ${latest.commit} (audit ${evidenceDir}, report ok=${auditOk}) — durable build evidence inside the publish window; routing to parent content verification. No re-trigger issued, no provenance stamped, original unknown outcome preserved.`,
1856
- ts);
1857
- // Mirror the workflow's verification-requested park note so
1858
- // scan-verification-pending claims it on the next tick.
1859
- db.prepare(
1860
- "INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
1861
- ).run(uuid(), taskId,
1862
- `Parked: publish: verification-requested ${latest.commit} (build agent_id unobserved) — recovered from publish-unknown via resolve-publish-unknown; durable evidence ${evidenceDir}. waiting on the manual read-back in docs/publish-verification.md.`,
1863
- tsLater);
1864
- db.prepare("UPDATE tasks SET state = 'parked', updated_at = ? WHERE id = ?").run(tsLater, taskId);
1865
- db.exec("COMMIT");
1866
- } catch (e) { db.exec("ROLLBACK"); throw e; }
1593
+ // Latest publish-intent ledger entry for (task, commit, publish_attempt):
1594
+ // the issuance anchor. The workflow writes attempt 1 (issuer "workflow");
1595
+ // the ack scan's re-issue writes attempts 2+ (issuer "tick-worker").
1596
+ function findIntentEntry(entries, commit, publishAttempt) {
1597
+ const matches = entries.filter((e) => e.outcome === "publish-intent" &&
1598
+ e.commit === commit && (e.publish_attempt || null) === (publishAttempt || null));
1599
+ matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
1600
+ return matches[matches.length - 1] || null;
1601
+ }
1867
1602
 
1868
- // Append-only audit trail: the resolution lands in the ledger next to the
1869
- // original unknown entry (which is never rewritten).
1870
- try {
1871
- appendFileSync(ledgerPath, JSON.stringify({
1872
- ts: new Date(ts).toISOString(),
1873
- task_id: taskId,
1874
- workflow: latest.workflow || "unknown",
1875
- slug,
1876
- commit: latest.commit,
1877
- attempt: latest.attempt,
1878
- agent_id: null,
1879
- applied_report: null,
1880
- outcome: "unknown-resolved",
1881
- detail: `durable build evidence ${evidenceDir} (audit report ok=${auditOk}) inside publish window; routed to parent verification without re-trigger`,
1882
- }) + "\n");
1883
- } catch (e) { /* best-effort observability */ }
1884
-
1885
- return { resolved: true, task_id: taskId, commit: latest.commit, evidence_dir: evidenceDir, audit_report_ok: auditOk };
1886
- };
1603
+ // Issuer-written "submitted" entries for (task, commit): the issuance
1604
+ // record. Only the session-carrying tick worker performs issuance, and it
1605
+ // stamps its writes issuer="tick-worker" — anything else is a phantom claim
1606
+ // and never binds. Oldest first (D1: the bound instant is issued_at || ts).
1607
+ function findSubmittedEntries(entries, commit) {
1608
+ const matches = entries.filter((e) => e.outcome === "submitted" &&
1609
+ e.issuer === "tick-worker" && e.commit === commit);
1610
+ matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
1611
+ return matches;
1612
+ }
1887
1613
 
1888
- // Cron unknown-recovery scan (2026-09-18, blocker 15). Runs in cron Step 4.5
1889
- // BEFORE scan-verification-pending, so a task the classifier verifies (or
1890
- // the retry protocol re-triggers) is claimed by the verification scan on
1891
- // the same tick.
1892
- //
1893
- // The note is the state machine; this command is the only writer of its
1894
- // transitions. The tick ferries classifier decisions and retry steps — code
1895
- // decides every state change. One atomic transaction per scan: claims,
1896
- // mirrors, and terminal notes all land together or not at all.
1614
+ // 0.14.6 §1.3 — record a positive version acknowledgement: stamp provenance
1615
+ // (certifying the issuance REQUEST was acknowledged — never byte equality),
1616
+ // write the terminal `publish: version-acknowledged` note, and re-queue the
1617
+ // task. Shared by scan-ack-pending and the record-version-ack command (the
1618
+ // re-claim path). The strict release cross-check refuses to stamp from a
1619
+ // stale cron body or a mid-deploy swap — fail closed.
1620
+ function recordVersionAck(db, ctx, { taskId, projectId, commit, version, via }) {
1621
+ const releaseName = resolveActiveRelease(ctx.crewHome);
1622
+ const ts = now();
1623
+ stampProvenance(db, {
1624
+ project_id: projectId, source_commit: commit, crew_release: releaseName,
1625
+ published_at: ts, task_id: taskId,
1626
+ });
1627
+ writeGuardedPublishNote(db, taskId,
1628
+ `publish: version-acknowledged ${commit} via=${via} — the artifact acknowledged this attempt's exact version ` +
1629
+ `(${via === "disk" ? "on-disk receipt .crew-publish-version" : "version-echoing builder report"}); ` +
1630
+ `provenance stamped for crew release ${releaseName} (certifies the issuance request was acknowledged, not byte equality). Task re-queued.`,
1631
+ ts);
1632
+ db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?").run(ts, taskId);
1633
+ return { ok: true, task_id: taskId, commit, version, via, crew_release: releaseName };
1634
+ }
1635
+
1636
+ // The version on a submitted entry must be the deterministic one for its
1637
+ // (task, commit, publish_attempt) — the ledger is advisory; the derivation
1638
+ // is the contract. Returns the entry's publish_attempt or null.
1639
+ function verifiedSubmittedAttempt(taskId, entry) {
1640
+ const pa = entry.publish_attempt;
1641
+ if (!Number.isInteger(pa) || pa < 1) return null;
1642
+ if (!/^[0-9a-f]{40}$/.test(entry.commit || "")) return null;
1643
+ if (!isPublishVersion(entry.version)) return null;
1644
+ if (entry.version !== derivePublishVersion(entry.commit, pa)) return null;
1645
+ return pa;
1646
+ }
1647
+ // Publish-intent scan (0.14.6 §1.2/§1.3): claims parked publish intents for
1648
+ // the session-carrying tick worker's direct issuance. One atomic
1649
+ // transaction per scan: claims and terminal notes all land together or not
1650
+ // at all.
1897
1651
  //
1898
1652
  // latest publish: note this scan
1899
- // "Publish outcome unknown…" now < park+30m → waiting (no claim, log only)
1900
- // else → atomic 1h claim → due, with classifier inputs
1901
- // "publish: unknown-recovery-claimed …" unexpired → skip (a tick owns it)
1902
- // expired → re-claim → due (the tick died mid-classification)
1903
- // "publish: dropped …" budget spent (retry-issued in history) → terminal
1904
- // publish: ambiguous … retry-budget-exhausted
1905
- // else → retry_due (the tick runs the retry protocol)
1906
- // "publish: retry-intended …" w/o → mirror publish: verification-requested
1907
- // "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
1908
- // "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
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)
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"
1920
- // "publish: verification-requested" → already routed; ignored (the verification scan owns it)
1653
+ // "publish: publish-requested …" → atomic 1h claim → intent bucket:
1654
+ // the tick issues the edit directly
1655
+ // in its own turn (no child).
1656
+ // "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it);
1657
+ // expired: re-claim → intent (the tick
1658
+ // checks the dead attempt's version
1659
+ // receipt before issuing — never
1660
+ // blindly re-issues).
1661
+ // "publish: edit-issued …" → already routed; ignored (the ack
1662
+ // scan owns it from here).
1663
+ // terminal notes (the closed registry) → terminal; skipped with the
1664
+ // reason named, never "unrecognized".
1921
1665
  //
1922
- // Unknown parks whose trigger was never issued (preflight/toolcheck
1923
- // inconclusives) or whose submitted ledger entry is missing have no
1924
- // knowable trigger anchor: the scan leaves them parked for human attention.
1925
- // Fail closed, never guess.
1926
- commands["scan-publish-unknown"] = (db, args, ctx) => {
1666
+ // The tick never issues without a claim, and a claim never outlives its
1667
+ // lease. Fail closed, never guess.
1668
+ commands["scan-publish-intent"] = (db, args, ctx) => {
1927
1669
  const crewHome = ctx.crewHome;
1928
1670
  const nowMs = Date.now();
1929
- const quiesceMs = 30 * 60 * 1000; // design §1.4e: never trust a read before the park is 30m old
1930
1671
  const claimLeaseMs = 60 * 60 * 1000; // one hour — survives a dead tick, does not block the next one
1931
- const retrySettleMs = 20 * 60 * 1000; // a re-triggered build gets 20m before verification may claim it
1932
1672
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1933
1673
 
1934
- const waiting = [];
1935
- const due = [];
1936
1674
  const intent = [];
1937
- const retryDue = [];
1938
- const mirrored = [];
1939
1675
  const skipped = [];
1940
- const requeued = []; // F-A4: stale-base intents re-queued to the workflow's Publish
1941
-
1942
- const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
1943
-
1944
- // Shared tail of the unknown branches: derive anchors, check the trigger
1945
- // went out, find the trigger anchor, write the atomic claim, emit the
1946
- // classifier inputs. parkNote is the /publish outcome unknown/i note.
1947
- const claimForClassification = (taskId, notes, parkNote) => {
1948
- const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
1949
- if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); return; }
1950
- if (!triggerWasIssued(derived.unknownEntry)) {
1951
- skip(taskId, "trigger-never-issued",
1952
- "the unknown ledger entry records that the trigger was never issued — no dropped edit to recover; stays parked for human attention");
1953
- return;
1954
- }
1955
- const trigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
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)) {
1959
- skip(taskId, "no-trigger-anchor",
1960
- "no submitted ledger entry for this attempt — the trigger instant is unknowable; stays parked for human attention");
1961
- return;
1962
- }
1963
- // Atomic claim: the note IS the claim, written inside this transaction
1964
- // only while the latest publish: note is still the expected one (the
1965
- // caller re-checks freshness before invoking this).
1966
- const expiry = new Date(nowMs + claimLeaseMs).toISOString();
1967
- writePublishNote(db, taskId,
1968
- `publish: unknown-recovery-claimed ${expiry} — ${taskId} parked unknown at ${parkNote.timestamp}; claimed for classification (1-hour lease).`,
1969
- new Date(nowMs).toISOString());
1970
- // Classifier base: the last verified publish, or the empty tree when
1971
- // nothing is stamped yet (a first publish) — same convention as the
1972
- // Step 4.5 read-back sensor.
1973
- // 2026-09-18, should-fix 4: provenance query failures fail CLOSED —
1974
- // the empty tree is valid only when there is truly no provenance value.
1975
- // A query error (locked DB, I/O failure) is not "no provenance"; skip
1976
- // the task so the next scan retries.
1977
- let base;
1978
- try {
1979
- const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(derived.projectId);
1980
- if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
1981
- base = prov.provenance_source_commit;
1982
- } else {
1983
- base = EMPTY_TREE; // truly no provenance value: first publish
1984
- }
1985
- } catch (e) {
1986
- skip(taskId, "provenance-query-failed",
1987
- `provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
1988
- return;
1989
- }
1990
- due.push({
1991
- task_id: taskId, commit: derived.commit, base,
1992
- trigger_ts: trigger.issued_at || trigger.ts, park_ts: parkNote.timestamp,
1993
- slug: derived.slug, repo_path: derived.repoPath,
1994
- project_id: derived.projectId, claim_expiry: expiry,
1995
- ledger_path: derived.ledgerPath,
1996
- // Pre-trigger manifest baseline (design §1.9): the workflow snapshots
1997
- // the manifest before the trigger into the submitted ledger entry; the
1998
- // classifier's verified path compares the current manifest against it.
1999
- // Null when the workflow predates the capture (fail closed downstream).
2000
- manifest_before: (trigger.manifest_before && typeof trigger.manifest_before === "object")
2001
- ? trigger.manifest_before : null,
2002
- });
2003
- };
1676
+ const requeued = []; // stale-base intents re-queued to the workflow's Publish for re-preparation
2004
1677
 
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
1678
+ const skip = (taskId, reason, detail) =>
1679
+ skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
1680
+
1681
+ // One-party publish intent: the workflow parked at intent — the merged
1682
+ // change is staged as a checksummed diff carrying the per-attempt version,
1683
+ // and issuance belongs to the session-carrying tick worker. The intent
1684
+ // ledger entry (outcome "publish-intent") is the durable anchor: the tick
1685
+ // verifies the staged diff's sha256 against it and regenerates from
2011
1686
  // 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
1687
+ // tick's claim expired: the tick checks the dead attempt's version receipt
1688
+ // before issuing, because the dead tick may have issued and died before
2014
1689
  // recording it — a re-claim never blindly re-issues.
2015
1690
  const claimForIntent = (taskId, intentNote, reclaimed, anchorNote) => {
2016
- const m = intentNote.message.match(/publish: publish-requested\s+([0-9a-f]{40})\s+(\S+)/);
1691
+ const m = intentNote.message.match(/publish:\s*publish-requested\s+([0-9a-f]{40})\s+attempt=(\d+)/);
2017
1692
  if (!m) {
2018
1693
  skip(taskId, "intent-note-unparseable",
2019
- "the publish-requested note carries no commit hash — stays parked for human attention");
1694
+ "the publish-requested note carries no commit hash + attempt=N — stays parked for human attention");
2020
1695
  return;
2021
1696
  }
2022
1697
  const commit = m[1];
1698
+ const publishAttempt = parseInt(m[2], 10);
2023
1699
  const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
2024
1700
  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`);
1701
+ skip(taskId, "not-parked",
1702
+ `task is not parked (state=${task && task.state}) — the intent claim requires a parked task`);
2026
1703
  return;
2027
1704
  }
2028
1705
  const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
@@ -2031,36 +1708,32 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2031
1708
  skip(taskId, "no-deploy-slug", "project has no deploy_slug — stays parked for human attention");
2032
1709
  return;
2033
1710
  }
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.
1711
+ // A null repo_path fails closed at emission: the tick can neither
1712
+ // regenerate nor verify the diff. No claim, no tick work.
2037
1713
  const repoPath = project && project.repo_path;
2038
1714
  if (!repoPath) {
2039
1715
  skip(taskId, "no-repo-path",
2040
1716
  "project has no repo_path — the intent diff cannot be regenerated or verified; fails closed at emission; stays parked for human attention");
2041
1717
  return;
2042
1718
  }
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`);
1719
+ const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
1720
+ if (!ledger.ok) {
1721
+ skip(taskId, "ledger-unreadable", `${ledger.reason} — stays parked for human attention`);
2052
1722
  return;
2053
1723
  }
2054
- // Latest intent wins: a reworked task re-parks a fresh intent.
2055
- const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
1724
+ const intentEntry = findIntentEntry(ledger.entries, commit, publishAttempt);
2056
1725
  if (!intentEntry) {
2057
1726
  skip(taskId, "no-intent-entry",
2058
- "no publish-intent ledger entry for this task — the intent anchor is missing; stays parked for human attention");
1727
+ "no publish-intent ledger entry for this task/commit/attempt — the intent anchor is missing; stays parked for human attention");
2059
1728
  return;
2060
1729
  }
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`);
1730
+ // The version is protocol: the ledger is advisory — the deterministic
1731
+ // derivation is the contract. A mismatch means the staged diff was not
1732
+ // built by this machine; never issue it.
1733
+ const expectedVersion = derivePublishVersion(commit, publishAttempt);
1734
+ if (intentEntry.version !== expectedVersion) {
1735
+ skip(taskId, "intent-version-mismatch",
1736
+ "the intent entry's version is missing or does not match the deterministic derivation — stays parked for human attention");
2064
1737
  return;
2065
1738
  }
2066
1739
  if (typeof intentEntry.diff_path !== "string" || !intentEntry.diff_path ||
@@ -2069,35 +1742,15 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2069
1742
  "the intent entry carries no usable diff_path/diff_sha256 — stays parked for human attention");
2070
1743
  return;
2071
1744
  }
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.
1745
+ // Freshness CAS: the latest publish: note must still be the anchor note
1746
+ // — for a first claim that is the intent note; for a re-claim it is the
1747
+ // expired claim note.
2088
1748
  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.
1749
+ if (!fresh || fresh.message !== anchorNote.message) { skip(taskId, "raced"); return; }
1750
+ // The classifier-base convention: last acknowledged publish, or the
1751
+ // empty tree on a first publish — the tick needs it to regenerate the
1752
+ // diff byte-identically when the staged file is missing. A query error
1753
+ // is not "no provenance" — fail closed.
2101
1754
  let base;
2102
1755
  try {
2103
1756
  const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
@@ -2111,47 +1764,39 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2111
1764
  `provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
2112
1765
  return;
2113
1766
  }
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.
1767
+ // The intent's recorded base must match stamped provenance: another
1768
+ // publish may have landed after the intent was staged. A stale base
1769
+ // means the staged diff is against a superseded base — re-queue the
1770
+ // task (next_phase routes it straight back to the workflow's Publish,
1771
+ // which re-prepares the intent against the new base); never issue the
1772
+ // stale diff. Intents with no recorded base re-queue the same way.
2121
1773
  const intentBase = (typeof intentEntry.base === "string" && /^[0-9a-f]{40}$/.test(intentEntry.base))
2122
1774
  ? intentEntry.base : null;
2123
1775
  if (!intentBase || intentBase !== base) {
2124
- writePublishNote(db, taskId,
1776
+ const staleAt = new Date(nowMs).toISOString();
1777
+ writeGuardedPublishNote(db, taskId,
2125
1778
  `publish: publish-base-stale ${commit} — the intent's staged diff was computed against base ` +
2126
1779
  `${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: ` +
1780
+ `${base.slice(0, 7)}; issuing the stale diff risks a spurious mismatch. Re-queued: ` +
2128
1781
  `the workflow's Publish re-prepares the intent against the new base.`,
2129
- claimedAt);
1782
+ staleAt);
2130
1783
  db.prepare("UPDATE tasks SET next_phase = 'Publish', state = 'in_progress', updated_at = ? WHERE id = ?")
2131
1784
  .run(now(), taskId);
2132
1785
  requeued.push({ task_id: taskId, intent_base: intentBase, provenance_base: base });
2133
1786
  return;
2134
1787
  }
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
- }
1788
+ const claimedAt = new Date(nowMs).toISOString();
1789
+ const expiry = new Date(nowMs + claimLeaseMs).toISOString();
1790
+ writeGuardedPublishNote(db, taskId,
1791
+ `publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit} ` +
1792
+ `(attempt ${publishAttempt}); claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
1793
+ claimedAt);
2149
1794
  intent.push({
2150
- task_id: taskId, commit, attempt: intentEntry.attempt || null,
1795
+ task_id: taskId, commit, attempt: publishAttempt, version: expectedVersion,
2151
1796
  slug, repo_path: repoPath,
2152
- project_id: project.id, ledger_path: ledgerPath, base,
1797
+ project_id: project.id, ledger_path: ledger.ledgerPath, base,
2153
1798
  diff_path: intentEntry.diff_path, diff_sha256: intentEntry.diff_sha256,
2154
- manifest_before: manifestBefore,
1799
+ files: Array.isArray(intentEntry.files) ? intentEntry.files : null,
2155
1800
  claimed_at: claimedAt, claim_expiry: expiry, reclaimed,
2156
1801
  });
2157
1802
  };
@@ -2171,288 +1816,146 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2171
1816
  const latest = notes[0];
2172
1817
  const msg = latest.message;
2173
1818
  const has = (s) => msg.includes(s);
2174
- const historyHas = (s) => notes.some((n) => n.message.includes(s));
2175
1819
 
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.
1820
+ // Terminal states — the machine never revisits these. The vocabulary
1821
+ // is explicit state (lib/publish-note-vocabulary.js); a recognized
1822
+ // terminal note is skipped as terminal with its meaning, never as
1823
+ // unrecognized.
2179
1824
  const terminalNote = matchTerminalPublishNote(msg);
2180
1825
  if (terminalNote) {
2181
1826
  skip(taskId, "terminal",
2182
1827
  `recognized terminal note '${terminalNote}' — ${TERMINAL_NOTE_MEANINGS[terminalNote]}`);
2183
1828
  continue;
2184
1829
  }
2185
- // Already routed into the verification pipeline — the verification
2186
- // scan owns it from here.
2187
- if (has("publish: verification-requested")) {
2188
- skip(taskId, "routed-to-verification");
2189
- continue;
2190
- }
2191
- // A retry went out but the mirrored verification request is missing
2192
- // (the tick died between the two writes). Mirror it now — the retry is
2193
- // at least one tick old, so no not-before is needed.
2194
- if (has("publish: retry-issued")) {
2195
- if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
2196
- const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
2197
- if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
2198
- writePublishNote(db, taskId,
2199
- `publish: verification-requested ${derived.commit} attempt=${derived.attempt} — mirrored by scan-publish-unknown: the retry was issued without a mirrored request (the tick died between the writes). Waiting on the manual read-back in docs/publish-verification.md.`);
2200
- mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: null, reason: "retry-issued-without-mirror" });
2201
- continue;
2202
- }
2203
- // A retry-intended note with no retry-issued: the tick died mid-retry
2204
- // and the trigger may or may not have gone out. NEVER re-trigger here —
2205
- // route to the verification pipeline and let content verification be
2206
- // the arbiter (design §1.5 R3).
2207
- if (has("publish: retry-intended")) {
2208
- if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
2209
- const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
2210
- if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
2211
- const m = msg.match(/publish: retry-intended\s+[0-9a-f]{40}\s+(\S+)/);
2212
- const intendedMs = m ? Date.parse(m[1]) : NaN;
2213
- const notBefore = new Date(Math.max(nowMs, (intendedMs > 0 ? intendedMs : nowMs) + retrySettleMs)).toISOString();
2214
- writePublishNote(db, taskId,
2215
- `publish: verification-requested ${derived.commit} attempt=${derived.attempt} not-before=${notBefore} — mirrored by scan-publish-unknown: a retry-intended note exists without retry-issued (the tick died mid-retry), so the trigger may or may not have gone out; never re-triggering here, content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
2216
- mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: notBefore, reason: "retry-intended-without-retry-issued" });
2217
- continue;
2218
- }
2219
- // Classified as dropped and awaiting the retry protocol. Exactly one
2220
- // retry, enforced from note events (design §1.5): a retry-issued
2221
- // anywhere in this task's history spends the budget — including a
2222
- // retry for an earlier unknown attempt on a reworked task. When the
2223
- // budget is spent the drop is terminal: ambiguous, human attention.
2224
- if (has("publish: dropped")) {
2225
- const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
2226
- if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
2227
- if (historyHas("publish: retry-issued")) {
2228
- writePublishNote(db, taskId,
2229
- `publish: ambiguous ${derived.commit} — retry budget exhausted: a publish retry was already issued for this task in an earlier attempt; not retrying blind. Parked for human attention.`);
2230
- try {
2231
- appendFileSync(derived.ledgerPath, JSON.stringify({
2232
- ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
2233
- commit: derived.commit, attempt: derived.attempt, agent_id: null,
2234
- applied_report: null, outcome: "unknown-classified",
2235
- detail: "retry budget exhausted — ambiguous, terminal",
2236
- }) + "\n");
2237
- } catch (e) { /* best-effort observability */ }
2238
- continue;
2239
- }
2240
- // Retry budget free: queue the retry protocol. The tick needs the
2241
- // classifier base to regenerate the publish diff byte-identically
2242
- // (compute-publish-diff.js --base), so ferry it from provenance.
2243
- // The in-lock re-check also needs the trigger anchor and the
2244
- // pre-trigger manifest baseline (design §1.9).
2245
- const retryProv = db.prepare(
2246
- `SELECT provenance_source_commit FROM projects WHERE id = ?`
2247
- ).get(derived.projectId);
2248
- const retryBase = (retryProv && retryProv.provenance_source_commit) || EMPTY_TREE;
2249
- const retryTrigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
2250
- retryDue.push({
2251
- task_id: taskId, commit: derived.commit, attempt: derived.attempt,
2252
- slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
2253
- ledger_path: derived.ledgerPath, base: retryBase,
2254
- trigger_ts: (retryTrigger && (retryTrigger.issued_at || retryTrigger.ts)) || null,
2255
- manifest_before: (retryTrigger && retryTrigger.manifest_before &&
2256
- typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
2257
- });
1830
+ // Already issued — the acknowledgement scan owns the task from here.
1831
+ if (has("publish: edit-issued")) {
1832
+ skip(taskId, "routed-to-ack-scan");
2258
1833
  continue;
2259
1834
  }
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.
1835
+ // A previous tick claimed this intent. Unexpired: a tick owns it.
1836
+ // Expired: the tick died mid-issuance — re-derive from the original
1837
+ // intent note and re-claim; the re-claiming tick checks the dead
1838
+ // attempt's version receipt before issuing, never blindly re-issues.
2263
1839
  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;
1840
+ const cm = msg.match(/publish: publish-intent-claimed\s+(\S+)/);
1841
+ const expiryMs = cm ? Date.parse(cm[1]) : NaN;
2266
1842
  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
1843
  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; }
1844
+ if (!intentNote) {
1845
+ skip(taskId, "no-intent-note", "expired intent claim with no publish-requested note in history");
1846
+ continue;
1847
+ }
1848
+ const fresh = latestPublishNote(db, taskId);
1849
+ if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
2272
1850
  claimForIntent(taskId, intentNote, true, latest);
2273
1851
  continue;
2274
1852
  }
1853
+ // The workflow's parked intent.
2275
1854
  if (has("publish: publish-requested")) {
2276
1855
  claimForIntent(taskId, latest, false, latest);
2277
1856
  continue;
2278
1857
  }
2279
- // A previous tick claimed this task for classification.
2280
- // A previous tick claimed this task for classification.
2281
- if (has("publish: unknown-recovery-claimed")) {
2282
- const m = msg.match(/publish: unknown-recovery-claimed\s+(\S+)/);
2283
- const expiryMs = m ? Date.parse(m[1]) : NaN;
2284
- if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
2285
- // Expired claim: the tick died mid-classification. Re-derive from
2286
- // the original unknown note and re-claim.
2287
- const parkNote = notes.find((n) => /publish outcome unknown/i.test(n.message));
2288
- if (!parkNote) { skip(taskId, "no-unknown-note", "expired claim with no unknown park note in history"); continue; }
2289
- const fresh = latestPublishNote(db, taskId);
2290
- if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
2291
- claimForClassification(taskId, notes, parkNote);
2292
- continue;
2293
- }
2294
- // The initial unknown park note. Quiescence first: the classifier must
2295
- // never read a manifest younger than 30 minutes past the park.
2296
- if (/publish outcome unknown/i.test(msg)) {
2297
- const parkMs = Date.parse(latest.timestamp);
2298
- if (!(parkMs > 0) || nowMs < parkMs + quiesceMs) {
2299
- waiting.push({ task_id: taskId, parked_at: latest.timestamp });
2300
- continue;
2301
- }
2302
- // Re-check freshness inside the transaction: the latest publish:
2303
- // note must still be this unknown note.
2304
- const fresh = latestPublishNote(db, taskId);
2305
- if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
2306
- claimForClassification(taskId, notes, latest);
2307
- continue;
2308
- }
2309
- skip(taskId, "unrecognized-publish-note");
1858
+ skip(taskId, "unrecognized-publish-note",
1859
+ `latest publish note is not part of the 0.14.6 machine — stays parked for human attention: ${msg.slice(0, 120)}`);
2310
1860
  }
2311
1861
  db.exec("COMMIT");
2312
1862
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2313
1863
 
2314
- return { waiting, due, intent, retry_due: retryDue, mirrored, skipped, requeued };
1864
+ return { intent, skipped, requeued };
2315
1865
  };
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.
1866
+ // Record a one-party intent issuance (0.14.6 §1.2): the session-carrying
1867
+ // tick worker calls this exactly once per artifact_edit call that completed
1868
+ // without error and without an explicit refusal.
2323
1869
  //
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).
1870
+ // Args: { task_id, claim_expiry, edit_outcome: "accepted"|"refused",
1871
+ // refusal_text? } — omit refusal_text unless refused.
2333
1872
  //
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.
1873
+ // `accepted` writes the issuer-stamped `submitted` ledger entry (carrying
1874
+ // the attempt's version) and mirrors `publish: edit-issued` — the ack scan
1875
+ // claims the task on a later tick and evaluates acknowledgement evidence.
1876
+ // `refused` writes `rejected` and the terminal `publish: publish-refused`
1877
+ // note — no re-issue, ever.
2338
1878
  //
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).
1879
+ // There is deliberately NO outcome for an inconclusive edit (timeout,
1880
+ // transport error, tool unavailable): the tick records NOTHING then — the
1881
+ // claim expires, the next scan re-claims, and the re-claiming tick's version
1882
+ // check decides. An unrecorded outcome is the only honest record of an
1883
+ // unknown one.
2351
1884
  //
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).
1885
+ // The claim_expiry is a compare-and-swap: the record lands only while the
1886
+ // latest publish: note is still this tick's intent claim. A stale tick gets
1887
+ // { recorded: false } and writes nothing.
1888
+ // Shared preamble for the tick worker's claim-guarded record commands
1889
+ // (record-intent-issuance, record-intent-unissuable, 2026-09-20 REVIEW):
1890
+ // verifies the CAS claim, resolves the project's deploy slug, reads the
1891
+ // task's publish ledger, and locates the latest publish-intent entry.
1892
+ // Returns { ok: false, reason } when the command must not record, else
1893
+ // { ok: true, slug, ledgerPath, intentEntry, commit }. Callers run inside
1894
+ // their own BEGIN/COMMIT transaction and ROLLBACK on { ok: false }.
1895
+ function resolveClaimedIntent(db, ctx, taskId, claimExpiry) {
1896
+ // CAS: the latest publish: note must still be this tick's claim.
1897
+ const latest = latestPublishNote(db, taskId);
1898
+ if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
1899
+ !latest.message.includes(claimExpiry)) {
1900
+ return { ok: false, reason: "claim no longer current — another tick owns this task" };
1901
+ }
1902
+ const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
1903
+ const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
1904
+ const slug = project && project.deploy_slug;
1905
+ if (!slug) return { ok: false, reason: "project has no deploy_slug" };
1906
+ const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
1907
+ if (!ledger.ok) return { ok: false, reason: ledger.reason };
1908
+ const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
1909
+ if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
1910
+ return { ok: false, reason: "no usable publish-intent ledger entry for this task" };
1911
+ }
1912
+ return { ok: true, slug, ledgerPath: ledger.ledgerPath, intentEntry, commit: intentEntry.commit };
1913
+ }
1914
+
2355
1915
  commands["record-intent-issuance"] = (db, args, ctx) => {
2356
1916
  const taskId = args.task_id;
2357
1917
  const claimExpiry = args.claim_expiry;
2358
1918
  const editOutcome = args.edit_outcome;
2359
1919
  if (!taskId) throw usageError("task_id is required.");
2360
1920
  if (!claimExpiry) throw usageError("claim_expiry is required.");
2361
- if (!["accepted", "recovered", "refused"].includes(editOutcome)) {
1921
+ if (!["accepted", "refused"].includes(editOutcome)) {
2362
1922
  throw usageError(`unknown edit outcome: ${editOutcome}.`);
2363
1923
  }
2364
1924
 
2365
1925
  db.exec("BEGIN");
2366
1926
  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)) {
1927
+ const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
1928
+ if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
1929
+ const { slug, ledgerPath, intentEntry, commit } = pre;
1930
+ const publishAttempt = intentEntry.publish_attempt;
1931
+ const version = intentEntry.version;
1932
+ if (!Number.isInteger(publishAttempt) || publishAttempt < 1 ||
1933
+ version !== derivePublishVersion(commit, publishAttempt)) {
2371
1934
  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" };
1935
+ return { recorded: false, reason: "intent entry carries no usable publish_attempt/version — refusing to record" };
2397
1936
  }
2398
1937
  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
1938
  const ledgerLine = {
2432
1939
  ts, task_id: taskId, workflow: "intent-issuance", slug,
2433
- commit, attempt, agent_id: null, applied_report: null,
1940
+ commit, attempt: intentEntry.attempt || null, publish_attempt: publishAttempt, version,
1941
+ agent_id: null, applied_report: null,
2434
1942
  issuer: "tick-worker",
2435
1943
  diff_sha256: intentEntry.diff_sha256 || null,
2436
- manifest_before: intentEntry.manifest_before || null,
2437
- freshness_evidence: freshnessEvidence,
1944
+ issued_at: editOutcome === "accepted" ? ts : null,
2438
1945
  };
2439
1946
  let note;
2440
1947
  if (editOutcome === "refused") {
2441
1948
  const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
2442
1949
  ledgerLine.outcome = "rejected";
2443
- ledgerLine.issued_at = null;
2444
1950
  ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}`;
2445
1951
  // Terminal: parked for human attention. "publish: publish-refused"
2446
1952
  // is registered in lib/publish-note-vocabulary.js (the closed
2447
1953
  // 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.`;
1954
+ note = `publish: publish-refused ${commit} attempt=${publishAttempt} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
2449
1955
  } else {
2450
1956
  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.`;
1957
+ ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt";
1958
+ note = `publish: edit-issued ${commit} attempt=${publishAttempt} version=${version} — issued directly by the session-carrying tick worker (one-party publish). The acknowledgement scan owns the task from here: exact version string, 30-minute window.`;
2456
1959
  }
2457
1960
  try {
2458
1961
  appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
@@ -2460,29 +1963,11 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
2460
1963
  db.exec("ROLLBACK");
2461
1964
  return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
2462
1965
  }
2463
- writePublishNote(db, taskId, note, ts);
1966
+ writeGuardedPublishNote(db, taskId, note, ts);
2464
1967
  db.exec("COMMIT");
2465
- return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, note };
1968
+ return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, publish_attempt: publishAttempt, note };
2466
1969
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2467
1970
  };
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
1971
  commands["record-intent-unissuable"] = (db, args, ctx) => {
2487
1972
  const taskId = args.task_id;
2488
1973
  const claimExpiry = args.claim_expiry;
@@ -2493,38 +1978,10 @@ commands["record-intent-unissuable"] = (db, args, ctx) => {
2493
1978
 
2494
1979
  db.exec("BEGIN");
2495
1980
  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
- }
1981
+ const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
1982
+ if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
1983
+ const { slug, ledgerPath, intentEntry, commit } = pre;
2526
1984
  const ts = now();
2527
- const commit = intentEntry.commit;
2528
1985
  const attempt = intentEntry.attempt || null;
2529
1986
  const ledgerLine = {
2530
1987
  ts, task_id: taskId, workflow: "intent-issuance", slug,
@@ -2544,197 +2001,451 @@ commands["record-intent-unissuable"] = (db, args, ctx) => {
2544
2001
  // lib/publish-note-vocabulary.js (the closed registry — the writer-side
2545
2002
  // guard pattern from D7).
2546
2003
  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);
2004
+ writeGuardedPublishNote(db, taskId, note, ts);
2548
2005
  db.exec("COMMIT");
2549
2006
  return { recorded: true, task_id: taskId, commit, note };
2550
2007
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2551
2008
  };
2552
-
2553
- // Record the unknown-recovery classifier's decision (2026-09-18, blocker
2554
- // 15). The tick ferries: it runs lib/classify-publish-absence.js for each
2555
- // scan-publish-unknown "due" entry and passes the classifier's JSON here.
2556
- // Code decides: every note the decision implies is written here, atomically,
2557
- // never free-formed by the tick.
2009
+ // Acknowledgement scan (0.14.6 §1.3): evaluates acknowledgement evidence
2010
+ // for issued edits. Eric's contract: exact version acknowledgement is the
2011
+ // SOLE positive completion criterion — "If we hear that the artifact
2012
+ // acknowledges our version, that's it. We don't verify against content."
2558
2013
  //
2559
- // Args: { task_id, claim_expiry, decision } where decision is the
2560
- // classifier's { ok, decision, reasons, details } JSON.
2014
+ // Per parked task whose latest publish: note is `publish: edit-issued`:
2015
+ // - the artifact's on-disk version receipt
2016
+ // (<spaces>/ts-spaces/<slug>/.crew-publish-version-<task_id>) equals ANY issued
2017
+ // version for this task+commit (a late earlier attempt counts), OR a
2018
+ // version-echoing builder report (outcome acknowledged) names one
2019
+ // → acknowledged: provenance stamped (certifying the request was
2020
+ // acknowledged, not byte equality), terminal
2021
+ // `publish: version-acknowledged`, task re-queued.
2022
+ // - else a builder report (outcome refused) names an issued version
2023
+ // → refused: terminal `publish: publish-refused`.
2024
+ // - else the 30-minute window from the LATEST issuance is still open
2025
+ // → skipped (window-open).
2026
+ // - else the 2-hour total budget from the FIRST issuance is not exhausted
2027
+ // → reissued: a fresh intent is staged with a fresh version,
2028
+ // immediately claimable (no backoff); the NEXT tick's intent scan
2029
+ // claims it. This tick issues nothing.
2030
+ // - else → version-timeout: terminal `publish: version-timeout`.
2561
2031
  //
2562
- // The claim_expiry is a compare-and-swap: the record lands only while the
2563
- // latest publish: note is still this tick's claim. A stale tick (its claim
2564
- // expired and a newer tick re-claimed) gets { recorded: false } and writes
2565
- // nothing.
2032
+ // One atomic transaction per scan. The command is fully code-local — no
2033
+ // claim notes, no tick mediation. `spaces_root` arg overrides the ts-spaces
2034
+ // root (tests); production reads ~/workspace/ts-spaces.
2566
2035
  //
2567
- // Routing (design §1.4/§1.5):
2568
- // verified → publish: verification-requested (no not-before: the
2569
- // content is already the new state) + unknown-resolved
2570
- // ledger entry; the verification scan claims it.
2571
- // provably-dropped → publish: dropped (the next scan queues the retry
2572
- // protocol). No ledger resolution — the attempt is
2573
- // still logically unknown.
2574
- // applied-not-built / ambiguous
2575
- // → publish: ambiguous (terminal) + unknown-classified
2576
- // ledger entry. Parked for human attention.
2577
- // deferred → no note: the claim expires and the next scan
2578
- // re-claims. A deferred classification is not a verdict.
2579
- // superseded → publish: superseded (terminal) + unknown-classified
2580
- // ledger entry. HEAD moved; recovery aborted.
2581
- commands["record-unknown-classification"] = (db, args, ctx) => {
2582
- const taskId = args.task_id;
2583
- const claimExpiry = args.claim_expiry;
2584
- const decision = args.decision;
2585
- if (!taskId) throw usageError("task_id is required.");
2586
- if (!claimExpiry) throw usageError("claim_expiry is required.");
2587
- if (!decision || typeof decision !== "object") throw usageError("decision is required (the classifier's JSON).");
2588
- const verdict = decision.decision;
2589
- const valid = ["verified", "provably-dropped", "applied-not-built", "ambiguous", "deferred", "superseded"];
2590
- if (!valid.includes(verdict)) throw usageError(`unknown classifier decision: ${verdict}.`);
2036
+ // Returns { acknowledged, refused, reissued, timeouts, skipped }.
2037
+ commands["scan-ack-pending"] = (db, args, ctx) => {
2038
+ const crewHome = ctx.crewHome;
2039
+ const nowMs = Date.now();
2040
+ // (2026-09-20, Eric's call) 30-minute acknowledgement window per attempt,
2041
+ // 2-hour TOTAL budget from the FIRST issuance of the commit — then terminal
2042
+ // version-timeout. No backoff: a reissue staged at window expiry is
2043
+ // immediately claimable; the window expiry is the only spacing. The window
2044
+ // anchors at the LATEST issuance (each attempt gets its own window); the
2045
+ // budget anchors at the FIRST (the whole publish effort gets one budget,
2046
+ // however many attempts fit). The 30-minute window is evidence-set: room
2047
+ // #25's one healthy ack landed ~15-17 minutes after issuance — the window
2048
+ // is ~2x that plus margin. The ledger records issued_at and first-seen ack
2049
+ // for every attempt, so the real distribution is measurable and the window
2050
+ // is tunable from data, not from a placeholder.
2051
+ const ACK_WINDOW_MS = 30 * 60 * 1000;
2052
+ const TOTAL_BUDGET_MS = 2 * 60 * 60 * 1000;
2053
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2054
+ const spacesRoot = (args && args.spaces_root) || join(homedir(), "workspace", "ts-spaces");
2055
+ // The release running this scan must provably be the active release
2056
+ // before it stamps acknowledgement claims — fail the whole scan closed
2057
+ // on a stale cron body or mid-deploy swap.
2058
+ const releaseName = resolveActiveRelease(crewHome);
2059
+ const computeDiffScript = new URL("./compute-publish-diff.js", import.meta.url).pathname;
2060
+
2061
+ const acknowledged = [];
2062
+ const refused = [];
2063
+ const reissued = [];
2064
+ const timeouts = [];
2065
+ const skipped = [];
2066
+ const skip = (taskId, reason, detail) =>
2067
+ skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
2068
+
2069
+ const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
2591
2070
 
2592
2071
  db.exec("BEGIN");
2593
2072
  try {
2594
- // CAS: the latest publish: note must still be this tick's claim.
2595
- const latest = latestPublishNote(db, taskId);
2596
- if (!latest || !latest.message.includes("publish: unknown-recovery-claimed") ||
2597
- !latest.message.includes(claimExpiry)) {
2598
- db.exec("ROLLBACK");
2599
- return { recorded: false, reason: "claim no longer current — another tick owns this task" };
2600
- }
2601
- const derived = derivePublishUnknownAttempt(db, ctx.crewHome, taskId);
2602
- if (!derived.ok) {
2603
- db.exec("ROLLBACK");
2604
- return { recorded: false, reason: derived.reason };
2605
- }
2606
- const reasons = Array.isArray(decision.reasons) ? decision.reasons.join("; ") : "";
2607
- const ts = now();
2608
-
2609
- const appendLedger = (outcome, detail) => {
2073
+ for (const row of parked) {
2074
+ const taskId = row.task_id;
2075
+ const latest = latestPublishNote(db, taskId);
2076
+ if (!latest || !latest.message.includes("publish: edit-issued")) { skip(taskId, "not-issued"); continue; }
2077
+ const m = latest.message.match(/publish:\s*edit-issued\s+([0-9a-f]{40})\s+attempt=(\d+)/);
2078
+ if (!m) {
2079
+ skip(taskId, "edit-issued-unparseable",
2080
+ "the edit-issued note carries no commit hash + attempt=N — stays parked for human attention");
2081
+ continue;
2082
+ }
2083
+ const commit = m[1];
2084
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
2085
+ const project = task && db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
2086
+ const slug = project && project.deploy_slug;
2087
+ if (!slug) { skip(taskId, "no-deploy-slug"); continue; }
2088
+ const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
2089
+ if (!ledger.ok) { skip(taskId, "ledger-unreadable", ledger.reason); continue; }
2090
+ // The issuance record: issuer-stamped submitted entries with
2091
+ // derivation-verified versions. A version that fails the derivation is
2092
+ // not this machine's — the whole attempt is unusable, fail closed.
2093
+ const submittedAll = findSubmittedEntries(ledger.entries, commit);
2094
+ if (submittedAll.length === 0) {
2095
+ skip(taskId, "no-issuance-record",
2096
+ "no issuer-stamped submitted ledger entry for this commit — failing closed; stays parked for human attention");
2097
+ continue;
2098
+ }
2099
+ // The ack version is derived deterministically from (task, commit,
2100
+ // attempt) — §1.1. An entry whose version fails the derivation is a
2101
+ // phantom claim: it never binds (D2). A corrupt entry poisons only
2102
+ // itself — it is quarantined while valid entries still drive the
2103
+ // windows and the budget, so one bad ledger line can never park the
2104
+ // whole record before the budget is evaluated. Only
2105
+ // derivation-verified entries anchor the clocks below.
2106
+ const attempts = [];
2107
+ const validEntries = [];
2108
+ for (const e of submittedAll) {
2109
+ const pa = verifiedSubmittedAttempt(taskId, e);
2110
+ if (pa === null) continue;
2111
+ if (!attempts.includes(pa)) attempts.push(pa);
2112
+ validEntries.push(e);
2113
+ }
2114
+ if (attempts.length === 0) {
2115
+ skip(taskId, "version-mismatch",
2116
+ "no submitted entry's version passes the deterministic derivation — the issuance record is unusable; stays parked for human attention");
2117
+ continue;
2118
+ }
2119
+ attempts.sort((a, b) => a - b);
2120
+ const expectedVersion = (a) => derivePublishVersion(commit, a);
2121
+
2122
+ // --- acknowledgement evidence (§1.3): the version receipt on disk, or
2123
+ // a version-echoing builder report. ANY issued version for this
2124
+ // task+commit counts — including a late earlier attempt's. The
2125
+ // receipt is scoped per task (`.crew-publish-version-<task_id>`):
2126
+ // two tasks of the same project can sit in `edit-issued`
2127
+ // concurrently (per-project simultaneity defaults to 2), and a
2128
+ // shared per-slug receipt would let each issuance overwrite the
2129
+ // other's acknowledgement — receipt ping-pong with no false ack.
2130
+ let diskVersion = null;
2610
2131
  try {
2611
- appendFileSync(derived.ledgerPath, JSON.stringify({
2612
- ts, task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
2613
- commit: derived.commit, attempt: derived.attempt, agent_id: null,
2614
- applied_report: null, outcome, detail,
2615
- }) + "\n");
2616
- } catch (e) { /* best-effort observability */ }
2617
- };
2618
-
2619
- if (verdict === "deferred") {
2620
- // No note: the claim expires, the next scan re-claims and retries the
2621
- // classification. A deferred classification is explicitly not a verdict.
2622
- db.exec("COMMIT");
2623
- return { recorded: true, decision: verdict, task_id: taskId, note: "deferred — claim left to expire" };
2624
- }
2625
-
2626
- let note;
2627
- if (verdict === "verified") {
2628
- note = `publish: verification-requested ${derived.commit} attempt=${derived.attempt} — unknown-recovery classifier verified the content already shows the new state (${reasons}); routed without re-trigger. Waiting on the manual read-back in docs/publish-verification.md.`;
2629
- writePublishNote(db, taskId, note, ts);
2630
- appendLedger("unknown-resolved",
2631
- `classifier verified: content shows the new state; routed to parent verification without re-trigger (${reasons})`);
2632
- } else if (verdict === "provably-dropped") {
2633
- note = `publish: dropped ${derived.commit} — unknown-recovery classifier: the trigger went out and the platform provably dropped the edit (${reasons}); queued for the retry protocol.`;
2634
- writePublishNote(db, taskId, note, ts);
2635
- // No ledger resolution: the attempt is still logically unknown until
2636
- // the retry protocol journals its outcome.
2637
- } else if (verdict === "superseded") {
2638
- note = `publish: superseded ${derived.commit} — unknown-recovery classifier: HEAD moved past the attempt commit (${reasons}); recovery aborted. Parked for human attention.`;
2639
- writePublishNote(db, taskId, note, ts);
2640
- appendLedger("unknown-classified", `classifier superseded — terminal (${reasons})`);
2641
- } else {
2642
- // applied-not-built, ambiguous: the classifier could not prove the
2643
- // drop. Terminal — parked for human attention, never retried blind.
2644
- note = `publish: ambiguous ${derived.commit} — unknown-recovery classifier could not prove the edit was dropped (${verdict}: ${reasons}); not retrying blind. Parked for human attention.`;
2645
- writePublishNote(db, taskId, note, ts);
2646
- appendLedger("unknown-classified", `classifier ${verdict} — terminal, no retry (${reasons})`);
2132
+ diskVersion = readFileSync(join(spacesRoot, slug, ".crew-publish-version-" + taskId), "utf8").trim() || null;
2133
+ if (diskVersion && !isPublishVersion(diskVersion)) diskVersion = null;
2134
+ } catch { diskVersion = null; }
2135
+ const reports = db.prepare(
2136
+ `SELECT version, outcome FROM builder_reports WHERE task_id = ?`
2137
+ ).all(taskId);
2138
+ // (2026-09-20 REVIEW) Disk is the SOLE positive evidence. The
2139
+ // builder-report "acknowledged" path was circular: the tick worker
2140
+ // knows the version (it was in the scan entry), so report_text
2141
+ // containing the version proves version knowledge, not artifact/platform
2142
+ // origin. A worker can fabricate an acknowledged report and cause
2143
+ // false provenance stamping. Builder reports are retained for
2144
+ // explicit REFUSALS only (negative evidence).
2145
+ const ackedAttempt = attempts.find((a) => diskVersion === expectedVersion(a));
2146
+ if (ackedAttempt !== undefined) {
2147
+ recordVersionAck(db, ctx, {
2148
+ taskId, projectId: project.id, commit, version: expectedVersion(ackedAttempt), via: "disk",
2149
+ });
2150
+ acknowledged.push({ task_id: taskId, commit, attempt: ackedAttempt, via: "disk", crew_release: releaseName });
2151
+ continue;
2152
+ }
2153
+ // Explicit refusal naming an issued version: terminal, no re-issue.
2154
+ const refusedAttempt = attempts.find((a) =>
2155
+ reports.some((r) => r.outcome === "refused" && r.version === expectedVersion(a)));
2156
+ if (refusedAttempt !== undefined) {
2157
+ writeGuardedPublishNote(db, taskId,
2158
+ `publish: publish-refused ${commit} attempt=${refusedAttempt} — the builder's report carried the ` +
2159
+ `attempt's exact version with an explicit refusal. No re-issue; parked for human attention.`,
2160
+ new Date(nowMs).toISOString());
2161
+ refused.push({ task_id: taskId, commit, attempt: refusedAttempt });
2162
+ continue;
2163
+ }
2164
+ // The acknowledgement window runs from the LATEST issuance of this
2165
+ // commit — a 30-minute window PER ATTEMPT (0.14.6 §1.3, design §3, as
2166
+ // re-set 2026-09-20). Anchoring at the earliest issuance would give
2167
+ // attempts 2+ no window at all: they are always issued after attempt
2168
+ // 1's window expired, so the scan would re-issue or time out
2169
+ // immediately after they were issued. Late acknowledgements for any
2170
+ // previously issued version are still accepted above; only the
2171
+ // re-issue/timeout clock follows the latest attempt.
2172
+ const latestIssuance = validEntries[validEntries.length - 1];
2173
+ const issuedAtMs = Date.parse(latestIssuance.issued_at || latestIssuance.ts);
2174
+ if (issuedAtMs > 0 && nowMs < issuedAtMs + ACK_WINDOW_MS) { skip(taskId, "window-open"); continue; }
2175
+ // An unparseable latest issuance is an UNKNOWN window — never call
2176
+ // it "window-open". The budget check below still runs, so a corrupt
2177
+ // latest timestamp cannot defer the terminal timeout forever; only
2178
+ // the re-issue is withheld (re-issuing into an unknown window would
2179
+ // risk a duplicate issuance the budget cannot yet justify).
2180
+ // Window expired. Budget remains → re-issue with a FRESH version;
2181
+ // budget exhausted → terminal version-timeout. The budget is measured
2182
+ // from the FIRST issuance of this commit: the whole publish effort
2183
+ // gets one 2-hour budget, however many 30-minute attempts fit inside
2184
+ // it. A fixed attempt cap would false-timeout a slow-but-healthy
2185
+ // artifact; a pure window with no budget would retry forever.
2186
+ const firstIssuance = validEntries[0];
2187
+ const firstIssuedAtMs = Date.parse(firstIssuance.issued_at || firstIssuance.ts);
2188
+ if (!(firstIssuedAtMs > 0)) {
2189
+ skip(taskId, "first-issuance-unparseable",
2190
+ "the first submitted entry's issued_at is unparseable — failing closed; stays parked for human attention");
2191
+ continue;
2192
+ }
2193
+ const maxAttempt = Math.max(...attempts);
2194
+ if (nowMs - firstIssuedAtMs >= TOTAL_BUDGET_MS) {
2195
+ writeGuardedPublishNote(db, taskId,
2196
+ `publish: version-timeout ${commit} — the 2-hour acknowledgement budget is exhausted ` +
2197
+ `(${maxAttempt} attempts, no version acknowledgement; unobserved within budget — the edit may ` +
2198
+ `still have landed; the outcome is unknown). Parked for human attention.`,
2199
+ new Date(nowMs).toISOString());
2200
+ timeouts.push({ task_id: taskId, commit, attempts: maxAttempt });
2201
+ continue;
2202
+ }
2203
+ if (!(issuedAtMs > 0)) {
2204
+ skip(taskId, "latest-issuance-unparseable",
2205
+ "the latest submitted entry's issued_at is unparseable — the acknowledgement window state is " +
2206
+ "unknown and the total budget is not exhausted; stays parked for human attention");
2207
+ continue;
2208
+ }
2209
+ const newAttempt = maxAttempt + 1;
2210
+ // Idempotency: a previous scan may already have prepared this
2211
+ // attempt. The guard is keyed on the SAME anchor the intent scan
2212
+ // claims on — the `publish: publish-requested <commit> attempt=N`
2213
+ // note — not the ledger line. The ledger append and the note write
2214
+ // are not atomic: a crash between them (or a rolled-back COMMIT)
2215
+ // leaves an orphan ledger line with no note, and a line-keyed guard
2216
+ // would skip it as "already prepared" until the budget forced the
2217
+ // terminal timeout. Keyed on the note, a torn prepare self-heals:
2218
+ // the orphan line is content-identical (deterministic version) and the
2219
+ // intent scan claims the re-appended line exactly once. Do NOT
2220
+ // reverse the write order: a note-without-line tear would park
2221
+ // permanently with no budget-expiry resolution.
2222
+ const prepareNote = db.prepare(
2223
+ `SELECT id FROM events WHERE task_id = ? AND type = 'note'
2224
+ AND message LIKE ? LIMIT 1`
2225
+ ).get(taskId, `publish: publish-requested ${commit} attempt=${newAttempt} %`);
2226
+ if (prepareNote) { skip(taskId, "reissue-already-prepared"); continue; }
2227
+ // The re-issue diff is recomputed against CURRENT stamped provenance
2228
+ // (or the empty tree): the ack scan stages the attempt the way the
2229
+ // workflow staged attempt 1. A query error is not "no provenance" —
2230
+ // fail closed.
2231
+ let base;
2232
+ try {
2233
+ const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
2234
+ base = (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit))
2235
+ ? prov.provenance_source_commit : EMPTY_TREE;
2236
+ } catch (e) {
2237
+ skip(taskId, "provenance-query-failed",
2238
+ `provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
2239
+ continue;
2240
+ }
2241
+ const repoPath = project.repo_path;
2242
+ if (!repoPath) { skip(taskId, "no-repo-path"); continue; }
2243
+ const newVersion = derivePublishVersion(commit, newAttempt);
2244
+ const diffPath = join(crewHome, ".publish-diffs", `${taskId}-a${newAttempt}.diff`);
2245
+ let diffJson = null;
2246
+ try {
2247
+ const out = execFileSync("node", [computeDiffScript,
2248
+ "--repo-path", repoPath, "--base", base, "--commit", commit,
2249
+ "--task-id", taskId, "--attempt", String(newAttempt), "--out", diffPath],
2250
+ { encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
2251
+ diffJson = JSON.parse(String(out).trim());
2252
+ } catch (e) {
2253
+ skip(taskId, "reissue-diff-failed", `diff recomputation failed: ${String((e && e.message) || e).slice(0, 200)}`);
2254
+ continue;
2255
+ }
2256
+ if (!diffJson || diffJson.ok !== true || diffJson.version !== newVersion ||
2257
+ !/^[0-9a-f]{64}$/.test(diffJson.sha256 || "")) {
2258
+ skip(taskId, "reissue-diff-unusable",
2259
+ "the recomputed diff is unusable (version mismatch or bad checksum) — stays parked for human attention");
2260
+ continue;
2261
+ }
2262
+ // No backoff: the reissue is immediately claimable by the next intent
2263
+ // scan. The window expiry is the only spacing — a slow artifact gets
2264
+ // its full window per attempt, and a dropped one is retried on the
2265
+ // next tick, not after an arbitrary extra wait.
2266
+ const ts = new Date(nowMs).toISOString();
2267
+ const intentLine = {
2268
+ ts, task_id: taskId, workflow: "ack-reissue", slug,
2269
+ commit, attempt: null, publish_attempt: newAttempt, version: newVersion,
2270
+ agent_id: null, applied_report: null,
2271
+ issuer: "tick-worker",
2272
+ diff_path: diffPath, diff_sha256: diffJson.sha256,
2273
+ files: Array.isArray(diffJson.files) ? diffJson.files : null,
2274
+ base, outcome: "publish-intent",
2275
+ detail: `ack scan re-issue: attempt ${maxAttempt}'s 30-minute window expired with no version acknowledgement; ` +
2276
+ `fresh version staged, immediately claimable (no backoff)`,
2277
+ };
2278
+ try {
2279
+ appendFileSync(ledger.ledgerPath, JSON.stringify(intentLine) + "\n");
2280
+ } catch (e) {
2281
+ skip(taskId, "reissue-ledger-failed", `cannot append publish ledger: ${e.message}`);
2282
+ continue;
2283
+ }
2284
+ writeGuardedPublishNote(db, taskId,
2285
+ `publish: publish-requested ${commit} attempt=${newAttempt} — ack scan re-issue attempt ${newAttempt}: ` +
2286
+ `no version acknowledgement within the 30-minute window; fresh version staged, immediately claimable ` +
2287
+ `(2-hour total budget from first issuance).`,
2288
+ ts);
2289
+ reissued.push({ task_id: taskId, commit, attempt: newAttempt });
2647
2290
  }
2648
2291
  db.exec("COMMIT");
2649
- return { recorded: true, decision: verdict, task_id: taskId, note };
2650
2292
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2293
+
2294
+ return { acknowledged, refused, reissued, timeouts, skipped };
2651
2295
  };
2652
2296
 
2653
- // Retry-protocol re-check routing (2026-09-18, blocker 15). The tick runs
2654
- // the retry protocol from inside the merge lock; its pre-trigger content
2655
- // re-read (lib/classify-publish-absence.js) can come back something other
2656
- // than provably-dropped. This command routes that decision — code-owned, so
2657
- // the tick never free-forms the state machine.
2297
+ // Record the artifact platform's report text for an issued edit (0.14.6
2298
+ // §1.4). The tick worker calls this after each artifact_edit call with the
2299
+ // result/refusal text verbatim.
2658
2300
  //
2659
- // Args: { task_id, decision } where decision is the classifier's JSON
2660
- // ({ ok:true, decision, reasons, details }).
2661
- // CAS: the latest publish: note must still be "publish: dropped" — the merge
2662
- // lock is the mutual exclusion; the CAS is the backstop against a tick that
2663
- // died after another writer moved the state.
2301
+ // Args: { task_id, version, outcome: "acknowledged"|"refused",
2302
+ // report_text }.
2664
2303
  //
2665
- // verified → publish: verification-requested (content is
2666
- // present now; no not-before) + unknown-resolved
2667
- // ledger entry.
2668
- // provably-dropped → no-op: the tick proceeds with the retry.
2669
- // applied-not-built / ambiguous → publish: ambiguous (terminal).
2670
- // superseded → publish: superseded (terminal).
2671
- // deferred → no-op: the drop note stays latest; the next scan
2672
- // re-queues the protocol and re-reads.
2673
- commands["record-retry-recheck"] = (db, args, ctx) => {
2674
- const crewHome = ctx?.crewHome;
2675
- if (!crewHome) throw usageError("crewHome is required (internal context).");
2676
- const taskId = String(args?.task_id ?? "").trim();
2304
+ // Mechanical guards (the command, not prose, decides):
2305
+ // - report_text must contain the EXACT version string, else rejected;
2306
+ // - the version must match an issued attempt for this task (derived, never
2307
+ // trusted from prose), else rejected.
2308
+ // A refused report naming an issued version terminal-parks the task at the
2309
+ // next ack scan (`publish: publish-refused`). (2026-09-20 REVIEW) The
2310
+ // "acknowledged" outcome is rejected: it was circular (the worker knows
2311
+ // the version, so the report proves nothing), and disk is the sole positive
2312
+ // evidence. The rows persist as evidence.
2313
+ commands["record-builder-report"] = (db, args, ctx) => {
2314
+ const taskId = args.task_id;
2315
+ const version = args.version;
2316
+ const outcome = args.outcome;
2317
+ const reportText = args.report_text;
2677
2318
  if (!taskId) throw usageError("task_id is required.");
2678
- const decision = args?.decision;
2679
- if (!decision || typeof decision !== "object" || decision.ok !== true) {
2680
- throw usageError("decision is required — pass the classifier's JSON (ok:true).");
2319
+ if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
2320
+ // (2026-09-20 REVIEW) "acknowledged" is rejected — the positive
2321
+ // builder-report path was circular. Disk is the sole positive evidence.
2322
+ if (outcome !== "refused") throw usageError(`unknown outcome: ${outcome} (only "refused" is accepted).`);
2323
+ if (typeof reportText !== "string" || !reportText.includes(version)) {
2324
+ throw usageError("report_text must contain the exact version string — the report is mechanically rejected otherwise.");
2681
2325
  }
2682
- const valid = ["verified", "provably-dropped", "applied-not-built", "ambiguous", "deferred", "superseded"];
2683
- if (!valid.includes(decision.decision)) {
2684
- throw usageError(`unknown classifier decision: ${decision.decision}.`);
2326
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
2327
+ if (!task) throw notFound(`unknown task: ${taskId}`);
2328
+ if (task.state !== "parked") {
2329
+ throw usageError(`task is not parked (state=${task.state}) — builder reports attach to parked publish tasks.`);
2685
2330
  }
2686
- requireTask(db, taskId);
2687
- const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
2688
- if (!derived.ok) throw usageError(`cannot derive the unknown attempt: ${derived.reason}.`);
2689
- const latest = latestPublishNote(db, taskId);
2690
- if (!latest || !latest.message.includes("publish: dropped")) {
2691
- return {
2692
- recorded: false,
2693
- reason: "latest publish: note is no longer publish: dropped — another writer moved the state; recorded nothing.",
2694
- };
2331
+ const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
2332
+ const slug = project && project.deploy_slug;
2333
+ if (!slug) throw usageError("project has no deploy_slug.");
2334
+ const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
2335
+ if (!ledger.ok) throw usageError(ledger.reason);
2336
+ let matched = null;
2337
+ for (const e of ledger.entries) {
2338
+ if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
2339
+ if (verifiedSubmittedAttempt(taskId, e) === null) continue;
2340
+ if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
2341
+ }
2342
+ if (!matched) {
2343
+ throw usageError("version does not match any issued attempt for this task — the report is rejected.");
2695
2344
  }
2696
- const d = decision.decision;
2697
- const reasons = Array.isArray(decision.reasons) ? decision.reasons.join("; ") : "";
2698
- const commit = derived.commit;
2699
- if (d === "provably-dropped" || d === "deferred") {
2700
- // No state change: the tick proceeds (dropped) or the next scan
2701
- // re-queues the protocol (deferred).
2702
- return { recorded: false, reason: `re-check decision ${d}: no state change; the drop note stands.` };
2703
- }
2704
- if (d === "verified") {
2705
- writePublishNote(db, taskId,
2706
- `publish: verification-requested ${commit} attempt=${derived.attempt} — unknown-recovery retry re-check: the content is present now, so no retry was needed. Waiting on the manual read-back in docs/publish-verification.md.`);
2707
- try {
2708
- appendFileSync(derived.ledgerPath, JSON.stringify({
2709
- ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
2710
- commit, attempt: derived.attempt, agent_id: null, applied_report: null,
2711
- outcome: "unknown-resolved",
2712
- detail: `retry re-check verified (was: ${reasons})`,
2713
- }) + "\n");
2714
- } catch (e) { /* best-effort observability */ }
2715
- return { recorded: true, decision: d, routed: "verification-requested" };
2716
- }
2717
- // applied-not-built / ambiguous → terminal; superseded → terminal.
2718
- const terminal = d === "superseded" ? "superseded" : "ambiguous";
2719
- const noteBody = terminal === "superseded"
2720
- ? `publish: superseded ${commit} — unknown-recovery retry re-check: the retry target moved (${reasons}); not retrying a stale commit. Parked for human attention.`
2721
- : `publish: ambiguous ${commit} — unknown-recovery retry re-check: ${reasons}; not retrying blind. Parked for human attention.`;
2722
- writePublishNote(db, taskId, noteBody);
2345
+ db.prepare(
2346
+ `INSERT INTO builder_reports (id, task_id, version, outcome, report_text, created_at)
2347
+ VALUES (?, ?, ?, ?, ?, ?)`
2348
+ ).run(uuid(), taskId, version, outcome, reportText.slice(0, 20000), now());
2349
+ return {
2350
+ ok: true, task_id: taskId, version, outcome,
2351
+ commit: matched.commit, publish_attempt: matched.publish_attempt,
2352
+ };
2353
+ };
2354
+
2355
+ // Record a positive version acknowledgement found off the ack scan's path
2356
+ // (0.14.6 §1.3): the re-claiming tick read the dead attempt's version receipt
2357
+ // from the artifact's disk. The version must name an issued attempt for this
2358
+ // task (derived, never trusted) — ANY issued version for the task counts,
2359
+ // including a late earlier attempt's. (2026-09-20 REVIEW) The reclaim path
2360
+ // also accepts the exact disk version for the currently claimed intent when
2361
+ // no submitted entry exists (dead tick issued but died before recording).
2362
+ // Stamps provenance, writes the terminal `publish: version-acknowledged`
2363
+ // note, re-queues the task.
2364
+ //
2365
+ // Args: { task_id, version, via: "disk" }.
2366
+ commands["record-version-ack"] = (db, args, ctx) => {
2367
+ const taskId = args.task_id;
2368
+ const version = args.version;
2369
+ const via = args.via;
2370
+ if (!taskId) throw usageError("task_id is required.");
2371
+ if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
2372
+ if (via !== "disk") throw usageError(`unknown via: ${via} (only "disk" is accepted).`);
2373
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
2374
+ if (!task) throw notFound(`unknown task: ${taskId}`);
2375
+ if (task.state !== "parked") {
2376
+ throw usageError(`task is not parked (state=${task.state}) — version acks attach to parked publish tasks.`);
2377
+ }
2378
+ const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
2379
+ const slug = project && project.deploy_slug;
2380
+ if (!slug) throw usageError("project has no deploy_slug.");
2381
+ const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
2382
+ if (!ledger.ok) throw usageError(ledger.reason);
2383
+ let matched = null;
2384
+ for (const e of ledger.entries) {
2385
+ if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
2386
+ if (verifiedSubmittedAttempt(taskId, e) === null) continue;
2387
+ if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
2388
+ }
2389
+ // (2026-09-20 REVIEW) Reclaim path: a tick that died after issuing but
2390
+ // before recording has no submitted entry. The code-written active claim
2391
+ // (publish-intent-claimed note) + the exact on-disk derived version for
2392
+ // the claimed intent's commit+attempt proves the dead issuance occurred.
2393
+ // Arbitrary/non-current/non-derived versions are still rejected.
2394
+ if (!matched) {
2395
+ const claimed = latestPublishNote(db, taskId);
2396
+ const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
2397
+ if (claimed && claimed.message.includes("publish: publish-intent-claimed") &&
2398
+ intentEntry && /^[0-9a-f]{40}$/.test(intentEntry.commit || "") &&
2399
+ Number.isInteger(intentEntry.publish_attempt) && intentEntry.publish_attempt >= 1 &&
2400
+ derivePublishVersion(intentEntry.commit, intentEntry.publish_attempt) === version &&
2401
+ via === "disk") {
2402
+ matched = {
2403
+ commit: intentEntry.commit,
2404
+ publish_attempt: intentEntry.publish_attempt,
2405
+ // No submitted entry — the reclaim synthesizes the ack from the
2406
+ // claimed intent. The issued_at is unknown; the ack is timeless.
2407
+ };
2408
+ }
2409
+ }
2410
+ if (!matched) {
2411
+ throw usageError("version does not match any issued attempt for this task — the ack is rejected.");
2412
+ }
2413
+ db.exec("BEGIN");
2723
2414
  try {
2724
- appendFileSync(derived.ledgerPath, JSON.stringify({
2725
- ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
2726
- commit, attempt: derived.attempt, agent_id: null, applied_report: null,
2727
- outcome: "unknown-classified",
2728
- detail: `retry re-check ${d}: terminal (${reasons})`,
2729
- }) + "\n");
2730
- } catch (e) { /* best-effort observability */ }
2731
- return { recorded: true, decision: d, routed: terminal };
2415
+ const res = recordVersionAck(db, ctx, {
2416
+ taskId, projectId: project.id, commit: matched.commit, version, via,
2417
+ });
2418
+ db.exec("COMMIT");
2419
+ return res;
2420
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
2732
2421
  };
2733
2422
 
2734
- // Composite: record a phase's session verdict AND its event in one
2735
- // transaction. This is the operation that used to crash the workflow when the
2736
- // two writes went to different contracts: the session stored, the event
2737
- // rejected, the run dead. One transaction, one vocabulary, no partial state.
2423
+ // The closed publish-note writer's prose entry point (0.14.6 §1.5): the
2424
+ // ONLY way for tick prose to write a state-carrying `publish: <verb>` note.
2425
+ // The verb is asserted against the closed registry in
2426
+ // lib/publish-note-vocabulary.js — prose cannot mint new `publish: <verb>`
2427
+ // states; an unregistered verb is a usage error, never a note.
2428
+ //
2429
+ // Args: { task_id, note } — the full note text.
2430
+ commands["record-publish-note"] = (db, args) => {
2431
+ const taskId = args.task_id;
2432
+ const note = String(args.note || "");
2433
+ if (!taskId) throw usageError("task_id is required.");
2434
+ if (!note) throw usageError("note is required.");
2435
+ const task = db.prepare("SELECT id, state FROM tasks WHERE id = ?").get(taskId);
2436
+ if (!task) throw notFound(`unknown task: ${taskId}`);
2437
+ if (task.state !== "parked") {
2438
+ throw usageError(`task is not parked (state=${task.state}) — publish notes attach to parked tasks.`);
2439
+ }
2440
+ let verb;
2441
+ try {
2442
+ verb = assertWritablePublishNote(note);
2443
+ } catch (e) {
2444
+ throw usageError(e.message);
2445
+ }
2446
+ writePublishNote(db, taskId, note);
2447
+ return { ok: true, task_id: taskId, verb };
2448
+ };
2738
2449
  commands["record-phase"] = (db, args) => {
2739
2450
  if (!args.task_id) throw usageError("task_id is required.");
2740
2451
  requireTask(db, args.task_id);
@@ -2823,7 +2534,7 @@ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType
2823
2534
  // Symlink-only resolution: the stamp records the crew home's ACTIVE
2824
2535
  // release — it must not depend on which copy of this file is running.
2825
2536
  // The strict self-path cross-check lives in resolveActiveRelease, used by
2826
- // scan-verification-pending; requiring it here silently skipped the stamp
2537
+ // the ack/intent scans; requiring it here silently skipped the stamp
2827
2538
  // for every setup path that invokes crew-api.js outside the release tree
2828
2539
  // (room #15: create-project through the npm package's own lib/).
2829
2540
  try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
@@ -3069,7 +2780,7 @@ commands["update-config"] = (db, args) => {
3069
2780
  // never touches another's row.
3070
2781
  function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
3071
2782
  // task_id invariant (2026-09-19 REVIEW): task_id is set by every REAL
3072
- // publish (lib/verify-publish.js and the workflow provenance-refresh paths)
2783
+ // publish (the version ack scan and the workflow provenance-refresh paths)
3073
2784
  // and is null for initial stamps (tryStampInitialProvenance) and for
3074
2785
  // publish-base advances (lib/advance-publish-base.js). So
3075
2786
  // provenance.task_id != null ⟺ a real publish has stamped this project.
@@ -3164,12 +2875,12 @@ commands["migrate"] = (db, args) => {
3164
2875
 
3165
2876
  // import-json: one-time import from a dashboard exportCrewState JSON payload.
3166
2877
  // Used for the live migration (the artifact's app.db is never opened directly).
3167
- // Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...] } }
2878
+ // Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...], builder_reports: [...] } }
3168
2879
  // Or pipe the JSON via stdin.
3169
2880
  commands["import-json"] = (db, args) => {
3170
2881
  const data = args.data;
3171
2882
  if (!data || typeof data !== "object") throw usageError("data must be the exportCrewState JSON object.");
3172
- const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events"];
2883
+ const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events", "builder_reports"];
3173
2884
  const counts = {};
3174
2885
  db.exec("BEGIN");
3175
2886
  try {