muse-crew 0.14.4 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/docs/decisions/publish-path.md +53 -0
- package/docs/guide.md +2 -2
- package/docs/publish-unknown-recovery.md +10 -3
- package/docs/publish-verification.md +117 -61
- package/docs/release-integrity.md +5 -3
- package/docs/reviews/critic-0145.md +106 -0
- package/lib/AGENTS.md +2 -1
- package/lib/check-intent-freshness.js +101 -0
- package/lib/compute-publish-diff.js +34 -13
- package/lib/crew-api.js +437 -18
- package/lib/publish-note-vocabulary.js +20 -0
- package/lib/retry-publish.js +45 -33
- package/lib/verify-publish.js +11 -9
- package/package.json +1 -1
- package/seed/cron-body-template.md +50 -16
- package/workflows/bugfix.js +96 -627
- package/workflows/chore.js +96 -627
- package/workflows/crew-dispatch.js +36 -0
- package/workflows/standard.js +104 -636
package/lib/crew-api.js
CHANGED
|
@@ -1711,10 +1711,10 @@ function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false
|
|
|
1711
1711
|
}
|
|
1712
1712
|
|
|
1713
1713
|
// The unknown-recovery state machine reads two note shapes: the machine's
|
|
1714
|
-
// own "publish: <transition>" notes, and the workflow's
|
|
1714
|
+
// own "publish: <transition>" notes, and the workflow's legacy (pre-2026-09-20)
|
|
1715
1715
|
// "Publish outcome unknown …" park note — which carries no "publish:"
|
|
1716
|
-
// marker
|
|
1717
|
-
//
|
|
1716
|
+
// marker, so the prefilter must match it explicitly. LIKE is ASCII
|
|
1717
|
+
// case-insensitive in SQLite.
|
|
1718
1718
|
const PUBLISH_NOTE_PREFILTER =
|
|
1719
1719
|
`(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
|
|
1720
1720
|
|
|
@@ -1728,28 +1728,29 @@ function latestPublishNote(db, taskId) {
|
|
|
1728
1728
|
).get(taskId) || null;
|
|
1729
1729
|
}
|
|
1730
1730
|
|
|
1731
|
-
// The trigger anchor for an unknown attempt: the
|
|
1732
|
-
// ledger entry for the same attempt (
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1731
|
+
// The trigger anchor for an unknown attempt: the ISSUER-WRITTEN "submitted"
|
|
1732
|
+
// ledger entry for the same attempt (2026-09-20, one-party worker-owned
|
|
1733
|
+
// publish: only the session-carrying tick worker performs issuance, and it
|
|
1734
|
+
// stamps its writes issuer="tick-worker"). The workflow never issues and
|
|
1735
|
+
// never writes "submitted" — a workflow-written "submitted" (issuer absent,
|
|
1736
|
+
// the pre-one-party shape) is a phantom claim and must NOT bind. Within one
|
|
1737
|
+
// attempt the anchor is the OLDEST by ts (D1: the bound instant is
|
|
1738
|
+
// issued_at || ts, the same fallback verify-publish.js uses).
|
|
1738
1739
|
// Returns the entry or null. A null means the trigger
|
|
1739
1740
|
// instant is unknowable (or the trigger was never issued) — classification
|
|
1740
1741
|
// cannot run; the task stays parked for human attention. Fail closed, never
|
|
1741
1742
|
// guess the anchor.
|
|
1742
1743
|
function findTriggerEntry(entries, commit, attempt) {
|
|
1743
|
-
// D1 (2026-09-19; entry_kind cut 2026-09-20
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
// is the whole mechanism.
|
|
1744
|
+
// D1 (2026-09-19; entry_kind cut 2026-09-20; issuer anchor 2026-09-20):
|
|
1745
|
+
// the anchor is the OLDEST tick-worker "submitted" entry by ts and the
|
|
1746
|
+
// bound instant is its issued_at || ts — the same fallback
|
|
1747
|
+
// verify-publish.js uses. A "submitted" without issuer (the retired
|
|
1748
|
+
// workflow write) is not an issuance and never binds.
|
|
1749
1749
|
const matches = [];
|
|
1750
1750
|
for (let i = 0; i < entries.length; i++) {
|
|
1751
1751
|
const e = entries[i];
|
|
1752
|
-
if (e.outcome === "submitted" && e.
|
|
1752
|
+
if (e.outcome === "submitted" && e.issuer === "tick-worker" &&
|
|
1753
|
+
e.commit === commit &&
|
|
1753
1754
|
(e.attempt || null) === (attempt || null)) {
|
|
1754
1755
|
matches.push(e);
|
|
1755
1756
|
}
|
|
@@ -1906,6 +1907,13 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
|
|
|
1906
1907
|
// "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
|
|
1907
1908
|
// "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
|
|
1908
1909
|
// verification-requested
|
|
1910
|
+
// "publish: publish-requested" (one-party → atomic 1h claim → intent bucket: the tick issues
|
|
1911
|
+
// intent, 2026-09-20 blocker 22) the edit directly in its own turn (no child).
|
|
1912
|
+
// An issuer-written "submitted" already present →
|
|
1913
|
+
// mirror verification-requested, NEVER re-issue.
|
|
1914
|
+
// "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it); expired:
|
|
1915
|
+
// re-claim → intent (the tick runs the
|
|
1916
|
+
// manifest-freshness check first)
|
|
1909
1917
|
// terminal notes (the closed registry in → terminal; skipped with the
|
|
1910
1918
|
// lib/publish-note-vocabulary.js — the reason named, never
|
|
1911
1919
|
// scan names the recognized note) "unrecognized"
|
|
@@ -1925,9 +1933,11 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
1925
1933
|
|
|
1926
1934
|
const waiting = [];
|
|
1927
1935
|
const due = [];
|
|
1936
|
+
const intent = [];
|
|
1928
1937
|
const retryDue = [];
|
|
1929
1938
|
const mirrored = [];
|
|
1930
1939
|
const skipped = [];
|
|
1940
|
+
const requeued = []; // F-A4: stale-base intents re-queued to the workflow's Publish
|
|
1931
1941
|
|
|
1932
1942
|
const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
|
|
1933
1943
|
|
|
@@ -1992,6 +2002,160 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
1992
2002
|
});
|
|
1993
2003
|
};
|
|
1994
2004
|
|
|
2005
|
+
// One-party publish intent (2026-09-20, blocker 22): the workflow parks at
|
|
2006
|
+
// intent — the merged change is staged as a checksummed diff and issuance
|
|
2007
|
+
// belongs to the session-carrying tick worker (no child — blocker 22
|
|
2008
|
+
// proved children cannot reach artifact_edit). The intent ledger entry
|
|
2009
|
+
// (outcome "publish-intent", issuer "workflow") is the durable anchor: the
|
|
2010
|
+
// tick verifies the staged diff's sha256 against it and regenerates from
|
|
2011
|
+
// base..commit when the file is missing. reclaimed=true when a previous
|
|
2012
|
+
// tick's claim expired: the tick runs the manifest-freshness check before
|
|
2013
|
+
// issuing, because the dead tick may have issued and died before
|
|
2014
|
+
// recording it — a re-claim never blindly re-issues.
|
|
2015
|
+
const claimForIntent = (taskId, intentNote, reclaimed, anchorNote) => {
|
|
2016
|
+
const m = intentNote.message.match(/publish: publish-requested\s+([0-9a-f]{40})\s+(\S+)/);
|
|
2017
|
+
if (!m) {
|
|
2018
|
+
skip(taskId, "intent-note-unparseable",
|
|
2019
|
+
"the publish-requested note carries no commit hash — stays parked for human attention");
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const commit = m[1];
|
|
2023
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2024
|
+
if (!task || task.state !== "parked") {
|
|
2025
|
+
skip(taskId, "not-parked", `task is not parked (state=${task && task.state}) — the intent claim requires a parked task`);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
2029
|
+
const slug = project && project.deploy_slug;
|
|
2030
|
+
if (!slug) {
|
|
2031
|
+
skip(taskId, "no-deploy-slug", "project has no deploy_slug — stays parked for human attention");
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
// (2026-09-20, critic-0145 F-R5) A null repo_path fails closed at
|
|
2035
|
+
// emission: the tick can neither regenerate nor verify the diff. The
|
|
2036
|
+
// task stays parked for human attention — no claim, no tick work.
|
|
2037
|
+
const repoPath = project && project.repo_path;
|
|
2038
|
+
if (!repoPath) {
|
|
2039
|
+
skip(taskId, "no-repo-path",
|
|
2040
|
+
"project has no repo_path — the intent diff cannot be regenerated or verified; fails closed at emission; stays parked for human attention");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
2044
|
+
let entries = [];
|
|
2045
|
+
try {
|
|
2046
|
+
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2047
|
+
.filter((l) => l.trim().length > 0)
|
|
2048
|
+
.map((l) => JSON.parse(l))
|
|
2049
|
+
.filter((e) => e.task_id === taskId);
|
|
2050
|
+
} catch (e) {
|
|
2051
|
+
skip(taskId, "ledger-unreadable", `cannot read publish ledger: ${e.message} — stays parked for human attention`);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
// Latest intent wins: a reworked task re-parks a fresh intent.
|
|
2055
|
+
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2056
|
+
if (!intentEntry) {
|
|
2057
|
+
skip(taskId, "no-intent-entry",
|
|
2058
|
+
"no publish-intent ledger entry for this task — the intent anchor is missing; stays parked for human attention");
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (intentEntry.commit !== commit) {
|
|
2062
|
+
skip(taskId, "intent-commit-mismatch",
|
|
2063
|
+
`intent ledger entry commits ${intentEntry.commit} but the park note names ${commit} — stays parked for human attention`);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (typeof intentEntry.diff_path !== "string" || !intentEntry.diff_path ||
|
|
2067
|
+
!/^[0-9a-f]{64}$/.test(intentEntry.diff_sha256 || "")) {
|
|
2068
|
+
skip(taskId, "intent-diff-unusable",
|
|
2069
|
+
"the intent entry carries no usable diff_path/diff_sha256 — stays parked for human attention");
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
// The tick died after issuing but before mirroring: an issuer-written
|
|
2073
|
+
// "submitted" for this commit+attempt exists. NEVER re-issue — mirror
|
|
2074
|
+
// the missing verification request and let content verification be the
|
|
2075
|
+
// arbiter (the same rule as the retry-intended crash window).
|
|
2076
|
+
const issued = entries.some((e) =>
|
|
2077
|
+
e.outcome === "submitted" && e.issuer === "tick-worker" &&
|
|
2078
|
+
e.commit === commit && (e.attempt || null) === (intentEntry.attempt || null));
|
|
2079
|
+
if (issued) {
|
|
2080
|
+
writePublishNote(db, taskId,
|
|
2081
|
+
`publish: verification-requested ${commit} attempt=${intentEntry.attempt} — mirrored by scan-publish-unknown: a tick-worker issuance entry exists without a mirrored request (the tick died between the writes). Waiting on the manual read-back in docs/publish-verification.md.`);
|
|
2082
|
+
mirrored.push({ task_id: taskId, commit, attempt: intentEntry.attempt || null, not_before: null, reason: "intent-issued-without-mirror" });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
// Freshness CAS: the latest publish: note must still be the note we
|
|
2086
|
+
// branched on — for a first claim that is the intent note; for a
|
|
2087
|
+
// re-claim it is the expired claim note.
|
|
2088
|
+
const fresh = latestPublishNote(db, taskId);
|
|
2089
|
+
if (!fresh || !anchorNote || fresh.message !== anchorNote.message) { skip(taskId, "raced"); return; }
|
|
2090
|
+
const manifestBefore = (intentEntry.manifest_before && typeof intentEntry.manifest_before === "object")
|
|
2091
|
+
? intentEntry.manifest_before : null;
|
|
2092
|
+
const claimedAt = new Date(nowMs).toISOString();
|
|
2093
|
+
const expiry = new Date(nowMs + claimLeaseMs).toISOString();
|
|
2094
|
+
writePublishNote(db, taskId,
|
|
2095
|
+
`publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit}; claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
|
|
2096
|
+
claimedAt);
|
|
2097
|
+
// The classifier-base convention: last verified publish, or the empty
|
|
2098
|
+
// tree on a first publish — the tick needs it to regenerate the diff
|
|
2099
|
+
// byte-identically when the staged file is missing. A query error is
|
|
2100
|
+
// not "no provenance" — fail closed.
|
|
2101
|
+
let base;
|
|
2102
|
+
try {
|
|
2103
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
2104
|
+
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
|
|
2105
|
+
base = prov.provenance_source_commit;
|
|
2106
|
+
} else {
|
|
2107
|
+
base = EMPTY_TREE; // truly no provenance value: first publish
|
|
2108
|
+
}
|
|
2109
|
+
} catch (e) {
|
|
2110
|
+
skip(taskId, "provenance-query-failed",
|
|
2111
|
+
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
// (2026-09-20, critic-0145 F-A4) The intent's recorded base must match
|
|
2115
|
+
// stamped provenance: another publish may have landed after the intent
|
|
2116
|
+
// was staged. A stale base means the staged diff is against a superseded
|
|
2117
|
+
// base — re-queue the task (next_phase routes it straight back to the
|
|
2118
|
+
// workflow's Publish, which re-prepares the intent against the new base);
|
|
2119
|
+
// never issue the stale diff. Legacy intents with no recorded base
|
|
2120
|
+
// re-queue exactly once, then re-prepare with a base.
|
|
2121
|
+
const intentBase = (typeof intentEntry.base === "string" && /^[0-9a-f]{40}$/.test(intentEntry.base))
|
|
2122
|
+
? intentEntry.base : null;
|
|
2123
|
+
if (!intentBase || intentBase !== base) {
|
|
2124
|
+
writePublishNote(db, taskId,
|
|
2125
|
+
`publish: publish-base-stale ${commit} — the intent's staged diff was computed against base ` +
|
|
2126
|
+
`${intentBase ? intentBase.slice(0, 7) : "(none recorded)"} but stamped provenance now reads ` +
|
|
2127
|
+
`${base.slice(0, 7)}; issuing the stale diff risks a spurious content-mismatch. Re-queued: ` +
|
|
2128
|
+
`the workflow's Publish re-prepares the intent against the new base.`,
|
|
2129
|
+
claimedAt);
|
|
2130
|
+
db.prepare("UPDATE tasks SET next_phase = 'Publish', state = 'in_progress', updated_at = ? WHERE id = ?")
|
|
2131
|
+
.run(now(), taskId);
|
|
2132
|
+
requeued.push({ task_id: taskId, intent_base: intentBase, provenance_base: base });
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
// (2026-09-20, critic-0145 F-R3) Re-claim with no manifest baseline: the
|
|
2136
|
+
// tick could neither prove a dead tick's issuance nor safely re-issue —
|
|
2137
|
+
// fail closed with a terminal human-attention park; never blind-issue,
|
|
2138
|
+
// never loop indefinitely.
|
|
2139
|
+
if (reclaimed && !manifestBefore) {
|
|
2140
|
+
writePublishNote(db, taskId,
|
|
2141
|
+
`publish: intent-unverifiable ${commit} — one-party intent terminal: this re-claim arrived with ` +
|
|
2142
|
+
`no pre-issuance manifest baseline, so the freshness comparison is unperformable; the tick can ` +
|
|
2143
|
+
`neither prove a dead tick's issuance nor safely re-issue. Parked for human attention.`,
|
|
2144
|
+
claimedAt);
|
|
2145
|
+
skip(taskId, "terminal",
|
|
2146
|
+
"re-claimed intent with a null manifest baseline — 'publish: intent-unverifiable'; parked for human attention");
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
intent.push({
|
|
2150
|
+
task_id: taskId, commit, attempt: intentEntry.attempt || null,
|
|
2151
|
+
slug, repo_path: repoPath,
|
|
2152
|
+
project_id: project.id, ledger_path: ledgerPath, base,
|
|
2153
|
+
diff_path: intentEntry.diff_path, diff_sha256: intentEntry.diff_sha256,
|
|
2154
|
+
manifest_before: manifestBefore,
|
|
2155
|
+
claimed_at: claimedAt, claim_expiry: expiry, reclaimed,
|
|
2156
|
+
});
|
|
2157
|
+
};
|
|
2158
|
+
|
|
1995
2159
|
const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
|
|
1996
2160
|
|
|
1997
2161
|
db.exec("BEGIN");
|
|
@@ -2093,6 +2257,25 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2093
2257
|
});
|
|
2094
2258
|
continue;
|
|
2095
2259
|
}
|
|
2260
|
+
// One-party publish intent (2026-09-20, blocker 22): the workflow
|
|
2261
|
+
// parked at intent; the session-carrying tick worker issues the edit
|
|
2262
|
+
// directly from the intent bucket — no child, no trigger.
|
|
2263
|
+
if (has("publish: publish-intent-claimed")) {
|
|
2264
|
+
const m = msg.match(/publish: publish-intent-claimed\s+(\S+)/);
|
|
2265
|
+
const expiryMs = m ? Date.parse(m[1]) : NaN;
|
|
2266
|
+
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
2267
|
+
// Expired claim: the tick died mid-issuance. Re-derive from the
|
|
2268
|
+
// original intent note and re-claim — the re-claiming tick runs the
|
|
2269
|
+
// manifest-freshness check before issuing, never blindly re-issues.
|
|
2270
|
+
const intentNote = notes.find((n) => n.message.includes("publish: publish-requested"));
|
|
2271
|
+
if (!intentNote) { skip(taskId, "no-intent-note", "expired intent claim with no publish-requested note in history"); continue; }
|
|
2272
|
+
claimForIntent(taskId, intentNote, true, latest);
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
2275
|
+
if (has("publish: publish-requested")) {
|
|
2276
|
+
claimForIntent(taskId, latest, false, latest);
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2096
2279
|
// A previous tick claimed this task for classification.
|
|
2097
2280
|
// A previous tick claimed this task for classification.
|
|
2098
2281
|
if (has("publish: unknown-recovery-claimed")) {
|
|
@@ -2128,7 +2311,243 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2128
2311
|
db.exec("COMMIT");
|
|
2129
2312
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2130
2313
|
|
|
2131
|
-
return { waiting, due, retry_due: retryDue, mirrored, skipped };
|
|
2314
|
+
return { waiting, due, intent, retry_due: retryDue, mirrored, skipped, requeued };
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
// One-party intent issuance record (2026-09-20, blocker 22). The
|
|
2318
|
+
// session-carrying tick worker issues the artifact_edit DIRECTLY in its own
|
|
2319
|
+
// turn (no child — blocker 22 proved children cannot reach artifact_edit)
|
|
2320
|
+
// and ferries the outcome here. Code decides: every note and ledger line
|
|
2321
|
+
// the outcome implies is written here, atomically, never free-formed by
|
|
2322
|
+
// the tick.
|
|
2323
|
+
//
|
|
2324
|
+
// Args: { task_id, claim_expiry, edit_outcome, refusal_text?, freshness_evidence? } where
|
|
2325
|
+
// edit_outcome is "accepted" (the direct artifact_edit call completed
|
|
2326
|
+
// without error and without an explicit refusal — and ONLY then; tool
|
|
2327
|
+
// unavailable, timeout, or any ambiguous result is not accepted and is
|
|
2328
|
+
// never recorded here, the tick logs it and lets the claim expire),
|
|
2329
|
+
// "recovered" (a re-claiming tick ran lib/check-intent-freshness.js and the
|
|
2330
|
+
// manifest advanced past the intent's baseline — the dead tick issued and
|
|
2331
|
+
// died before recording it), or "refused" (the platform explicitly refused
|
|
2332
|
+
// the edit).
|
|
2333
|
+
//
|
|
2334
|
+
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
2335
|
+
// latest publish: note is still this tick's intent claim. A stale tick (its
|
|
2336
|
+
// claim expired and a newer tick re-claimed) gets { recorded: false } and
|
|
2337
|
+
// writes nothing.
|
|
2338
|
+
//
|
|
2339
|
+
// Routing:
|
|
2340
|
+
// accepted / recovered → issuer-written "submitted" ledger entry
|
|
2341
|
+
// (issuer "tick-worker"; issued_at = the command's clock — the upper
|
|
2342
|
+
// bound on issuance — for "accepted"; for "recovered", the dead tick's
|
|
2343
|
+
// original claim time — the earliest matching publish-intent-claimed
|
|
2344
|
+
// note for this commit — which lower-bounds the unobserved issuance
|
|
2345
|
+
// instant, 2026-09-20 critic-0145 F-O1) + the tick's freshness evidence
|
|
2346
|
+
// recorded on the "recovered" entry + mirrored
|
|
2347
|
+
// `publish: verification-requested` (the verification scan claims it).
|
|
2348
|
+
// refused → issuer-written "rejected" ledger entry carrying
|
|
2349
|
+
// the platform's refusal text + terminal `publish: publish-refused`
|
|
2350
|
+
// (parked for human attention — no re-issue, no park-unknown).
|
|
2351
|
+
//
|
|
2352
|
+
// A "recovered" record without machine freshness evidence is refused: pass
|
|
2353
|
+
// freshness_evidence (the check-intent-freshness.js JSON, advanced:true) —
|
|
2354
|
+
// an unproven recovery is not recorded (2026-09-20 critic-0145 F-O3/F-R2).
|
|
2355
|
+
commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
2356
|
+
const taskId = args.task_id;
|
|
2357
|
+
const claimExpiry = args.claim_expiry;
|
|
2358
|
+
const editOutcome = args.edit_outcome;
|
|
2359
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2360
|
+
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
2361
|
+
if (!["accepted", "recovered", "refused"].includes(editOutcome)) {
|
|
2362
|
+
throw usageError(`unknown edit outcome: ${editOutcome}.`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
db.exec("BEGIN");
|
|
2366
|
+
try {
|
|
2367
|
+
// CAS: the latest publish: note must still be this tick's claim.
|
|
2368
|
+
const latest = latestPublishNote(db, taskId);
|
|
2369
|
+
if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
|
|
2370
|
+
!latest.message.includes(claimExpiry)) {
|
|
2371
|
+
db.exec("ROLLBACK");
|
|
2372
|
+
return { recorded: false, reason: "claim no longer current — another tick owns this task" };
|
|
2373
|
+
}
|
|
2374
|
+
// The intent anchor: the workflow's issuer-written intent entry.
|
|
2375
|
+
const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
|
|
2376
|
+
const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2377
|
+
const slug = project && project.deploy_slug;
|
|
2378
|
+
if (!slug) {
|
|
2379
|
+
db.exec("ROLLBACK");
|
|
2380
|
+
return { recorded: false, reason: "project has no deploy_slug" };
|
|
2381
|
+
}
|
|
2382
|
+
const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
|
|
2383
|
+
let entries = [];
|
|
2384
|
+
try {
|
|
2385
|
+
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2386
|
+
.filter((l) => l.trim().length > 0)
|
|
2387
|
+
.map((l) => JSON.parse(l))
|
|
2388
|
+
.filter((e) => e.task_id === taskId);
|
|
2389
|
+
} catch (e) {
|
|
2390
|
+
db.exec("ROLLBACK");
|
|
2391
|
+
return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
2392
|
+
}
|
|
2393
|
+
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2394
|
+
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
2395
|
+
db.exec("ROLLBACK");
|
|
2396
|
+
return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
2397
|
+
}
|
|
2398
|
+
const ts = now();
|
|
2399
|
+
const commit = intentEntry.commit;
|
|
2400
|
+
const attempt = intentEntry.attempt || null;
|
|
2401
|
+
// (2026-09-20, critic-0145 F-O1) A recovered issuance's instant is
|
|
2402
|
+
// unobserved, but it is LOWER-BOUNDED by the dead tick's original
|
|
2403
|
+
// claim: the earliest publish-intent-claimed note for this commit (not
|
|
2404
|
+
// the reclaimer's later claim). Binding issued_at to that time keeps
|
|
2405
|
+
// the verifier's staleness check honest — the old null fell back to
|
|
2406
|
+
// the recovery record's later timestamp and made a genuine
|
|
2407
|
+
// already-completed build look stale.
|
|
2408
|
+
let recoveredIssuedAt = null;
|
|
2409
|
+
if (editOutcome === "recovered") {
|
|
2410
|
+
const deadClaim = db.prepare(
|
|
2411
|
+
`SELECT timestamp FROM events
|
|
2412
|
+
WHERE task_id = ? AND type = 'note' AND message LIKE '%publish: publish-intent-claimed%'
|
|
2413
|
+
AND message LIKE ?
|
|
2414
|
+
ORDER BY timestamp ASC LIMIT 1`
|
|
2415
|
+
).get(taskId, `%${commit}%`);
|
|
2416
|
+
recoveredIssuedAt = (deadClaim && deadClaim.timestamp) || null;
|
|
2417
|
+
}
|
|
2418
|
+
// (2026-09-20, critic-0145 F-O3/F-R2) A recovery must carry machine
|
|
2419
|
+
// evidence: the check-intent-freshness.js JSON with advanced:true. An
|
|
2420
|
+
// unproven recovery is not recorded — the command fails the call so the
|
|
2421
|
+
// tick cannot write an honest-looking "recovered" on prose.
|
|
2422
|
+
let freshnessEvidence = null;
|
|
2423
|
+
if (editOutcome === "recovered") {
|
|
2424
|
+
const ev = args.freshness_evidence;
|
|
2425
|
+
const evObj = typeof ev === "string" ? (() => { try { return JSON.parse(ev); } catch { return null; } })() : ev;
|
|
2426
|
+
if (!evObj || typeof evObj !== "object" || evObj.ok !== true || evObj.advanced !== true) {
|
|
2427
|
+
throw usageError("recovered requires freshness_evidence — the check-intent-freshness.js JSON with advanced:true.");
|
|
2428
|
+
}
|
|
2429
|
+
freshnessEvidence = evObj;
|
|
2430
|
+
}
|
|
2431
|
+
const ledgerLine = {
|
|
2432
|
+
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
2433
|
+
commit, attempt, agent_id: null, applied_report: null,
|
|
2434
|
+
issuer: "tick-worker",
|
|
2435
|
+
diff_sha256: intentEntry.diff_sha256 || null,
|
|
2436
|
+
manifest_before: intentEntry.manifest_before || null,
|
|
2437
|
+
freshness_evidence: freshnessEvidence,
|
|
2438
|
+
};
|
|
2439
|
+
let note;
|
|
2440
|
+
if (editOutcome === "refused") {
|
|
2441
|
+
const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
|
|
2442
|
+
ledgerLine.outcome = "rejected";
|
|
2443
|
+
ledgerLine.issued_at = null;
|
|
2444
|
+
ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}`;
|
|
2445
|
+
// Terminal: parked for human attention. "publish: publish-refused"
|
|
2446
|
+
// is registered in lib/publish-note-vocabulary.js (the closed
|
|
2447
|
+
// registry — the writer-side guard pattern from D7).
|
|
2448
|
+
note = `publish: publish-refused ${commit} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
|
|
2449
|
+
} else {
|
|
2450
|
+
ledgerLine.outcome = "submitted";
|
|
2451
|
+
ledgerLine.issued_at = editOutcome === "accepted" ? ts : recoveredIssuedAt;
|
|
2452
|
+
ledgerLine.detail = editOutcome === "accepted"
|
|
2453
|
+
? "edit issued directly by the session-carrying tick worker (one-party publish)"
|
|
2454
|
+
: `recovered: the manifest advanced past the intent's baseline (freshness evidence recorded) — the dead tick issued and died before recording it; issuance attributed to the dead tick, issued_at lower-bounded by the dead tick's original claim at ${recoveredIssuedAt || "unknown"}`;
|
|
2455
|
+
note = `publish: verification-requested ${commit} attempt=${attempt} — issued directly by the session-carrying tick worker (one-party publish). Waiting on the manual read-back in docs/publish-verification.md.`;
|
|
2456
|
+
}
|
|
2457
|
+
try {
|
|
2458
|
+
appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
|
|
2459
|
+
} catch (e) {
|
|
2460
|
+
db.exec("ROLLBACK");
|
|
2461
|
+
return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
|
|
2462
|
+
}
|
|
2463
|
+
writePublishNote(db, taskId, note, ts);
|
|
2464
|
+
db.exec("COMMIT");
|
|
2465
|
+
return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, note };
|
|
2466
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2467
|
+
};
|
|
2468
|
+
|
|
2469
|
+
// Record a one-party intent as unissuable (2026-09-20, critic-0145 F-O2).
|
|
2470
|
+
// The session-carrying tick worker calls this when the staged diff cannot
|
|
2471
|
+
// be (re)generated byte-identically — the staged file is missing and
|
|
2472
|
+
// compute-publish-diff --commit fails, or the regenerated hash does not
|
|
2473
|
+
// match the intent's diff_sha256. Code decides: the terminal
|
|
2474
|
+
// `publish: publish-unissuable` note and an issuer-written ledger entry
|
|
2475
|
+
// (outcome "unissuable", issuer "tick-worker") are written here,
|
|
2476
|
+
// atomically, never free-formed by the tick.
|
|
2477
|
+
//
|
|
2478
|
+
// Args: { task_id, claim_expiry, reason } where reason is a short
|
|
2479
|
+
// machine-readable cause (missing-staged-diff, diff-unregenerable,
|
|
2480
|
+
// hash-mismatch-after-regeneration).
|
|
2481
|
+
//
|
|
2482
|
+
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
2483
|
+
// latest publish: note is still this tick's intent claim. A stale tick gets
|
|
2484
|
+
// { recorded: false } and writes nothing. The terminal note means the scan
|
|
2485
|
+
// never re-claims — no hourly reclaim loop on an unissuable diff.
|
|
2486
|
+
commands["record-intent-unissuable"] = (db, args, ctx) => {
|
|
2487
|
+
const taskId = args.task_id;
|
|
2488
|
+
const claimExpiry = args.claim_expiry;
|
|
2489
|
+
const reason = String(args.reason || "").split("\n")[0].slice(0, 200);
|
|
2490
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2491
|
+
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
2492
|
+
if (!reason) throw usageError("reason is required.");
|
|
2493
|
+
|
|
2494
|
+
db.exec("BEGIN");
|
|
2495
|
+
try {
|
|
2496
|
+
// CAS: the latest publish: note must still be this tick's claim.
|
|
2497
|
+
const latest = latestPublishNote(db, taskId);
|
|
2498
|
+
if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
|
|
2499
|
+
!latest.message.includes(claimExpiry)) {
|
|
2500
|
+
db.exec("ROLLBACK");
|
|
2501
|
+
return { recorded: false, reason: "claim no longer current — another tick owns this task" };
|
|
2502
|
+
}
|
|
2503
|
+
const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
|
|
2504
|
+
const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2505
|
+
const slug = project && project.deploy_slug;
|
|
2506
|
+
if (!slug) {
|
|
2507
|
+
db.exec("ROLLBACK");
|
|
2508
|
+
return { recorded: false, reason: "project has no deploy_slug" };
|
|
2509
|
+
}
|
|
2510
|
+
const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
|
|
2511
|
+
let entries = [];
|
|
2512
|
+
try {
|
|
2513
|
+
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2514
|
+
.filter((l) => l.trim().length > 0)
|
|
2515
|
+
.map((l) => JSON.parse(l))
|
|
2516
|
+
.filter((e) => e.task_id === taskId);
|
|
2517
|
+
} catch (e) {
|
|
2518
|
+
db.exec("ROLLBACK");
|
|
2519
|
+
return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
2520
|
+
}
|
|
2521
|
+
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2522
|
+
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
2523
|
+
db.exec("ROLLBACK");
|
|
2524
|
+
return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
2525
|
+
}
|
|
2526
|
+
const ts = now();
|
|
2527
|
+
const commit = intentEntry.commit;
|
|
2528
|
+
const attempt = intentEntry.attempt || null;
|
|
2529
|
+
const ledgerLine = {
|
|
2530
|
+
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
2531
|
+
commit, attempt, agent_id: null, applied_report: null,
|
|
2532
|
+
issuer: "tick-worker", outcome: "unissuable", issued_at: null,
|
|
2533
|
+
diff_sha256: intentEntry.diff_sha256 || null,
|
|
2534
|
+
manifest_before: intentEntry.manifest_before || null,
|
|
2535
|
+
detail: `intent diff unissuable: ${reason} — the staged diff cannot be (re)generated byte-identically; parked for human attention`,
|
|
2536
|
+
};
|
|
2537
|
+
try {
|
|
2538
|
+
appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
|
|
2539
|
+
} catch (e) {
|
|
2540
|
+
db.exec("ROLLBACK");
|
|
2541
|
+
return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
|
|
2542
|
+
}
|
|
2543
|
+
// Terminal: "publish: publish-unissuable" is registered in
|
|
2544
|
+
// lib/publish-note-vocabulary.js (the closed registry — the writer-side
|
|
2545
|
+
// guard pattern from D7).
|
|
2546
|
+
const note = `publish: publish-unissuable ${commit} — the intent's staged diff cannot be (re)generated byte-identically (${reason}). Never re-claim, never loop; parked for human attention.`;
|
|
2547
|
+
writePublishNote(db, taskId, note, ts);
|
|
2548
|
+
db.exec("COMMIT");
|
|
2549
|
+
return { recorded: true, task_id: taskId, commit, note };
|
|
2550
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2132
2551
|
};
|
|
2133
2552
|
|
|
2134
2553
|
// Record the unknown-recovery classifier's decision (2026-09-18, blocker
|
|
@@ -22,11 +22,28 @@ export const TERMINAL_PUBLISH_NOTES = [
|
|
|
22
22
|
"publish: retry-refused",
|
|
23
23
|
"publish: verified",
|
|
24
24
|
"publish: superseded",
|
|
25
|
+
// One-party publish refusal (2026-09-20, blocker 22): the session-carrying
|
|
26
|
+
// tick worker issued the edit directly and the platform refused it.
|
|
27
|
+
// Honest terminal state — no re-issue, no park-unknown; the task stays
|
|
28
|
+
// parked for human attention.
|
|
29
|
+
"publish: publish-refused",
|
|
25
30
|
// Verification pipeline's terminal content verdict (verify-publish.js).
|
|
26
31
|
// The task stays parked for human attention; unknown-recovery never
|
|
27
32
|
// re-enters it. Added 2026-09-19 (Room #23 J1/J3: was mislogged as
|
|
28
33
|
// unrecognized-publish-note).
|
|
29
34
|
"publish: verification-failed",
|
|
35
|
+
// (2026-09-20, critic-0145 F-O2) One-party intent terminal: the diff for
|
|
36
|
+
// the committed intent cannot be (re)generated byte-identically — the
|
|
37
|
+
// staged file is missing or corrupt, or the base..commit range cannot be
|
|
38
|
+
// regenerated. Never re-claim, never loop on an hourly reclaim; parked
|
|
39
|
+
// for human attention.
|
|
40
|
+
"publish: publish-unissuable",
|
|
41
|
+
// (2026-09-20, critic-0145 F-R3) One-party intent terminal: a re-claimed
|
|
42
|
+
// intent arrived with no pre-issuance manifest baseline, so the freshness
|
|
43
|
+
// comparison is unperformable — the tick can neither prove a dead tick's
|
|
44
|
+
// issuance nor safely re-issue. Never blind-issue, never loop
|
|
45
|
+
// indefinitely; parked for human attention.
|
|
46
|
+
"publish: intent-unverifiable",
|
|
30
47
|
];
|
|
31
48
|
export const TERMINAL_NOTE_MEANINGS = {
|
|
32
49
|
"publish: ambiguous": "unknown-recovery terminal: could not prove the drop; parked for human attention",
|
|
@@ -34,7 +51,10 @@ export const TERMINAL_NOTE_MEANINGS = {
|
|
|
34
51
|
"publish: retry-refused": "retry protocol terminal: platform refused the re-issued edit; parked for human attention",
|
|
35
52
|
"publish: verified": "publish verified and stamped; task re-queued (terminal for the scan)",
|
|
36
53
|
"publish: superseded": "HEAD moved past the attempt; recovery aborted; parked for human attention",
|
|
54
|
+
"publish: publish-refused": "one-party publish terminal: the platform refused the tick worker's directly-issued edit; parked for human attention",
|
|
37
55
|
"publish: verification-failed": "verification pipeline's terminal content verdict — parked for human attention",
|
|
56
|
+
"publish: publish-unissuable": "one-party intent terminal: the intent diff cannot be (re)generated byte-identically; parked for human attention",
|
|
57
|
+
"publish: intent-unverifiable": "one-party intent terminal: re-claimed intent with no manifest baseline — freshness unprovable; parked for human attention",
|
|
38
58
|
};
|
|
39
59
|
export function matchTerminalPublishNote(message) {
|
|
40
60
|
for (const note of TERMINAL_PUBLISH_NOTES) {
|