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