muse-crew 0.13.3 → 0.14.0

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.
@@ -0,0 +1,113 @@
1
+ # Publish-unknown recovery (blocker 15, 2026-09-18)
2
+
3
+ When a standard/bugfix Publish parks with "Publish outcome unknown", the
4
+ artifact-edit trigger went out fire-and-forget and no receipt came back —
5
+ async was planned for, receipt-less was not. The unknown-recovery loop
6
+ closes that gap without re-issuing blindly.
7
+
8
+ ## The note is the state machine
9
+
10
+ Recovery state lives in the task's `note` events, keyed on machine-written
11
+ `publish: <transition>` markers. Deterministic code (`lib/crew-api.js`) owns
12
+ every transition; the cron tick (Step 4.4) is only the ferry between the
13
+ deterministic steps. The initial park note is the workflow's
14
+ "Publish outcome unknown …" note (no `publish:` marker — the scan matches it
15
+ explicitly); every transition after that is machine-written.
16
+
17
+ State diagram (latest `publish:` note wins; history is the retry budget):
18
+
19
+ ```
20
+ "Publish outcome unknown" park
21
+ (< 30m) ──waiting──> scan: waiting (leave alone)
22
+ (>= 30m) ──scan──> publish: unknown-recovery-claimed <expiry> (1h lease)
23
+ │
24
+ ├─ tick runs lib/classify-publish-absence.js, then
25
+ │ record-unknown-classification (CAS on the claim expiry):
26
+ │
27
+ ├─ verified ──────────> publish: verification-requested <commit>
28
+ │ (content IS present; Step 4.5 verifies;
29
+ │ ledger: unknown-resolved)
30
+ ├─ provably-dropped ──> publish: dropped <commit>
31
+ │ (queues the retry protocol, next tick)
32
+ ├─ applied-not-built / ambiguous ──> publish: ambiguous <commit>
33
+ │ (terminal; ledger: unknown-classified)
34
+ ├─ superseded ────────> publish: superseded <commit> (terminal)
35
+ └─ deferred ──────────> (no note; the claim expires; the next scan
36
+ re-claims and re-classifies)
37
+ ```
38
+
39
+ Retry protocol (Step 4.4, for `publish: dropped` with a free budget):
40
+
41
+ ```
42
+ publish: dropped
43
+ ── tick: HEAD == commit? no ──> publish: retry-superseded (terminal)
44
+ ── tick: acquire merge lock publish-retry:<task> (600s); held ──> stop, retry next tick
45
+ ── tick: re-read content in the lock (classifier)
46
+ ├─ not provably-dropped ──> record-retry-recheck routes it
47
+ │ (verified → verification-requested; ambiguous → terminal;
48
+ │ superseded → terminal; deferred → no-op)
49
+ └─ provably-dropped ──> publish: retry-intended <commit> <ts>
50
+ ── trigger (same child shape as the first attempt)
51
+ ├─ ARTIFACT_EDIT_REFUSED ──> publish: retry-refused (terminal)
52
+ └─ no refusal ──> publish: retry-issued <commit>
53
+ ──> publish: verification-requested <commit> not-before=<ts+20m>
54
+ (Step 4.5 skips not-before entries until the window passes)
55
+ ──> one -retry1 ledger entry (best-effort)
56
+ ── release the merge lock (every path)
57
+ ```
58
+
59
+ Crash recovery (fail closed, never re-trigger blind):
60
+
61
+ - `publish: retry-intended` without `retry-issued` → the next scan mirrors
62
+ `verification-requested` with `not-before=intended+20m`. The trigger may
63
+ or may not have gone out; content verification is the arbiter.
64
+ - `publish: retry-issued` without a mirrored request → the next scan writes
65
+ the missing mirror.
66
+ - Exactly one retry per task, enforced from note history: the scan emits
67
+ `retry_due` only when no `publish: retry-issued` exists in the task's
68
+ history — including a retry for an earlier unknown attempt on a reworked
69
+ task. When the budget is spent, the drop is terminal: `publish: ambiguous`.
70
+
71
+ ## Commands
72
+
73
+ - `scan-publish-unknown` — the cron scan (Step 4.5 of the old numbering).
74
+ Returns `{ waiting, due, retry_due, mirrored, skipped }`. `due` entries
75
+ ferry the classifier inputs: `commit`, `trigger_ts` (from the `submitted`
76
+ ledger entry — never a time window), `park_ts`, `slug`, `repo_path`,
77
+ `base` (provenance `source_commit`, else the empty tree), and
78
+ `claim_expiry` for the record CAS. `retry_due` entries ferry the retry
79
+ inputs (`commit`, `attempt`, `slug`, `repo_path`, `base`, `ledger_path`).
80
+ - `record-unknown-classification --json '{task_id, claim_expiry, decision}'`
81
+ — routes the classifier's decision; CAS on the claim expiry (a stale tick
82
+ records nothing).
83
+ - `record-retry-recheck --json '{task_id, decision}'` — routes the retry
84
+ protocol's in-lock content re-read; CAS on latest being `publish: dropped`.
85
+ - `resolve-publish-unknown` — the manual one-shot for a single parked task
86
+ (unchanged; the audit-window contract, not the classifier).
87
+
88
+ ## Classifier (lib/classify-publish-absence.js)
89
+
90
+ Decides, from the task's repo and the platform's on-disk state, whether the
91
+ dropped edit is proven:
92
+
93
+ - `verified` — the content IS present (the platform applied it; the receipt
94
+ was the only thing lost). Never re-issue.
95
+ - `provably-dropped` — the old source is live AND the manifest shows no
96
+ build since the trigger. Only this decision may retry.
97
+ - `applied-not-built` — the new source is live but no build ran (the
98
+ trigger reached the platform but the build didn't). Ambiguous outcome,
99
+ terminal: a retry would double-apply.
100
+ - `ambiguous` — the content check is inconclusive. Never retry blind.
101
+ - `deferred` — not yet quiesced; re-check next tick.
102
+ - `superseded` — HEAD moved past the attempt's commit. Terminal.
103
+
104
+ The classifier's verified path requires the CURRENT manifest to be a new
105
+ build identity: `built_at` advanced past the trigger AND `content_sha256`
106
+ differs from the pre-trigger baseline the workflow snapshots into the
107
+ submitted ledger entry (design §1.9 — defeats a replayed manifest). When
108
+ the baseline is unavailable the classifier falls back to the time-based
109
+ advance check and notes it; the Step 4.5 verifier (`lib/verify-publish.js`)
110
+ is the strict gate and fails closed without a baseline before stamping.
111
+ Build-in-flight is checked twice bracketing the content read via manifest
112
+ state change (no mtime heuristics), and the suite carries add-only and
113
+ removal-only regression fixtures.
package/lib/AGENTS.md CHANGED
@@ -24,8 +24,10 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
24
24
  - `ux-doctrine.js` — UX-surface doctrine page resolution (2026-09-17): the canonical map from `environment_type` to the crew's shared UX bar (`artifact` → `docs/artifact-ux.md`, `terminal` → `docs/terminal-ux.md`; null/unknown → no page). Pure and deterministic: `doctrinePage(env)`, `doctrinePath(crewHome, env)`, `doctrinePageExists(crewHome, env)`; CLI `--page <env>` / `--path <crewHome> <env>`. Workflows mirror the map inline (one line — the workflow runtime's relative-import support is unverified) and tests pin the mirror against this file.
25
25
  - `serve-artifact.js` — local server for a built TS space for experiential QA (2026-09-14): serves `<space-dir>/client/dist` statically and dispatches POST `*/actions` to the compiled server actions with a locally-built Ctx. Prints `READY port=<n>` then serves until killed. Read-only w.r.t. the space directory. Fidelity: the served client and action handlers are the artifact's own built code; the Ctx is locally built (privileged handlers run from the space's own `server/dist/privileged.js` when present; blobs are stored in a per-run temp dir and served back at `/__blobs/<key>`); environment is inherited from the caller. It is not the hosted runtime — tasks that cannot be judged under it must report `NOT POSSIBLE: <reason>`.
26
26
  - `readback-disk.js` — deterministic publish content sensor (2026-09-16): reads the on-disk tree the artifact is built/served from and emits the machine-readable findings block (`FILE:`/`ADDED:`/`REMOVED:`/`END_FILE`) that `verify-publish.js` judges. The primary sensor — the LLM-inspector path (`build-readback-request.js`) is manual-fallback only since `artifact_inspect` was removed by the platform 2026-09-14.
27
+ - `publish-content.js` — shared ESM content-primitives for publish verification (2026-09-18, blocker 15): diff parsing (`parseDiff`), findings parsing (`parseFindings`), old-tree occurrence counting (`makeOldCounter`), and the discriminating-line / collision-exemption logic (`discriminatingLines`). Unifies `verify-publish.js` and the unknown-recovery classifier on one judgment so the two paths can never disagree about what a diff proves.
28
+ - `classify-publish-absence.js` — deterministic six-way classifier for publish-parked UNKNOWN outcomes (2026-09-18, blocker 15): decides from durable signals only — the pre-trigger manifest baseline (captured by the workflow into the submitted ledger entry; design §1.9) vs the current manifest's `built_at`/`content_sha256`, `git diff <base> <commit>` discriminating lines against the on-disk source tree, and HEAD vs the publish commit. Outcomes: `provably-dropped` (source shows pre-edit state, manifest NOT advanced past the trigger, HEAD == commit, aged past quiesce — retry once), `verified` (manifest advanced past the trigger AND content_sha256 differs from the pre-trigger baseline — a new build identity, not a replayed manifest; falls back to the time-based advance check with a note when the baseline is absent, and the Step 4.5 verifier fails closed without a baseline), `applied-not-built` (platform build-emission failure; no retry — the 2026-09-12 re-trigger hazard), `ambiguous` (any inconclusive shape — no retry by design), `deferred` (build in flight — manifest changed during the content read, or built within the settle window — or park below quiesce; not a verdict, retry later), `superseded` (HEAD != commit — never retry the old commit). Retry budget is consumed by the classification itself, never by the edit attempt. Never reads the wall clock except for recovery timing; addition-only and removal-only diffs are vacuously satisfied on their empty side.
27
29
  - `build-readback-request.js` — builds the LLM-inspector read-back `verbatim_request` from the merge commit's diff (2026-09-14): carries the merged diff as the expected change and asks for an independent read of the artifact's actual source. Retained as the manual fallback; the deterministic `readback-disk.js` is the primary sensor.
28
- - `verify-publish.js` — mechanical publish verification judge (2026-09-14/16): certifies the read-back findings block against `git diff` (strict `FILE:`/`ADDED:`/`REMOVED:`/`END_FILE` parsing, every added line PRESENT / every removed line ABSENT, HEAD==commit supersession check) and only then stamps provenance. Binary files, mode-only changes, and fully-colliding added hunks fail closed as `unverifiable-content` (2026-09-16, critic findings 1/5) — they can never vacuously stamp. Content-mismatch, unreadable-result, superseded, and stamp failures exit 1 with `publish: verification-failed` and no stamp. Collision exemption (2026-09-15): a removed line occurring in the old tree strictly more times than the diff removes is exempt from ABSENT (zero discriminating power); computed from `git show <base>:<path>`, never the working tree.
30
+ - `verify-publish.js` — mechanical publish verification judge (2026-09-14/16; shared primitives 2026-09-18): certifies the read-back findings block against `git diff` (strict `FILE:`/`ADDED:`/`REMOVED:`/`END_FILE` parsing, every discriminating added line PRESENT / every discriminating removed line ABSENT, HEAD==commit supersession check), then the design §1.9 manifest-freshness gate (current manifest `built_at` advanced past the trigger AND `content_sha256` differs from the workflow's pre-trigger baseline in the submitted ledger entry — a new build identity, not a replayed manifest; missing baseline fails closed), and only then stamps provenance. Diff parsing, findings parsing, and the collision-exemption rules come from the shared `lib/publish-content.js` (the unknown-recovery classifier's own judgment — one definition, never two). Binary files, mode-only changes, and fully-colliding added hunks fail closed as `unverifiable-content` (2026-09-16, critic findings 1/5) — they can never vacuously stamp. Content-mismatch, unreadable-result, superseded, and stamp failures exit 1 with `publish: verification-failed` and no stamp.
29
31
  - `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
30
32
  - `gitignore.js` — deterministic .gitignore management for crew-owned paths (2026-09-17): the crew touches exactly one user-owned file outside `.orchestration/` — the repo's `.gitignore`. `ensureGitignoreEntries(repoPath, entries)` creates the file when missing, appends missing entries (exact line match, no duplicates), preserves existing content byte-for-byte, and is idempotent. `describeGitignoreChange(repoPath, entries)` renders the exact diff for the setup consent conversation. Crew-owned entries: `.worktrees/`, `.orchestration/user/`. CLI: `--repo <path> [--dry-run]`.
31
33
  - `repo-orchestration.js` — repository-local `.orchestration/` scaffold (2026-09-17): `scaffoldRepoOrchestration(repoPath, crewRepoPath)` creates `$REPO/.orchestration/{workflows,identities,phases,user}/`, seeds workflows/identities/phases from the crew repo's platform defaults with no-clobber semantics (existing project customizations never overwritten), and writes a README in `user/` explaining it's for local config. Idempotent. CLI: `--repo <path> --crew-repo <path>`.
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ // classify-publish-absence.js — deterministic classifier for publish attempts
3
+ // parked with an UNKNOWN outcome (blocker 15, Phase 1, 2026-09-18).
4
+ //
5
+ // The workflow's fire-and-forget trigger returns no receipt, so a parked
6
+ // unknown means "the edit may or may not have gone through and the build
7
+ // may or may not have run." This unit decides mechanically from durable
8
+ // signals only — never from timing windows or audit-dir counting:
9
+ //
10
+ // sensor findings (lib/readback-disk.js — the on-disk source read-back)
11
+ // × manifest (.space-build/manifest.json — built_at + content_sha256)
12
+ // × repo state (git diff base..commit, HEAD)
13
+ //
14
+ // Decisions (design-room19.md §1.4):
15
+ // verified — manifest built_at advanced past the trigger AND
16
+ // every discriminating added line PRESENT / removed
17
+ // ABSENT (the build landed; route to parent
18
+ // verification, which stamps).
19
+ // provably-dropped — (a) every discriminating added line ABSENT,
20
+ // (b) every discriminating removed line PRESENT,
21
+ // (c) manifest built_at NOT advanced past trigger,
22
+ // (d) no build in flight (checked twice, bracketing
23
+ // the content read), (e) decision time ≥ park +
24
+ // quiesce, (f) HEAD == commit. One safe retry.
25
+ // applied-not-built — source PRESENT but manifest stale: the platform
26
+ // started something but no build completed.
27
+ // → ambiguous, park with the diagnosis, NO retry
28
+ // (re-applying a possibly mid-apply diff is the
29
+ // 2026-09-12 hazard in a new coat).
30
+ // ambiguous — partial-apply, binary/mode-only/rename diffs,
31
+ // unreadable sensor, manifest-advanced-but-mismatched,
32
+ // zero discriminating lines. Terminal, no retry.
33
+ // deferred — trigger age < quiesce, or build in flight. No
34
+ // budget consumed. Still in flight at park+2h → the
35
+ // caller treats it as terminal (pathological).
36
+ // superseded — HEAD != commit. Terminal, no retry (a retry would
37
+ // regress a newer publish).
38
+ //
39
+ // "Discriminating" reuses lib/publish-content.js — the verifier's own
40
+ // collision-exemption logic. Lines already in the old tree are
41
+ // non-discriminating and ignored.
42
+ //
43
+ // Usage:
44
+ // node classify-publish-absence.js --repo-path <path> --commit <sha>
45
+ // --base <sha> --slug <slug> --trigger-ts <ISO> --park-ts <ISO>
46
+ // [--spaces-root <dir>] [--task-id <uuid>]
47
+ // [--quiesce-mins <n>] [--in-flight-window-mins <n>]
48
+ //
49
+ // --manifest-before is the workflow's pre-trigger manifest snapshot
50
+ // (JSON {"built_at":<ISO|null>,"content_sha256":<hex|null>}, design §1.9).
51
+ // The verified path requires the current manifest's content_sha256 to
52
+ // differ from the baseline (a new build identity, not a replayed
53
+ // manifest). When the baseline is unavailable the classifier falls back
54
+ // to the time-based advance check and notes it; the Step 4.5 verifier
55
+ // (lib/verify-publish.js) is the strict gate and fails closed without a
56
+ // baseline before stamping.
57
+ //
58
+ // Exit 0 with the decision JSON on stdout for every decision (a decision
59
+ // is data, not a failure). Exit 2 on usage/validation. Nothing is printed
60
+ // to stdout on failure.
61
+
62
+ import { execFileSync } from "node:child_process";
63
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
64
+ import { homedir } from "node:os";
65
+ import { join, resolve } from "node:path";
66
+ import {
67
+ EMPTY_TREE,
68
+ parseDiff,
69
+ parseFindings,
70
+ makeOldCounter,
71
+ discriminatingLines,
72
+ } from "./publish-content.js";
73
+
74
+ function arg(name, required = true, def = null) {
75
+ const i = process.argv.lastIndexOf(name); // last occurrence wins
76
+ if (i < 0 || i + 1 >= process.argv.length) {
77
+ if (required && def === null) fail("usage", `${name} is required.`);
78
+ return def;
79
+ }
80
+ return process.argv[i + 1];
81
+ }
82
+ function fail(code, message) {
83
+ process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
84
+ process.exit(2);
85
+ }
86
+
87
+ const repoPath = arg("--repo-path");
88
+ const commit = arg("--commit");
89
+ const base = arg("--base");
90
+ const slug = arg("--slug");
91
+ const triggerTs = arg("--trigger-ts");
92
+ const parkTs = arg("--park-ts");
93
+ const spacesRoot = arg("--spaces-root", false, join(homedir(), "workspace", "ts-spaces"));
94
+ const taskId = arg("--task-id", false, "classify");
95
+ const quiesceMins = parseInt(arg("--quiesce-mins", false, "30"), 10);
96
+ const inFlightWindowMins = parseInt(arg("--in-flight-window-mins", false, "15"), 10);
97
+ // Pre-trigger manifest baseline (design §1.9), JSON-encoded. Optional —
98
+ // the classifier falls back to the time-based check when absent.
99
+ // 2026-09-18, should-fix 1: distinguish absent (legacy, no baseline) from
100
+ // malformed (supplied but invalid) — malformed is ambiguous, not absent.
101
+ // A valid baseline requires a parseable built_at timestamp and a 64-hex
102
+ // content_sha256.
103
+ let manifestBefore = null;
104
+ let manifestBeforeMalformed = false;
105
+ const manifestBeforeRaw = arg("--manifest-before", false, null);
106
+ if (manifestBeforeRaw) {
107
+ try {
108
+ const parsed = JSON.parse(manifestBeforeRaw);
109
+ const builtAt = parsed && typeof parsed.built_at === "string" ? parsed.built_at : null;
110
+ const sha = parsed && typeof parsed.content_sha256 === "string" ? parsed.content_sha256 : null;
111
+ const builtAtMs = builtAt ? Date.parse(builtAt) : NaN;
112
+ const shaOk = sha && /^[0-9a-f]{64}$/.test(sha);
113
+ if (parsed && typeof parsed === "object" && builtAtMs > 0 && shaOk) {
114
+ manifestBefore = { built_at: builtAt, content_sha256: sha };
115
+ } else {
116
+ // Supplied but invalid: fail closed as ambiguous, not absent.
117
+ manifestBeforeMalformed = true;
118
+ }
119
+ } catch {
120
+ // Supplied but unparsable: fail closed as ambiguous, not absent.
121
+ manifestBeforeMalformed = true;
122
+ }
123
+ }
124
+
125
+ if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.");
126
+ if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.");
127
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(slug)) fail("usage", "slug is not a safe artifact slug.");
128
+ const triggerMs = Date.parse(triggerTs);
129
+ const parkMs = Date.parse(parkTs);
130
+ if (!(triggerMs > 0)) fail("usage", "trigger-ts is not a valid timestamp.");
131
+ if (!(parkMs > 0)) fail("usage", "park-ts is not a valid timestamp.");
132
+ if (!(quiesceMins > 0)) fail("usage", "quiesce-mins must be positive.");
133
+ if (!(inFlightWindowMins > 0)) fail("usage", "in-flight-window-mins must be positive.");
134
+
135
+ function decide(decision, reasons, details = {}) {
136
+ process.stdout.write(JSON.stringify({ ok: true, decision, reasons, details }) + "\n");
137
+ process.exit(0);
138
+ }
139
+
140
+ // Resolve the space dir with realpath containment (same rule as
141
+ // readback-disk.js — the slug must not escape the spaces root).
142
+ function resolveSpaceDir() {
143
+ try {
144
+ const rootReal = realpathSync(spacesRoot);
145
+ const candReal = realpathSync(join(spacesRoot, slug));
146
+ if (candReal !== rootReal && !candReal.startsWith(rootReal + "/")) return null;
147
+ return statSync(candReal).isDirectory() ? candReal : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ // (d) No build in flight. Signals, both heuristic but conservative:
154
+ // - manifest built_at within the window: a build just landed, the
155
+ // deployment may still be propagating;
156
+ // - any file under .space-build with mtime within the window: the
157
+ // platform is actively writing build output.
158
+ // A build running longer than the window without touching .space-build is
159
+ // pathological; the caller escalates to terminal at park+2h.
160
+ // (d) Build-in-flight detection: the manifest is the platform's build
161
+ // record. A build is "in flight" for our purposes if the manifest state
162
+ // changes during the content read (a build landed mid-read — the read may
163
+ // be torn) or if the manifest's built_at is within the settle window (a
164
+ // build just completed; the working copy may still be settling). Checked
165
+ // twice bracketing the content read (design §1.4d); the double check
166
+ // bounds the residual window to the local disk-read duration. Directory
167
+ // mtimes are NOT used — they cannot distinguish a running build from a
168
+ // recently completed one, and the manifest is the honest record.
169
+ function manifestState(spaceDir) {
170
+ const m = readManifest(spaceDir);
171
+ if (!m) return null;
172
+ return { built_at: m.built_at, content_sha256: m.content_sha256 };
173
+ }
174
+ function buildInFlight(spaceDir, nowMs, beforeState) {
175
+ const afterState = manifestState(spaceDir);
176
+ // State changed during the read → a build landed mid-read.
177
+ if (beforeState && afterState &&
178
+ (beforeState.built_at !== afterState.built_at ||
179
+ beforeState.content_sha256 !== afterState.content_sha256)) {
180
+ return { inFlight: true, afterState, reason: `manifest changed during the content read (before built_at=${beforeState.built_at}, after built_at=${afterState.built_at}) — the read may be torn` };
181
+ }
182
+ // Manifest built very recently → a build just completed; let it settle.
183
+ // 2026-09-18, should-fix 2: enforce 0 <= age < settleMs. A future built_at
184
+ // (negative age) is pathological — clock skew or corruption — and must not
185
+ // defer forever. It is ambiguous, not in-flight.
186
+ const settleMs = inFlightWindowMins * 60 * 1000;
187
+ const builtAtMs = afterState && afterState.built_at ? Date.parse(afterState.built_at) : NaN;
188
+ if (Number.isFinite(builtAtMs)) {
189
+ const ageMs = nowMs - builtAtMs;
190
+ if (ageMs < 0) {
191
+ return { inFlight: false, afterState, pathological: true, reason: `manifest built_at ${afterState.built_at} is in the future (age ${ageMs}ms) — pathological, not in-flight` };
192
+ }
193
+ if (ageMs < settleMs) {
194
+ return { inFlight: true, afterState, reason: `manifest built_at ${afterState.built_at} is within the ${inFlightWindowMins}min settle window — a build just completed` };
195
+ }
196
+ }
197
+ return { inFlight: false, afterState };
198
+ }
199
+
200
+ function readManifest(spaceDir) {
201
+ if (!spaceDir) return null;
202
+ try {
203
+ const m = JSON.parse(readFileSync(join(spaceDir, ".space-build", "manifest.json"), "utf8"));
204
+ return {
205
+ built_at: typeof m.built_at === "string" ? m.built_at : null,
206
+ content_sha256: typeof m.content_sha256 === "string" ? m.content_sha256 : null,
207
+ };
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ const nowMs = Date.now();
214
+ const spaceDir = resolveSpaceDir();
215
+
216
+ // (should-fix 1) Malformed supplied baseline → ambiguous, not absent.
217
+ // A supplied --manifest-before that fails validation is evidence corruption,
218
+ // not a legacy absent baseline. Fail closed.
219
+ if (manifestBeforeMalformed) {
220
+ decide("ambiguous",
221
+ [`supplied --manifest-before failed validation (must be JSON with a parseable built_at and a 64-hex content_sha256)`],
222
+ { manifest_before_malformed: true });
223
+ }
224
+
225
+ // (e) Decision time ≥ park + quiesce. Too young → deferred, no budget
226
+ // consumed, no timer advanced.
227
+ if (nowMs < parkMs + quiesceMins * 60 * 1000) {
228
+ decide("deferred",
229
+ [`trigger age below quiesce: park ${parkTs}, quiesce ${quiesceMins}min`],
230
+ { park_ts: parkTs, quiesce_mins: quiesceMins });
231
+ }
232
+
233
+ // (d) First in-flight check, before the content read. Captures the
234
+ // manifest state; the second check compares against it.
235
+ const manifestBeforeRead = manifestState(spaceDir);
236
+ let flight = buildInFlight(spaceDir, nowMs, null);
237
+ // Pathological future timestamp → ambiguous, not deferred (should-fix 2).
238
+ if (flight.pathological) {
239
+ decide("ambiguous",
240
+ [`manifest built_at is in the future (pathological): ${flight.reason}`],
241
+ { pathological_future_built_at: true, in_flight_reason: flight.reason });
242
+ }
243
+ if (flight.inFlight) {
244
+ if (nowMs > parkMs + 2 * 60 * 60 * 1000) {
245
+ decide("ambiguous",
246
+ [`build still in flight at park+2h (pathological): ${flight.reason}`],
247
+ { in_flight_reason: flight.reason });
248
+ }
249
+ decide("deferred", [`build in flight (pre-read check): ${flight.reason}`],
250
+ { in_flight_reason: flight.reason });
251
+ }
252
+
253
+ // The content read: the deterministic sensor on the platform's working copy.
254
+ const sensorPath = join(new URL(".", import.meta.url).pathname, "readback-disk.js");
255
+ let findingsText;
256
+ try {
257
+ findingsText = execFileSync("node", [
258
+ sensorPath,
259
+ "--repo-path", repoPath,
260
+ "--commit", commit,
261
+ "--base", base,
262
+ "--slug", slug,
263
+ "--task-id", taskId,
264
+ "--spaces-root", spacesRoot,
265
+ ], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
266
+ } catch (e) {
267
+ decide("ambiguous",
268
+ [`sensor unreadable: ${(e.stderr || e.message || "").toString().slice(0, 200)}`],
269
+ { sensor_failed: true });
270
+ }
271
+
272
+ // (d) Second in-flight check, bracketing the content read. Compares the
273
+ // manifest state against the pre-read baseline; a change means a build
274
+ // landed mid-read (the read may be torn). The double check bounds the
275
+ // residual window to the local disk-read duration.
276
+ flight = buildInFlight(spaceDir, Date.now(), manifestBeforeRead);
277
+ // Pathological future timestamp → ambiguous, not deferred (should-fix 2).
278
+ if (flight.pathological) {
279
+ decide("ambiguous",
280
+ [`manifest built_at is in the future (pathological, post-read): ${flight.reason}`],
281
+ { pathological_future_built_at: true, in_flight_reason: flight.reason });
282
+ }
283
+ if (flight.inFlight) {
284
+ decide("deferred", [`build in flight (post-read check): ${flight.reason}`],
285
+ { in_flight_reason: flight.reason });
286
+ }
287
+
288
+ // 2026-09-18, should-fix 3: use the manifest state already checked by the
289
+ // post-read in-flight check (flight.afterState) — do NOT re-read the manifest
290
+ // here. A third read reopens the TOCTOU window the double-check just closed.
291
+ const manifest = flight.afterState;
292
+ const manifestBuiltAtMs = manifest && manifest.built_at ? Date.parse(manifest.built_at) : NaN;
293
+ const manifestAdvanced = Number.isFinite(manifestBuiltAtMs) && manifestBuiltAtMs > triggerMs;
294
+
295
+ // The expected change, from git (never from anyone's report).
296
+ if (base !== EMPTY_TREE) {
297
+ try {
298
+ execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
299
+ } catch {
300
+ decide("ambiguous", [`base ${base} is not an ancestor of commit ${commit}`], {});
301
+ }
302
+ }
303
+ let diff;
304
+ try {
305
+ diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
306
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
307
+ });
308
+ } catch (e) {
309
+ decide("ambiguous", [`git diff failed: ${e.message.slice(0, 200)}`], {});
310
+ }
311
+ if (!diff.trim()) {
312
+ decide("ambiguous", ["empty diff for commit — nothing to judge"], {});
313
+ }
314
+ const diffMap = parseDiff(diff);
315
+ const oldCount = makeOldCounter(repoPath, base);
316
+ const disc = discriminatingLines(diffMap, oldCount);
317
+
318
+ // Files the line-based judge cannot certify at all → ambiguous, never a
319
+ // retry on zero signal.
320
+ for (const [path, d] of disc) {
321
+ if (d.unjudgeable) {
322
+ decide("ambiguous",
323
+ [`unverifiable-content ${path}: ${d.unjudgeable} — the diff carries no discriminating lines for this file`],
324
+ { file: path, unjudgeable: d.unjudgeable });
325
+ }
326
+ }
327
+
328
+ // (f) Supersession re-check: a retry against a moved HEAD would regress a
329
+ // newer publish.
330
+ let head;
331
+ try {
332
+ head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
333
+ } catch (e) {
334
+ decide("ambiguous", [`git rev-parse HEAD failed: ${e.message.slice(0, 200)}`], {});
335
+ }
336
+ if (head !== commit) {
337
+ decide("superseded",
338
+ [`HEAD is ${head}, not ${commit} — a newer publish supersedes this one; no retry`],
339
+ { head, commit });
340
+ }
341
+
342
+ // The findings must cover every discriminating line, or the read is not a
343
+ // read of this diff.
344
+ const { findings, malformed } = parseFindings(findingsText);
345
+ if (malformed) {
346
+ decide("ambiguous", [`unreadable findings: ${malformed}`], {});
347
+ }
348
+ let totalDisc = 0;
349
+ let addedPresent = 0, addedAbsent = 0, removedPresent = 0, removedAbsent = 0;
350
+ for (const [path, d] of disc) {
351
+ const found = findings.get(path);
352
+ if (!found) {
353
+ decide("ambiguous", [`no findings for changed file ${path}`], { file: path });
354
+ }
355
+ for (const line of d.addedDisc) {
356
+ totalDisc++;
357
+ const v = found.added.get(line);
358
+ if (v === undefined) decide("ambiguous", [`no ADDED finding for line in ${path}`], { file: path });
359
+ if (v === "PRESENT") addedPresent++; else addedAbsent++;
360
+ }
361
+ for (const line of d.removedDisc) {
362
+ totalDisc++;
363
+ const v = found.removed.get(line);
364
+ if (v === undefined) decide("ambiguous", [`no REMOVED finding for line in ${path}`], { file: path });
365
+ if (v === "PRESENT") removedPresent++; else removedAbsent++;
366
+ }
367
+ }
368
+ if (totalDisc === 0) {
369
+ decide("ambiguous",
370
+ ["zero discriminating lines across the diff — no signal to judge; refusing to decide on vacuous truth"],
371
+ {});
372
+ }
373
+
374
+ const details = {
375
+ manifest_built_at: manifest ? manifest.built_at : null,
376
+ manifest_advanced: manifestAdvanced,
377
+ head,
378
+ discriminating_added: addedPresent + addedAbsent,
379
+ discriminating_removed: removedPresent + removedAbsent,
380
+ added_present: addedPresent,
381
+ added_absent: addedAbsent,
382
+ removed_present: removedPresent,
383
+ removed_absent: removedAbsent,
384
+ };
385
+
386
+ // Manifest advanced past the trigger: a build ran for this attempt.
387
+ if (manifestAdvanced) {
388
+ // Verified ⟺ the source shows the full post-edit state. A side with no
389
+ // discriminating lines is vacuously satisfied; totalDisc > 0 (checked
390
+ // above) guarantees at least one side carries signal.
391
+ const addedOk = addedPresent + addedAbsent === 0 || (addedPresent > 0 && addedAbsent === 0);
392
+ const removedOk = removedPresent + removedAbsent === 0 || (removedAbsent > 0 && removedPresent === 0);
393
+ if (addedOk && removedOk) {
394
+ // Design §1.9: the manifest must be a NEW build identity, not a
395
+ // replayed manifest with a bumped built_at. When the pre-trigger
396
+ // baseline is available, require the content_sha256 to differ; when
397
+ // unavailable, fall back to the time-based advance (noted) — the Step
398
+ // 4.5 verifier is the strict gate and fails closed without a baseline
399
+ // before stamping.
400
+ const beforeHash = manifestBefore ? manifestBefore.content_sha256 : null;
401
+ const currentHash = manifest ? manifest.content_sha256 : null;
402
+ if (manifestBefore && beforeHash && currentHash) {
403
+ if (beforeHash === currentHash) {
404
+ decide("ambiguous",
405
+ [`manifest built_at advanced past trigger but content_sha256 is unchanged from the pre-trigger baseline — a replayed manifest, not a new build; refusing to verify`],
406
+ { ...details, manifest_before: manifestBefore });
407
+ }
408
+ decide("verified",
409
+ [`manifest built_at ${manifest.built_at} advanced past trigger ${triggerTs} with a new content_sha256 (differs from pre-trigger baseline); content matches the diff`],
410
+ { ...details, manifest_before: manifestBefore });
411
+ }
412
+ decide("verified",
413
+ [`manifest built_at ${manifest.built_at} advanced past trigger ${triggerTs}; content matches the diff (no pre-trigger baseline — freshness by time only; the Step 4.5 verifier fails closed without a baseline)`],
414
+ { ...details, manifest_before: null, baseline_absent: true });
415
+ }
416
+ // A build ran but the content does not match: not ours to claim.
417
+ decide("ambiguous",
418
+ [`manifest advanced past trigger but content mismatched (added PRESENT ${addedPresent}/${addedPresent + addedAbsent}, removed ABSENT ${removedAbsent}/${removedPresent + removedAbsent}) — a build ran that is not this diff`],
419
+ details);
420
+ }
421
+
422
+ // Manifest stale: no build for this attempt. Judge the source state.
423
+ const addedDropped = addedPresent + addedAbsent === 0 || (addedAbsent > 0 && addedPresent === 0);
424
+ const removedDropped = removedPresent + removedAbsent === 0 || (removedPresent > 0 && removedAbsent === 0);
425
+ const addedApplied = addedPresent + addedAbsent === 0 || (addedPresent > 0 && addedAbsent === 0);
426
+ const removedApplied = removedPresent + removedAbsent === 0 || (removedAbsent > 0 && removedPresent === 0);
427
+ if (addedDropped && removedDropped) {
428
+ decide("provably-dropped",
429
+ [
430
+ "(a) every discriminating added line ABSENT",
431
+ "(b) every discriminating removed line PRESENT",
432
+ `(c) manifest built_at ${manifest ? manifest.built_at : "missing"} NOT advanced past trigger ${triggerTs}`,
433
+ "(d) no build in flight (checked twice, bracketing the read)",
434
+ `(e) decision time past park+${quiesceMins}min quiesce`,
435
+ "(f) HEAD == commit",
436
+ ],
437
+ details);
438
+ }
439
+ if (addedApplied && removedApplied) {
440
+ // J1's shape: the platform started something (the phantom) but no build
441
+ // completed — "platform build-emission failure." No retry: re-applying a
442
+ // possibly mid-apply diff is the 2026-09-12 hazard in a new coat.
443
+ decide("applied-not-built",
444
+ [
445
+ `source shows the full post-edit state but manifest stale (built_at ${manifest ? manifest.built_at : "missing"}) — platform build-emission failure; no retry`,
446
+ ],
447
+ details);
448
+ }
449
+ decide("ambiguous",
450
+ [`partial-apply or indeterminate (added PRESENT ${addedPresent}/${addedPresent + addedAbsent}, removed PRESENT ${removedPresent}/${removedPresent + removedAbsent}) with a stale manifest`],
451
+ details);