muse-crew 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/docs/decisions/AGENTS.md +4 -0
- package/docs/decisions/publish-path.md +229 -0
- package/docs/publish-verification.md +14 -3
- package/docs/release-integrity.md +60 -0
- package/docs/reviews/critic-0143-redo.md +94 -0
- package/docs/reviews/critic-0143.md +108 -0
- package/docs/reviews/critic-0144.md +83 -0
- package/lib/AGENTS.md +14 -6
- 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/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 +9 -0
- package/lib/crew-api.js +55 -20
- 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 +44 -0
- package/lib/publish-npm.sh +39 -10
- 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 +79 -44
- 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/test-detached-integrate.sh +79 -3
- package/lib/test-publish-preflight.sh +94 -5
- package/lib/update-watch.js +34 -17
- package/lib/ux-doctrine.js +31 -6
- package/lib/verify-publish.js +41 -6
- package/lib/worktree-lifecycle.sh +67 -25
- package/lib/write-ooda-verdict.js +12 -3
- package/package.json +1 -1
- package/workflows/bugfix.js +60 -61
- package/workflows/chore.js +60 -61
- package/workflows/standard.js +60 -42
- package/workflows/upgrade.js +4 -2
package/lib/update-watch.js
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
// every path except a missing --crew-home (exit 2).
|
|
23
23
|
"use strict";
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { realpathSync } from "node:fs";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
28
30
|
|
|
29
31
|
const STATE_FILE = ".update-watch.json";
|
|
30
32
|
const LOG_FILE = "update-watch.log";
|
|
@@ -653,6 +655,11 @@ function buildRealDeps(crewHome) {
|
|
|
653
655
|
|
|
654
656
|
function main() {
|
|
655
657
|
const args = process.argv.slice(2);
|
|
658
|
+
// --help: before required-arg parsing (entry-gate contract).
|
|
659
|
+
if (args.includes("--help")) {
|
|
660
|
+
console.log(USAGE);
|
|
661
|
+
process.exit(0);
|
|
662
|
+
}
|
|
656
663
|
let crewHome = null;
|
|
657
664
|
let enrollSha = null;
|
|
658
665
|
let enrollForce = false;
|
|
@@ -693,19 +700,29 @@ function main() {
|
|
|
693
700
|
);
|
|
694
701
|
}
|
|
695
702
|
|
|
696
|
-
|
|
697
|
-
parseSemver
|
|
698
|
-
compareSemver
|
|
699
|
-
channelAllows
|
|
700
|
-
crewDecision
|
|
701
|
-
dashboardDecision
|
|
702
|
-
crewVersionOrdering
|
|
703
|
-
fileCrewUpgrade
|
|
704
|
-
enrollDashboardBase
|
|
705
|
-
readState
|
|
706
|
-
writeState
|
|
707
|
-
appendLog
|
|
708
|
-
run
|
|
703
|
+
export {
|
|
704
|
+
parseSemver,
|
|
705
|
+
compareSemver,
|
|
706
|
+
channelAllows,
|
|
707
|
+
crewDecision,
|
|
708
|
+
dashboardDecision,
|
|
709
|
+
crewVersionOrdering,
|
|
710
|
+
fileCrewUpgrade,
|
|
711
|
+
enrollDashboardBase,
|
|
712
|
+
readState,
|
|
713
|
+
writeState,
|
|
714
|
+
appendLog,
|
|
715
|
+
run
|
|
709
716
|
};
|
|
710
717
|
|
|
711
|
-
|
|
718
|
+
const USAGE = "usage: node update-watch.js --crew-home <path> [--record-dashboard-sha <sha> [--force]]";
|
|
719
|
+
|
|
720
|
+
const isMainModule = (() => {
|
|
721
|
+
try {
|
|
722
|
+
return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
})();
|
|
727
|
+
|
|
728
|
+
if (isMainModule) main();
|
package/lib/ux-doctrine.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
// ux-doctrine.js — UX-surface doctrine page resolution.
|
|
2
3
|
//
|
|
3
4
|
// One canonical doctrine page per environment_type value: the crew reads
|
|
@@ -11,10 +12,16 @@
|
|
|
11
12
|
// against this file, so the two can never silently diverge.
|
|
12
13
|
//
|
|
13
14
|
// Pure and deterministic: no clock, no randomness, no I/O.
|
|
14
|
-
|
|
15
|
+
//
|
|
16
|
+
// Module shape (blocker 21): this file is an import-safe shared module —
|
|
17
|
+
// it has NO shebang, so the entry gate bare-loads it (`node ux-doctrine.js`
|
|
18
|
+
// exits 0). The CLI below runs only when the file itself is the process
|
|
19
|
+
// entrypoint.
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { realpathSync } from "node:fs";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
18
25
|
|
|
19
26
|
// THE MAP. environment_type value -> doctrine page filename, relative to
|
|
20
27
|
// the release docs dir ($CREW_HOME/current/docs/). null is unclassified —
|
|
@@ -44,12 +51,30 @@ function doctrinePageExists(crewHome, environmentType) {
|
|
|
44
51
|
return p ? existsSync(p) : false;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
export { DOCTRINE_PAGES, doctrinePage, doctrinePath, doctrinePageExists };
|
|
55
|
+
|
|
56
|
+
// realpath: production spawns through $CREW_HOME/current/lib (a symlink),
|
|
57
|
+
// so argv[1] must be resolved before comparison with import.meta.url.
|
|
58
|
+
const isMainModule = (() => {
|
|
59
|
+
try {
|
|
60
|
+
return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
48
65
|
|
|
49
|
-
|
|
66
|
+
const USAGE = "usage: node ux-doctrine.js --page <environment_type|null> | --path <crewHome> <environment_type|null>";
|
|
67
|
+
|
|
68
|
+
if (isMainModule) {
|
|
50
69
|
// CLI: node ux-doctrine.js --page <environment_type>
|
|
51
70
|
// node ux-doctrine.js --path <crewHome> <environment_type>
|
|
52
71
|
const args = process.argv.slice(2);
|
|
72
|
+
// Import-safe module contract: a bare invocation (no args) is a load
|
|
73
|
+
// check, not a usage error — it exits 0. --help always exits 0.
|
|
74
|
+
if (args.length === 0 || args.includes("--help")) {
|
|
75
|
+
console.log(USAGE);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
53
78
|
if (args[0] === "--page" && args.length === 2) {
|
|
54
79
|
const page = doctrinePage(args[1] === "null" ? null : args[1]);
|
|
55
80
|
console.log(page === null ? "null" : page);
|
|
@@ -57,7 +82,7 @@ if (require.main === module) {
|
|
|
57
82
|
const p = doctrinePath(args[1], args[2] === "null" ? null : args[2]);
|
|
58
83
|
console.log(p === null ? "null" : p);
|
|
59
84
|
} else {
|
|
60
|
-
console.error(
|
|
85
|
+
console.error(USAGE);
|
|
61
86
|
process.exit(2);
|
|
62
87
|
}
|
|
63
88
|
}
|
package/lib/verify-publish.js
CHANGED
|
@@ -51,6 +51,19 @@ import {
|
|
|
51
51
|
makeOldCounter,
|
|
52
52
|
discriminatingLines,
|
|
53
53
|
} from "./publish-content.js";
|
|
54
|
+
// Terminal-note vocabulary (D7, 2026-09-19): the writer-side guard.
|
|
55
|
+
// terminal() asserts its emitted verb is a registry member before writing,
|
|
56
|
+
// so no future terminal verb ships unrecognized by the scans.
|
|
57
|
+
import { matchTerminalPublishNote } from "./publish-note-vocabulary.js";
|
|
58
|
+
|
|
59
|
+
// --help: before required-arg parsing (shebang⇔CLI contract).
|
|
60
|
+
if (process.argv.slice(2).includes("--help")) {
|
|
61
|
+
console.log(
|
|
62
|
+
"usage: node verify-publish.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> --project-id <id> [--spaces-root <dir>]\n" +
|
|
63
|
+
"the mechanical publish verification JUDGE: certifies read-back findings against git diff, checks manifest freshness, stamps provenance"
|
|
64
|
+
);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
54
67
|
|
|
55
68
|
function arg(name, required = true) {
|
|
56
69
|
const i = process.argv.indexOf(name);
|
|
@@ -89,10 +102,25 @@ function api(command, json) {
|
|
|
89
102
|
});
|
|
90
103
|
return JSON.parse(out);
|
|
91
104
|
}
|
|
105
|
+
// The terminal verdict this writer emits. A single const (not an inline
|
|
106
|
+
// literal) so the emitted verb and the asserted verb can never drift apart.
|
|
107
|
+
const TERMINAL_VERB = "publish: verification-failed";
|
|
92
108
|
function terminal(reason, detail) {
|
|
93
109
|
// Always lands a terminal parent verdict, then exits 1 (failed) — the
|
|
94
110
|
// task stays parked for human attention. Only the verified path exits 0.
|
|
95
|
-
|
|
111
|
+
// Writer-side registry guard (D7): the verb is in the terminal-note
|
|
112
|
+
// registry before the note is written — a writer can never introduce a
|
|
113
|
+
// terminal verb the scans don't recognize.
|
|
114
|
+
if (!matchTerminalPublishNote(TERMINAL_VERB)) {
|
|
115
|
+
// Registry and writer disagree — fail loud, never emit an unrecognized
|
|
116
|
+
// terminal note.
|
|
117
|
+
process.stderr.write(JSON.stringify({
|
|
118
|
+
ok: false, verdict: "vocabulary-mismatch",
|
|
119
|
+
message: `terminal verb '${TERMINAL_VERB}' is not in the terminal-note registry`,
|
|
120
|
+
}) + "\n");
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
const msg = `${TERMINAL_VERB} ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
|
|
96
124
|
api("log-event", { task_id: taskId, type: "note", message: msg.slice(0, 1000) });
|
|
97
125
|
process.stderr.write(JSON.stringify({ ok: false, verdict: reason, message: msg }) + "\n");
|
|
98
126
|
process.exit(1);
|
|
@@ -298,8 +326,17 @@ if (head !== commit) {
|
|
|
298
326
|
// 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
|
|
299
327
|
// attempt is known — the original and retry attempts share a commit, and
|
|
300
328
|
// positional selection is luck. Within one attempt, select the OLDEST
|
|
301
|
-
// submitted entry (the trigger issuance;
|
|
302
|
-
//
|
|
329
|
+
// submitted entry (the trigger issuance; a receipt-bearing second "submitted"
|
|
330
|
+
// may follow it — the oldest binds the trigger instant, the latest carries
|
|
331
|
+
// the observed agent_id).
|
|
332
|
+
// D1 (2026-09-19; entry_kind cut 2026-09-20): the trigger instant is the
|
|
333
|
+
// ISSUANCE instant, not the ledger-write instant — a slow ledger write must
|
|
334
|
+
// not make a fresh build look stale (room #23 J1). The anchor is the OLDEST
|
|
335
|
+
// submitted entry by ts and the bound instant is its issued_at (falling back
|
|
336
|
+
// to its ts when the capture was unobserved). One anchor, one field — the
|
|
337
|
+
// retired entry_kind preference filter could never change the bound anchor
|
|
338
|
+
// (issuance and receipt entries carried the same captured issued_at), so the
|
|
339
|
+
// oldest-by-ts selection is the whole mechanism.
|
|
303
340
|
let triggerTs = null;
|
|
304
341
|
let manifestBefore = null;
|
|
305
342
|
try {
|
|
@@ -313,11 +350,9 @@ try {
|
|
|
313
350
|
(attempt == null || e.attempt === attempt)
|
|
314
351
|
);
|
|
315
352
|
if (candidates.length > 0) {
|
|
316
|
-
// Oldest first: the trigger issuance is the first submitted entry for
|
|
317
|
-
// the attempt (observations are "build-observed" per blocker 2).
|
|
318
353
|
candidates.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
319
354
|
const e = candidates[0];
|
|
320
|
-
triggerTs = e.ts || null;
|
|
355
|
+
triggerTs = e.issued_at || e.ts || null;
|
|
321
356
|
if (e.manifest_before && typeof e.manifest_before === "object") manifestBefore = e.manifest_before;
|
|
322
357
|
}
|
|
323
358
|
} catch (e) {
|
|
@@ -150,6 +150,24 @@ integration_target() {
|
|
|
150
150
|
git rev-parse --abbrev-ref HEAD 2>/dev/null || { echo "ERROR: cannot resolve HEAD"; return 1; }
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
# Resolve the remote branch a detached HEAD pushes to: the remote's default
|
|
154
|
+
# branch (origin/HEAD). Prints the bare branch name (no origin/ prefix).
|
|
155
|
+
# Attached checkouts never need this (their target IS the branch name).
|
|
156
|
+
# Fails when the destination is genuinely unknowable -- the caller fails
|
|
157
|
+
# closed (never a silent skip, never a refusal of a knowable push).
|
|
158
|
+
# (2026-09-19 detached-HEAD audit REDO: the old code treated detached+remote
|
|
159
|
+
# as PUSH_SKIPPED->PASS "by policy", hiding merged work that never reached
|
|
160
|
+
# the remote. The contract is explicit HEAD:<destination> semantics.)
|
|
161
|
+
resolve_push_destination() {
|
|
162
|
+
cd "$REPO"
|
|
163
|
+
local sym
|
|
164
|
+
sym=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) || {
|
|
165
|
+
echo "ERROR: detached HEAD with an origin remote, but origin/HEAD is not set -- the push destination is genuinely unknowable; set it (git remote set-head origin -a) and retry -- not pushing"
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
printf '%s\n' "${sym#origin/}"
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
# (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
|
|
154
172
|
# zero call sites and cmd_verify_merge reimplemented tip resolution inline
|
|
155
173
|
# with different semantics, a drift hazard. Tip resolution lives in exactly
|
|
@@ -323,8 +341,15 @@ acquire_merge_lock() {
|
|
|
323
341
|
# refresh needed) and by cmd_push_target for the R5 manual-conflict path
|
|
324
342
|
# (which refreshes the lock first). Never force-pushes.
|
|
325
343
|
#
|
|
326
|
-
# The caller must hold the merge lock. Prints one of PUSHED /
|
|
327
|
-
# NO_REMOTE_PUSH and returns 0, or prints
|
|
344
|
+
# The caller must hold the merge lock. Prints one of PUSHED /
|
|
345
|
+
# NO_REMOTE_PUSH and returns 0, or prints PUSH_FAILED / CONFLICT / ERROR
|
|
346
|
+
# and returns 1 as labeled. (PUSH_SKIPPED belongs to cmd_push_target's
|
|
347
|
+
# nothing-merged path only -- F-R3.) The push
|
|
348
|
+
# refspec is always explicit: a branch pushes as <branch>, a detached HEAD
|
|
349
|
+
# pushes as HEAD:<destination> where the destination is the remote's
|
|
350
|
+
# default branch resolved by resolve_push_destination() -- never a silent
|
|
351
|
+
# skip (2026-09-19 detached-HEAD audit REDO: PUSH_SKIPPED->PASS on
|
|
352
|
+
# detached+remote hid merged work that never reached the remote).
|
|
328
353
|
do_push() {
|
|
329
354
|
local task_id="$1"
|
|
330
355
|
cd "$REPO"
|
|
@@ -332,17 +357,6 @@ do_push() {
|
|
|
332
357
|
local target
|
|
333
358
|
target=$(integration_target) || return 1
|
|
334
359
|
|
|
335
|
-
# Detached HEAD: by design the integration line is local to this checkout
|
|
336
|
-
# (clean-room staging pins the reviewed commit; no remote ref is claimed
|
|
337
|
-
# for the detached line), so there is nothing to push. This is a policy
|
|
338
|
-
# statement, not a git limitation — `git push origin HEAD:<ref>` would
|
|
339
|
-
# work; we choose not to push the detached line. Room evidence is
|
|
340
|
-
# preserved in room state; releases ship through the loop's Publish phase.
|
|
341
|
-
if [ "$target" = "HEAD" ]; then
|
|
342
|
-
echo "PUSH_SKIPPED: detached HEAD -- the integration line is local by design; nothing to push"
|
|
343
|
-
return 0
|
|
344
|
-
fi
|
|
345
|
-
|
|
346
360
|
# No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
|
|
347
361
|
# code ran `git push origin` unconditionally, which exits 128 with no
|
|
348
362
|
# origin, got mislabeled PUSH_REJECTED, and failed the phase — while
|
|
@@ -354,8 +368,20 @@ do_push() {
|
|
|
354
368
|
return 0
|
|
355
369
|
fi
|
|
356
370
|
|
|
357
|
-
|
|
358
|
-
|
|
371
|
+
# Attached: branch name; detached: remote default branch via resolve_push_destination() (see header).
|
|
372
|
+
local dest refspec
|
|
373
|
+
if [ "$target" = "HEAD" ]; then
|
|
374
|
+
# Surface the resolver's reason: the caller cannot remediate a swallowed
|
|
375
|
+
# failure, and the retry-promise below must not lie about it (F-R1).
|
|
376
|
+
dest=$(resolve_push_destination) || { echo "$dest"; return 1; }
|
|
377
|
+
refspec="HEAD:$dest"
|
|
378
|
+
else
|
|
379
|
+
dest="$target"
|
|
380
|
+
refspec="$target"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
if git push origin "$refspec" 2>&1; then
|
|
384
|
+
echo "PUSHED: origin/$dest (refspec $refspec)"
|
|
359
385
|
return 0
|
|
360
386
|
fi
|
|
361
387
|
|
|
@@ -363,17 +389,17 @@ do_push() {
|
|
|
363
389
|
# "non-fast-forward" — auth errors got the same label). Recover
|
|
364
390
|
# mechanically while the lock is held: fetch, merge, retry once.
|
|
365
391
|
echo "PUSH_FAILED: push did not complete -- reconciling once while the lock is held"
|
|
366
|
-
if ! git fetch origin "$
|
|
367
|
-
echo "ERROR: git fetch origin $
|
|
392
|
+
if ! git fetch origin "$dest" 2>&1; then
|
|
393
|
+
echo "ERROR: git fetch origin $dest failed -- push not completed"
|
|
368
394
|
return 1
|
|
369
395
|
fi
|
|
370
|
-
if ! git merge --no-edit "origin/$
|
|
371
|
-
echo "CONFLICT: origin/$
|
|
396
|
+
if ! git merge --no-edit "origin/$dest" -m "merge: push-time reconcile ($task_id)" 2>&1; then
|
|
397
|
+
echo "CONFLICT: origin/$dest diverged with conflicting changes -- aborting"
|
|
372
398
|
git merge --abort 2>/dev/null || true
|
|
373
399
|
return 1
|
|
374
400
|
fi
|
|
375
|
-
if git push origin "$
|
|
376
|
-
echo "PUSHED: origin/$
|
|
401
|
+
if git push origin "$refspec" 2>&1; then
|
|
402
|
+
echo "PUSHED: origin/$dest (refspec $refspec, after reconcile)"
|
|
377
403
|
return 0
|
|
378
404
|
fi
|
|
379
405
|
echo "ERROR: push failed again after reconcile -- not pushing"
|
|
@@ -473,10 +499,12 @@ cmd_integrate() {
|
|
|
473
499
|
# the Integrate phase fails closed. Fetch and merge the remote branch now
|
|
474
500
|
# so the subsequent push is a fast-forward.
|
|
475
501
|
# Fail-soft when there is no remote; fail closed on a real conflict.
|
|
476
|
-
#
|
|
477
|
-
#
|
|
502
|
+
# Detached: there is no static branch target to reconcile against inline;
|
|
503
|
+
# do_push resolves the destination explicitly and reconciles on push
|
|
504
|
+
# failure, so the inline reconcile is skipped here (F-R2 -- the old
|
|
505
|
+
# "local by design" rationale is the rejected 0.14.2 policy).
|
|
478
506
|
if [ "$target" = "HEAD" ]; then
|
|
479
|
-
echo "NO_REMOTE_RECONCILE: detached HEAD --
|
|
507
|
+
echo "NO_REMOTE_RECONCILE: detached HEAD -- destination resolved explicitly by do_push, skipping inline reconcile"
|
|
480
508
|
elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
|
|
481
509
|
if git fetch origin "$target" 2>&1; then
|
|
482
510
|
if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
|
|
@@ -512,7 +540,7 @@ cmd_integrate() {
|
|
|
512
540
|
# agent run added only a lock refresh). R5's manual-conflict path uses the
|
|
513
541
|
# push-target command instead.
|
|
514
542
|
if ! do_push "$task_id"; then
|
|
515
|
-
echo "ERROR: push failed after merge $merged_commit -- merge is recorded;
|
|
543
|
+
echo "ERROR: push failed after merge $merged_commit -- merge is recorded; a re-run of push-target retries transient failures, but an unknowable destination needs 'git remote set-head origin -a' (see the ERROR line above -- F-R1)"
|
|
516
544
|
return 1
|
|
517
545
|
fi
|
|
518
546
|
|
|
@@ -834,6 +862,19 @@ cmd_integration_target() {
|
|
|
834
862
|
integration_target
|
|
835
863
|
}
|
|
836
864
|
|
|
865
|
+
# Agent-side one-liner: prints the remote branch a push updates. Attached:
|
|
866
|
+
# the branch name. Detached: the resolved destination from
|
|
867
|
+
# resolve_push_destination() (fails when genuinely unknowable).
|
|
868
|
+
cmd_push_destination() {
|
|
869
|
+
local target
|
|
870
|
+
target=$(integration_target) || exit 1
|
|
871
|
+
if [ "$target" = "HEAD" ]; then
|
|
872
|
+
resolve_push_destination
|
|
873
|
+
else
|
|
874
|
+
printf '%s\n' "$target"
|
|
875
|
+
fi
|
|
876
|
+
}
|
|
877
|
+
|
|
837
878
|
# Deterministic push of the integration target. Thin wrapper over do_push
|
|
838
879
|
# for the R5 manual-conflict path (re-running `integrate` there misfires on
|
|
839
880
|
# ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
|
|
@@ -888,6 +929,7 @@ case "$cmd" in
|
|
|
888
929
|
integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
|
|
889
930
|
verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
|
|
890
931
|
integration-target) cmd_integration_target ;;
|
|
932
|
+
push-destination) cmd_push_destination ;;
|
|
891
933
|
push-target) cmd_push_target "${1:?task_id required}" ;;
|
|
892
934
|
post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
|
|
893
935
|
terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
// write-ooda-verdict.js — deterministic writer for the OODA report's terminal state.
|
|
2
3
|
//
|
|
3
4
|
// After the see-act loop, the QA/repro agent records its verdict as
|
|
@@ -34,10 +35,9 @@
|
|
|
34
35
|
//
|
|
35
36
|
// Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
|
|
36
37
|
// randomness; ts comes only from --ts and is omitted when not passed.
|
|
37
|
-
"use strict";
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
|
|
40
|
+
import { join, resolve } from "node:path";
|
|
41
41
|
|
|
42
42
|
const VERDICTS = { PASS: 1, FAIL: 1, NOT_POSSIBLE: 1 };
|
|
43
43
|
|
|
@@ -85,6 +85,15 @@ function ledgerSeq(ledgerPath) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function main() {
|
|
88
|
+
// --help: before required-arg parsing (shebang⇔CLI contract).
|
|
89
|
+
if (process.argv.slice(2).includes("--help")) {
|
|
90
|
+
console.log(
|
|
91
|
+
"usage: node write-ooda-verdict.js --dir <phase-dir> --attempt <id> --verdict <PASS|FAIL|NOT_POSSIBLE>\n" +
|
|
92
|
+
" [--summary <text>] [--expected <text>] [--actual <text>] [--missing <json-array>] [--reason <text>] [--ts <iso>]\n" +
|
|
93
|
+
"--reason is REQUIRED and must be non-empty for FAIL and NOT_POSSIBLE"
|
|
94
|
+
);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
88
97
|
const args = parseArgs(process.argv.slice(2));
|
|
89
98
|
if (!args.dir) fail("missing --dir <phase-dir>");
|
|
90
99
|
if (args.attempt === undefined || String(args.attempt).trim() === "") {
|
package/package.json
CHANGED
package/workflows/bugfix.js
CHANGED
|
@@ -70,8 +70,10 @@ const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
|
|
|
70
70
|
const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
|
|
71
71
|
const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
|
|
72
72
|
const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
|
|
73
|
+
const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
|
|
74
|
+
const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
|
|
73
75
|
// See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
|
|
74
|
-
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
|
|
76
|
+
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
|
|
75
77
|
|
|
76
78
|
// Project config — passed by dispatcher, falls back to dashboard defaults
|
|
77
79
|
const projectConfig = inputs.project_config || {};
|
|
@@ -191,19 +193,12 @@ async function reaskVerdict(stepName, reworkSuffix, workerText) {
|
|
|
191
193
|
return verdict;
|
|
192
194
|
}
|
|
193
195
|
// Transport retry: the work-agent agent() call can throw even when the agent
|
|
194
|
-
// did the work
|
|
195
|
-
// the
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
|
|
201
|
-
// this covers the report never arriving. The assignment is retried boundedly
|
|
202
|
-
// with fresh keys (never a cached replay) before failing closed. Re-entry is
|
|
203
|
-
// safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
|
|
204
|
-
// retry trailer tells the agent to check existing state first and report
|
|
205
|
-
// rather than duplicate completed side effects, and the rework path already
|
|
206
|
-
// re-runs Build after rejection — Build re-entry is an established pattern.
|
|
196
|
+
// did the work — the runtime's JSON-candidate heuristic trips on brace-shaped
|
|
197
|
+
// prose. Retry the assignment boundedly with fresh keys (never a cached
|
|
198
|
+
// replay) before failing closed. Re-entry is safe: lifecycle scripts answer
|
|
199
|
+
// REUSED for existing worktrees/branches, the retry trailer tells the agent
|
|
200
|
+
// to check existing state first, and Build re-entry after rejection is an
|
|
201
|
+
// established pattern. Full history: docs/decisions/workflow-core.md.
|
|
207
202
|
function workRetryKey(stepName, reworkSuffix, attempt) {
|
|
208
203
|
return "work-" + stepName + reworkSuffix + "-t" + attempt;
|
|
209
204
|
}
|
|
@@ -215,7 +210,7 @@ function attemptKey(base, reworkCount) {
|
|
|
215
210
|
function pinLifecycle(key) {
|
|
216
211
|
return agent(
|
|
217
212
|
"Snapshot lifecycle scripts for version pinning.\n" +
|
|
218
|
-
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
213
|
+
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
219
214
|
"Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
|
|
220
215
|
{ key: key, label: "Pinning lifecycle scripts",
|
|
221
216
|
schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
|
|
@@ -396,27 +391,11 @@ function hydrateReleaseDecision(rec) {
|
|
|
396
391
|
return null;
|
|
397
392
|
}
|
|
398
393
|
|
|
399
|
-
// Publish read-back request (currently unavailable): the verbatim_request
|
|
400
|
-
// the parent protocol (docs/publish-verification.md) would hand to an
|
|
401
|
-
// independent read-back tool after the artifact build lands. artifact_inspect
|
|
402
|
-
// was removed by the platform (2026-09-14); artifact.inspect is malfunction
|
|
403
|
-
// diagnosis, not a substitute — so no agent-callable read-back tool exists
|
|
404
|
-
// and this request cannot currently be issued. Pure function — no I/O, no
|
|
405
|
-
// clock. The request carries the merged diff as the expected change and asks
|
|
406
|
-
// for an independent read of the artifact's actual source: for each file, the
|
|
407
|
-
// exact current text of the changed regions plus a per-line present/absent
|
|
408
|
-
// finding. Until a read-back path exists, the parent cannot independently
|
|
409
|
-
// confirm content and verification parks at "publish: verification-requested"
|
|
410
|
-
// (see docs/publish-verification.md). This preserves the circularity break
|
|
411
|
-
// that hollowed canary run 8 (2026-09-11): the old verifyAppliedChanges
|
|
412
|
-
// compared the builder's applied-report against the diff the report was
|
|
413
|
-
// derived from — a fabricated report passed by construction. The report
|
|
414
|
-
// itself is gone now (2026-09-16 fire-and-forget trigger). Independent
|
|
415
|
-
// read-back cannot be
|
|
416
|
-
// fabricated from the diff; it must match the artifact's real content.
|
|
417
|
-
|
|
418
394
|
// See docs/decisions/publish-path.md#publish-attempt-ledger: every trigger outcome is recorded in the durable ledger.
|
|
419
|
-
|
|
395
|
+
// keyTag (optional): same-outcome ledger writes in one pass need distinct
|
|
396
|
+
// agent-call keys — the runtime replays cached calls by key, so a shared key
|
|
397
|
+
// silently drops the second write (2026-09-19, submitted-on-issuance).
|
|
398
|
+
async function recordPublishLedger(entry, rework, keyTag) {
|
|
420
399
|
try {
|
|
421
400
|
var ledgerDir = crewHome + "/.publish-ledger";
|
|
422
401
|
var line = JSON.stringify({
|
|
@@ -430,7 +409,10 @@ async function recordPublishLedger(entry, rework) {
|
|
|
430
409
|
applied_report: entry.applied_report || null,
|
|
431
410
|
manifest_before: entry.manifest_before || null,
|
|
432
411
|
outcome: entry.outcome,
|
|
433
|
-
detail: entry.detail || ""
|
|
412
|
+
detail: entry.detail || "",
|
|
413
|
+
// D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
|
|
414
|
+
// instant (null when no trigger); ts = ledger-write instant.
|
|
415
|
+
issued_at: entry.issued_at || null,
|
|
434
416
|
});
|
|
435
417
|
var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
|
|
436
418
|
var res = await agent(
|
|
@@ -438,7 +420,7 @@ async function recordPublishLedger(entry, rework) {
|
|
|
438
420
|
"Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
|
|
439
421
|
" | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
|
|
440
422
|
"Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
|
|
441
|
-
{ key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
|
|
423
|
+
{ key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome + (keyTag ? "-" + keyTag : ""), rework),
|
|
442
424
|
label: "Recording publish attempt in ledger",
|
|
443
425
|
schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
|
|
444
426
|
);
|
|
@@ -1289,7 +1271,7 @@ while (i < STEPS.length) {
|
|
|
1289
1271
|
"The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
|
|
1290
1272
|
"- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
|
|
1291
1273
|
"- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
|
|
1292
|
-
"- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash
|
|
1274
|
+
"- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
|
|
1293
1275
|
"- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
|
|
1294
1276
|
"- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
1295
1277
|
"RESOLUTION:\n" +
|
|
@@ -1302,7 +1284,7 @@ while (i < STEPS.length) {
|
|
|
1302
1284
|
"ESCALATE — 'conflict needs human resolution', VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
1303
1285
|
"- ERROR: report it. VERDICT: FAIL.\n\n" +
|
|
1304
1286
|
"R5-PUSH (manual R5 resolution only — the normal path pushed inline). Run: " + LIFECYCLE_ENV + LIFECYCLE + " push-target " + taskId + "\n" +
|
|
1305
|
-
"PUSHED — report the merged hash, VERDICT: PASS; PUSH_SKIPPED
|
|
1287
|
+
"PUSHED — report the merged hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; PUSH_SKIPPED — no push attempted (no record + no lock); NO_REMOTE_PUSH — no remote; VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
|
|
1306
1288
|
"NEVER force-push.\n\n" +
|
|
1307
1289
|
"Report what happened at each step, ending with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
|
|
1308
1290
|
|
|
@@ -1640,7 +1622,7 @@ while (i < STEPS.length) {
|
|
|
1640
1622
|
log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
|
|
1641
1623
|
}
|
|
1642
1624
|
// See docs/decisions/publish-path.md#trigger-await: the artifact_edit call is awaited.
|
|
1643
|
-
var rebuildTrigger = null;
|
|
1625
|
+
var rebuildTrigger = null, triggerThrew = false;
|
|
1644
1626
|
try {
|
|
1645
1627
|
var triggerText = String(await agent(rebuildPrompt,
|
|
1646
1628
|
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
|
|
@@ -1660,7 +1642,36 @@ while (i < STEPS.length) {
|
|
|
1660
1642
|
}
|
|
1661
1643
|
} catch (triggerErr) {
|
|
1662
1644
|
log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
|
|
1645
|
+
triggerThrew = true;
|
|
1663
1646
|
}
|
|
1647
|
+
// D1 (2026-09-19; F-A1 upper-bound 2026-09-20): issued_at is an UPPER
|
|
1648
|
+
// bound on issuance — captured AFTER the trigger-agent call returns,
|
|
1649
|
+
// ferried from one shell date call (workflows have no clock) and
|
|
1650
|
+
// validated mechanically; null when unobserved (fallback: ts).
|
|
1651
|
+
// See docs/decisions/publish-path.md#d1-issuance-time.
|
|
1652
|
+
var triggerIssuedAt = null;
|
|
1653
|
+
try {
|
|
1654
|
+
var issuedAtFerry = await agent(
|
|
1655
|
+
"Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
|
|
1656
|
+
"Return JSON { \"ts\": \"<the date output, verbatim>\" } and nothing else.",
|
|
1657
|
+
{ key: attemptKey("publish-issued-at-" + taskId, totalReworkCount), label: "Capturing post-trigger instant (upper bound on issuance)",
|
|
1658
|
+
schema: { type: "object", properties: { ts: { type: "string" } }, required: ["ts"] } }
|
|
1659
|
+
);
|
|
1660
|
+
var issuedAtRaw = String((issuedAtFerry && issuedAtFerry.ts) || "").trim();
|
|
1661
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(issuedAtRaw)) triggerIssuedAt = issuedAtRaw;
|
|
1662
|
+
} catch (issuedAtErr) { /* issued_at stays null: the verifier falls back to ts */ }
|
|
1663
|
+
if (!triggerIssuedAt) log("Publish issuance instant unobserved for task " + taskId + " — issued_at null; the freshness gate falls back to ledger-write ts");
|
|
1664
|
+
// (2026-09-19, room #22) Submitted-on-issuance: see docs/decisions/publish-path.md#submitted-on-issuance.
|
|
1665
|
+
await recordPublishLedger({
|
|
1666
|
+
commit: mergeCommitForPublish,
|
|
1667
|
+
attempt: rebuildAttemptKey,
|
|
1668
|
+
agent_id: null,
|
|
1669
|
+
applied_report: publishAppliedObservation,
|
|
1670
|
+
manifest_before: preTriggerManifest,
|
|
1671
|
+
outcome: "submitted",
|
|
1672
|
+
issued_at: triggerIssuedAt,
|
|
1673
|
+
detail: "trigger issuance: rebuild-trigger agent invoked; receipt/observation pending. Invocation only - not evidence of artifact_edit or build acceptance." + (triggerThrew ? "; trigger-threw" : "; trigger-returned")
|
|
1674
|
+
}, totalReworkCount, "issuance");
|
|
1664
1675
|
// Post-trigger observation (primary, not fallback): the workflow
|
|
1665
1676
|
// attributes the edit itself. First the in-flight build state — a
|
|
1666
1677
|
// build whose agent_id is new relative to the pre-trigger baseline
|
|
@@ -1780,8 +1791,9 @@ while (i < STEPS.length) {
|
|
|
1780
1791
|
applied_report: publishAppliedObservation,
|
|
1781
1792
|
manifest_before: preTriggerManifest,
|
|
1782
1793
|
outcome: "submitted",
|
|
1783
|
-
|
|
1784
|
-
|
|
1794
|
+
issued_at: triggerIssuedAt,
|
|
1795
|
+
detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff); observation, not re-issuance"
|
|
1796
|
+
}, totalReworkCount, "receipt");
|
|
1785
1797
|
} else {
|
|
1786
1798
|
var newAuditDirs = [];
|
|
1787
1799
|
try {
|
|
@@ -1912,10 +1924,6 @@ while (i < STEPS.length) {
|
|
|
1912
1924
|
// its outcome — submitted, rejected, or unknown (unknown is recorded
|
|
1913
1925
|
// at the park site above). A later run or human matches commit hash +
|
|
1914
1926
|
// attempt key against the builder's eventual completion.
|
|
1915
|
-
// (2026-09-16) The trigger is fire-and-forget: the observation above
|
|
1916
|
-
// already recorded the ledger's submitted line on both positive paths
|
|
1917
|
-
// and parked on unknown — there is no applied report to observe and
|
|
1918
|
-
// no rejection signal to record.
|
|
1919
1927
|
// (2026-09-18, H2 verdict-first) The verdict was decided exactly
|
|
1920
1928
|
// once above; dispatch on it. landed bypasses the receipt poll
|
|
1921
1929
|
// (the audit evidence already proved completion); an explicit
|
|
@@ -2049,21 +2057,12 @@ while (i < STEPS.length) {
|
|
|
2049
2057
|
log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
|
|
2050
2058
|
} else {
|
|
2051
2059
|
// STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
|
|
2052
|
-
// the poll
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
// that appeared during the attempt window is evidence a build
|
|
2059
|
-
// completed. Attribution is by window, not by build identity:
|
|
2060
|
-
// the poll's saw_stranger signal only catches stranger builds in
|
|
2061
|
-
// flight AT a check — a stranger that finished entirely inside
|
|
2062
|
-
// the window is indistinguishable, so any observed stranger
|
|
2063
|
-
// blocks attribution and the outcome stays unknown. This never
|
|
2064
|
-
// re-issues the edit and never stamps provenance — ok=true only
|
|
2065
|
-
// routes to the parent's independent content read-back, which
|
|
2066
|
-
// remains the real verification.
|
|
2060
|
+
// the poll observes only in-flight builds; a build that finished
|
|
2061
|
+
// inside the window leaves a durable audit dir. Diff the audit
|
|
2062
|
+
// listing against the pre-trigger snapshot: a new timestamped dir
|
|
2063
|
+
// is evidence a build completed. Attribution is by window, not by build identity: any
|
|
2064
|
+
// observed stranger blocks it. Never re-issues, never stamps;
|
|
2065
|
+
// ok=true routes to the parent's read-back (the verification).
|
|
2067
2066
|
//
|
|
2068
2067
|
// The poll end-state is read from the poll's own observations,
|
|
2069
2068
|
// not from build_done alone: a build in flight at the last check
|