muse-crew 0.13.3 → 0.14.1
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/docs/publish-unknown-recovery.md +113 -0
- package/lib/AGENTS.md +7 -2
- package/lib/advance-publish-base.js +74 -28
- package/lib/classify-publish-absence.js +451 -0
- package/lib/crew-api.js +584 -21
- package/lib/publish-content.js +154 -0
- package/lib/retry-publish.js +370 -0
- package/lib/scaffold-crew.js +129 -0
- package/lib/verify-publish.js +101 -84
- package/package.json +2 -2
- package/seed/cron-body-template.md +57 -2
- package/workflows/bugfix.js +38 -23
- package/workflows/chore.js +37 -22
- package/workflows/crew-dispatch.js +43 -8
- package/workflows/crew-init.js +333 -78
- package/workflows/crew-uninstall.js +57 -10
- package/workflows/standard.js +35 -16
- package/workflows/upgrade.js +28 -14
|
@@ -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);
|