muse-crew 0.7.11 → 0.7.13
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/API.md +17 -3
- package/docs/guide.md +4 -4
- package/docs/ooda-report.md +32 -5
- package/docs/publish-verification.md +46 -22
- package/lib/AGENTS.md +2 -1
- package/lib/crew-api.js +186 -41
- package/lib/read-ooda-verdict.js +94 -0
- package/lib/readback-disk.js +186 -0
- package/lib/render-html.js +2 -6
- package/lib/see-act.js +2 -6
- package/lib/verify-publish.js +62 -4
- package/lib/write-ooda-verdict.js +19 -2
- package/package.json +5 -2
- package/seed/cron-body-template.md +7 -3
- package/seed/crons.json +1 -1
- package/workflows/bugfix.js +302 -12
- package/workflows/chore.js +236 -10
- package/workflows/crew-dispatch.js +71 -12
- package/workflows/crew-init.js +26 -32
- package/workflows/docs.js +10 -1
- package/workflows/standard.js +237 -11
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// read-ooda-verdict.js — deterministic cross-checker for the OODA terminal
|
|
2
|
+
// verdict (2026-09-15).
|
|
3
|
+
//
|
|
4
|
+
// The prose VERDICT: line in an agent's report is the routing signal, but
|
|
5
|
+
// verdict.json is the reason-carrying record the workflow closeout actually
|
|
6
|
+
// reads. This script cross-checks the two: the caller passes the verdict it
|
|
7
|
+
// read from prose (--expect) and the script fails loudly when the
|
|
8
|
+
// machine-readable record disagrees or cannot back the verdict.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node read-ooda-verdict.js --dir <phase-dir> --expect <PASS|FAIL>
|
|
12
|
+
//
|
|
13
|
+
// Reads <phase-dir>/verdict.json and prints exactly one JSON line to stdout.
|
|
14
|
+
//
|
|
15
|
+
// Exit 0 with {ok:true, verdict, reason, summary, expected, actual, attempt}
|
|
16
|
+
// when the record exists, parses, has a verdict field, the verdict equals
|
|
17
|
+
// --expect, and a FAIL carries a non-empty reason.
|
|
18
|
+
//
|
|
19
|
+
// Exit 2 with {ok:false, code, error} when:
|
|
20
|
+
// missing — verdict.json is absent (the agent never wrote one)
|
|
21
|
+
// corrupt — unparseable JSON, or parsed but no verdict field
|
|
22
|
+
// contradiction — the record's verdict !== --expect (NOT_POSSIBLE can
|
|
23
|
+
// never equal PASS or FAIL, so it lands here)
|
|
24
|
+
// no_reason — the record is FAIL with an empty or missing reason
|
|
25
|
+
//
|
|
26
|
+
// Determinism: no wall-clock reads, no randomness. The script never judges
|
|
27
|
+
// report content — the agent states the reason; the machine enforces its
|
|
28
|
+
// presence and its agreement with the prose verdict.
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
const { readFileSync, existsSync } = require("node:fs");
|
|
32
|
+
const { join, resolve } = require("node:path");
|
|
33
|
+
|
|
34
|
+
function fail(code, error) {
|
|
35
|
+
process.stdout.write(JSON.stringify({ ok: false, code: code, error: error }) + "\n");
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === "--dir") out.dir = argv[++i];
|
|
44
|
+
else if (a === "--expect") out.expect = argv[++i];
|
|
45
|
+
else fail("bad_input", "unknown flag: " + a);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function main() {
|
|
51
|
+
const args = parseArgs(process.argv.slice(2));
|
|
52
|
+
if (!args.dir) fail("bad_input", "missing --dir <phase-dir>");
|
|
53
|
+
if (!args.expect) fail("bad_input", "missing --expect <PASS|FAIL>");
|
|
54
|
+
if (args.expect !== "PASS" && args.expect !== "FAIL") {
|
|
55
|
+
fail("bad_input", "unknown --expect: " + args.expect + " (PASS|FAIL)");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const verdictPath = join(resolve(args.dir), "verdict.json");
|
|
59
|
+
if (!existsSync(verdictPath)) {
|
|
60
|
+
fail("missing", "verdict.json not found: " + verdictPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let record;
|
|
64
|
+
try {
|
|
65
|
+
record = JSON.parse(readFileSync(verdictPath, "utf8"));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
fail("corrupt", "verdict.json is unparseable: " + e.message);
|
|
68
|
+
}
|
|
69
|
+
if (!record || typeof record.verdict !== "string" || record.verdict === "") {
|
|
70
|
+
fail("corrupt", "verdict.json has no verdict field: " + verdictPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (record.verdict !== args.expect) {
|
|
74
|
+
fail("contradiction", "verdict.json verdict is " + record.verdict + " but prose verdict was " + args.expect);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (record.verdict === "FAIL") {
|
|
78
|
+
if (record.reason === undefined || String(record.reason).trim() === "") {
|
|
79
|
+
fail("no_reason", "verdict.json verdict is FAIL with no machine-readable reason");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.stdout.write(JSON.stringify({
|
|
84
|
+
ok: true,
|
|
85
|
+
verdict: record.verdict,
|
|
86
|
+
reason: record.reason === undefined ? "" : record.reason,
|
|
87
|
+
summary: record.summary === undefined ? "" : record.summary,
|
|
88
|
+
expected: record.expected === undefined ? "" : record.expected,
|
|
89
|
+
actual: record.actual === undefined ? "" : record.actual,
|
|
90
|
+
attempt: record.attempt === undefined ? "" : record.attempt,
|
|
91
|
+
}) + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main();
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// readback-disk.js — deterministic disk read-back sensor for parent publish
|
|
3
|
+
// verification (docs/publish-verification.md).
|
|
4
|
+
//
|
|
5
|
+
// The read-back gap (2026-09-14): the platform removed `artifact_inspect`
|
|
6
|
+
// and no agent-callable content read-back existed, so every artifact publish
|
|
7
|
+
// parked at `publish: verification-requested` permanently. The platform's
|
|
8
|
+
// artifact edits land in its on-disk working copy of the artifact source
|
|
9
|
+
// (~/workspace/ts-spaces/<slug>/ — verified empirically 2026-09-15: added
|
|
10
|
+
// lines present, removed lines absent across real platform commits), so a
|
|
11
|
+
// deterministic local sensor can replace the removed inspection tool: no
|
|
12
|
+
// LLM, no async handoff, no prose to parse.
|
|
13
|
+
//
|
|
14
|
+
// This script is the SENSOR; lib/verify-publish.js stays the judge. It
|
|
15
|
+
// emits the exact machine-readable findings block the verifier already
|
|
16
|
+
// parses:
|
|
17
|
+
//
|
|
18
|
+
// FILE: <path>
|
|
19
|
+
// ADDED: <exact added line from the diff> :: PRESENT|ABSENT
|
|
20
|
+
// REMOVED: <exact removed line from the diff> :: PRESENT|ABSENT
|
|
21
|
+
// END_FILE
|
|
22
|
+
//
|
|
23
|
+
// one ADDED line per (+) diff line and one REMOVED line per (-) diff line,
|
|
24
|
+
// in diff order. The verdict is PRESENT iff that exact line occurs in the
|
|
25
|
+
// file's CURRENT on-disk source (whole-line match), ABSENT otherwise.
|
|
26
|
+
//
|
|
27
|
+
// Authority boundary: the sensor reads the platform's working copy of the
|
|
28
|
+
// artifact source — the tree the hosted artifact is built/served from. If
|
|
29
|
+
// the working copy is stale relative to a just-applied edit, the findings
|
|
30
|
+
// honestly report ABSENT and the verifier fails CLOSED (parked, never a
|
|
31
|
+
// false stamp). Staleness can only park, never certify.
|
|
32
|
+
//
|
|
33
|
+
// Usage:
|
|
34
|
+
// node readback-disk.js --repo-path <path> --commit <sha> --base <sha>
|
|
35
|
+
// --slug <artifact-slug> --task-id <uuid> [--spaces-root <dir>]
|
|
36
|
+
//
|
|
37
|
+
// --base is the previously-stamped provenance source_commit (or the
|
|
38
|
+
// empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
|
|
39
|
+
// publish) — the SAME base the verifier uses. --spaces-root defaults to
|
|
40
|
+
// ~/workspace/ts-spaces.
|
|
41
|
+
//
|
|
42
|
+
// Exit codes: 0 ok (findings block on stdout) · 2 usage/validation ·
|
|
43
|
+
// 1 git/diff/read failure. On non-zero exit NOTHING is printed to stdout,
|
|
44
|
+
// so a caller must never save stdout as a findings file.
|
|
45
|
+
//
|
|
46
|
+
// Security: the slug is charset-validated and the resolved space directory
|
|
47
|
+
// must stay under the spaces root (realpath containment); every file path
|
|
48
|
+
// from the diff is likewise contained before reading. A path that escapes
|
|
49
|
+
// is a validation failure (exit 2), never a read.
|
|
50
|
+
|
|
51
|
+
import { execFileSync } from "node:child_process";
|
|
52
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
53
|
+
import { homedir } from "node:os";
|
|
54
|
+
import { join, resolve } from "node:path";
|
|
55
|
+
|
|
56
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
57
|
+
|
|
58
|
+
function arg(name) {
|
|
59
|
+
const i = process.argv.indexOf(name);
|
|
60
|
+
if (i < 0 || i + 1 >= process.argv.length) {
|
|
61
|
+
fail("usage", `${name} is required.`, 2);
|
|
62
|
+
}
|
|
63
|
+
return process.argv[i + 1];
|
|
64
|
+
}
|
|
65
|
+
function fail(code, message, exitCode) {
|
|
66
|
+
// stderr only — stdout must stay empty on failure.
|
|
67
|
+
process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
|
|
68
|
+
process.exit(exitCode);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const repoPath = arg("--repo-path");
|
|
72
|
+
const commit = arg("--commit");
|
|
73
|
+
const base = arg("--base");
|
|
74
|
+
const slug = arg("--slug");
|
|
75
|
+
const taskId = arg("--task-id");
|
|
76
|
+
const spacesRoot = process.argv.includes("--spaces-root")
|
|
77
|
+
? arg("--spaces-root")
|
|
78
|
+
: join(homedir(), "workspace", "ts-spaces");
|
|
79
|
+
|
|
80
|
+
if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
|
|
81
|
+
if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
|
|
82
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(slug)) {
|
|
83
|
+
fail("usage", `slug ${JSON.stringify(slug)} is not a safe artifact slug.`, 2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolve the space directory with realpath containment: the slug must not
|
|
87
|
+
// escape the spaces root (no "..", no symlinks pointing out).
|
|
88
|
+
let spaceDir;
|
|
89
|
+
try {
|
|
90
|
+
const rootReal = realpathSync(spacesRoot);
|
|
91
|
+
const candReal = realpathSync(join(spacesRoot, slug));
|
|
92
|
+
if (candReal !== rootReal && !candReal.startsWith(rootReal + "/")) {
|
|
93
|
+
fail("usage", `slug ${JSON.stringify(slug)} resolves outside the spaces root.`, 2);
|
|
94
|
+
}
|
|
95
|
+
spaceDir = candReal;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
fail("usage", `cannot resolve space dir for slug ${JSON.stringify(slug)}: ${e.message}`, 2);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (!statSync(spaceDir).isDirectory()) fail("usage", `space dir ${spaceDir} is not a directory.`, 2);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
fail("usage", `space dir ${spaceDir} unreadable: ${e.message}`, 2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The publish delta is base..commit — the same diff the verifier checks the
|
|
106
|
+
// findings against. Refuse a base that is not an ancestor: certifying a
|
|
107
|
+
// diff against an unrelated tree is worse than no read-back.
|
|
108
|
+
if (base !== EMPTY_TREE) {
|
|
109
|
+
try {
|
|
110
|
+
execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to read back an unrelated tree.`, 2);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let diff;
|
|
117
|
+
try {
|
|
118
|
+
diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
|
|
119
|
+
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
fail("git", `git diff failed: ${e.message}`, 1);
|
|
123
|
+
}
|
|
124
|
+
if (!diff.trim()) {
|
|
125
|
+
fail("git", "empty diff for commit — nothing to read back.", 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Parse the diff into per-file added/removed lines (byte-identical parser
|
|
129
|
+
// to build-readback-request.js and verify-publish.js: the verifier looks
|
|
130
|
+
// the raw lines up verbatim, so all three must agree on the split).
|
|
131
|
+
const files = [];
|
|
132
|
+
let current = null;
|
|
133
|
+
for (const line of diff.split("\n")) {
|
|
134
|
+
if (line.startsWith("diff --git")) {
|
|
135
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
136
|
+
current = { path: m ? m[2] : "unknown", added: [], removed: [] };
|
|
137
|
+
files.push(current);
|
|
138
|
+
} else if (current && line.startsWith("+") && !line.startsWith("+++")) {
|
|
139
|
+
current.added.push(line.slice(1));
|
|
140
|
+
} else if (current && line.startsWith("-") && !line.startsWith("---")) {
|
|
141
|
+
current.removed.push(line.slice(1));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Whole-line content of a disk file, normalized for the membership test.
|
|
146
|
+
// Trailing-newline artifact popped (same convention as verify-publish.js
|
|
147
|
+
// oldTreeLines); one trailing CR stripped per line so CRLF working copies
|
|
148
|
+
// compare honestly against LF diffs. The RAW diff lines are still what get
|
|
149
|
+
// emitted — the verifier looks those up verbatim.
|
|
150
|
+
const norm = (s) => (s.endsWith("\r") ? s.slice(0, -1) : s);
|
|
151
|
+
function diskLines(absPath) {
|
|
152
|
+
if (!existsSync(absPath)) return new Set(); // missing file: every line ABSENT
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = readFileSync(absPath, "utf8");
|
|
156
|
+
} catch (e) {
|
|
157
|
+
fail("read", `cannot read ${absPath}: ${e.message}`, 1);
|
|
158
|
+
}
|
|
159
|
+
const lines = text.split("\n");
|
|
160
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
161
|
+
return new Set(lines.map(norm));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
// Contain every diff path inside the space dir before reading.
|
|
167
|
+
const abs = resolve(spaceDir, f.path);
|
|
168
|
+
if (abs !== spaceDir && !abs.startsWith(spaceDir + "/")) {
|
|
169
|
+
fail("usage", `diff path ${JSON.stringify(f.path)} escapes the space dir.`, 2);
|
|
170
|
+
}
|
|
171
|
+
const present = diskLines(abs);
|
|
172
|
+
out.push(`FILE: ${f.path}`);
|
|
173
|
+
for (const line of f.added) {
|
|
174
|
+
out.push(`ADDED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
|
|
175
|
+
}
|
|
176
|
+
for (const line of f.removed) {
|
|
177
|
+
out.push(`REMOVED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
|
|
178
|
+
}
|
|
179
|
+
out.push("END_FILE");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
183
|
+
process.stderr.write(JSON.stringify({
|
|
184
|
+
ok: true, slug, task_id: taskId, commit, base,
|
|
185
|
+
files: files.map((f) => f.path),
|
|
186
|
+
} ) + "\n");
|
package/lib/render-html.js
CHANGED
|
@@ -52,6 +52,8 @@ function parseArgs(argv) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function loadPlaywright() {
|
|
55
|
+
// playwright-core is a declared dependency (package.json), so a normal
|
|
56
|
+
// npm install resolves it from the package's own node_modules.
|
|
55
57
|
try {
|
|
56
58
|
return require("playwright-core");
|
|
57
59
|
} catch (e) { /* fall through */ }
|
|
@@ -64,12 +66,6 @@ function loadPlaywright() {
|
|
|
64
66
|
return require(envDir);
|
|
65
67
|
} catch (e) { /* fall through */ }
|
|
66
68
|
}
|
|
67
|
-
const conventional = "/home/hatch/workspace/crew-tools/node_modules/playwright-core";
|
|
68
|
-
if (existsSync(conventional)) {
|
|
69
|
-
try {
|
|
70
|
-
return require(conventional);
|
|
71
|
-
} catch (e) { /* fall through */ }
|
|
72
|
-
}
|
|
73
69
|
fail(3, {
|
|
74
70
|
ok: false,
|
|
75
71
|
not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
|
package/lib/see-act.js
CHANGED
|
@@ -70,6 +70,8 @@ function parseArgs(argv) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function loadPlaywright() {
|
|
73
|
+
// playwright-core is a declared dependency (package.json), so a normal
|
|
74
|
+
// npm install resolves it from the package's own node_modules.
|
|
73
75
|
try {
|
|
74
76
|
return require("playwright-core");
|
|
75
77
|
} catch (e) { /* fall through */ }
|
|
@@ -82,12 +84,6 @@ function loadPlaywright() {
|
|
|
82
84
|
return require(envDir);
|
|
83
85
|
} catch (e) { /* fall through */ }
|
|
84
86
|
}
|
|
85
|
-
const conventional = "/home/hatch/workspace/crew-tools/node_modules/playwright-core";
|
|
86
|
-
if (existsSync(conventional)) {
|
|
87
|
-
try {
|
|
88
|
-
return require(conventional);
|
|
89
|
-
} catch (e) { /* fall through */ }
|
|
90
|
-
}
|
|
91
87
|
fail(3, {
|
|
92
88
|
ok: false,
|
|
93
89
|
not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
|
package/lib/verify-publish.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// async read-back inspection's result JSON and MECHANICALLY decides the
|
|
6
6
|
// verdict: it parses the inspector's machine-readable findings block,
|
|
7
7
|
// compares every added/removed diff line against the reported
|
|
8
|
-
// present/absent verdicts
|
|
9
|
-
//
|
|
8
|
+
// present/absent verdicts (with a mechanically computed collision
|
|
9
|
+
// exemption for removed lines that also occur in untouched code),
|
|
10
|
+
// checks supersession via git, and only then stamps provenance, logs
|
|
11
|
+
// the terminal event, and re-queues the task.
|
|
10
12
|
//
|
|
11
13
|
// The LLM is the sensor (it reads the artifact source); this code is the
|
|
12
14
|
// judge. Unparseable findings, content mismatches, supersession, and stamp
|
|
@@ -207,6 +209,51 @@ if (!findings || bestScore <= 0) {
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
// --- 4. Mechanical comparison ----------------------------------------------
|
|
212
|
+
// Collision exemption (2026-09-15, task 00bca4b8): a removed diff line that
|
|
213
|
+
// also occurs verbatim in untouched code has zero discriminating power —
|
|
214
|
+
// its presence in the new file cannot tell "old block removed" from "old
|
|
215
|
+
// block present". Task 1d692d91 parked a valid publish because two removed
|
|
216
|
+
// lines (` return (`, ` </div>`) occur identically in
|
|
217
|
+
// the untouched WorkflowSteps component; the naive every-removed-line-ABSENT
|
|
218
|
+
// rule failed closed on zero signal.
|
|
219
|
+
//
|
|
220
|
+
// The exemption is mechanically computed and signal-preserving: for file F,
|
|
221
|
+
// line L is exempt iff L occurs in F's old tree (at <base>) strictly more
|
|
222
|
+
// times than the expected diff removes it. An exempted line must survive in
|
|
223
|
+
// untouched code no matter what, so exempting it can never turn a missed
|
|
224
|
+
// removal into a pass. No change to the sensor: build-readback-request.js
|
|
225
|
+
// still reports whole-file PRESENT/ABSENT honestly; only this judge gets
|
|
226
|
+
// smarter.
|
|
227
|
+
function oldTreeLines(path) {
|
|
228
|
+
// Exact whole-line contents of <path> at <base>; [] when the base is the
|
|
229
|
+
// empty tree or the file did not exist there (a new file has no removed
|
|
230
|
+
// lines, so the exemption is vacuous for it).
|
|
231
|
+
if (base === EMPTY_TREE) return [];
|
|
232
|
+
let text;
|
|
233
|
+
try {
|
|
234
|
+
text = execFileSync("git", ["-C", repoPath, "show", `${base}:${path}`], {
|
|
235
|
+
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
if (text === "") return [];
|
|
241
|
+
const lines = text.split("\n");
|
|
242
|
+
if (lines[lines.length - 1] === "") lines.pop(); // drop the trailing-newline artifact
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
const oldTreeCounts = new Map(); // path -> Map(line -> occurrences in old tree)
|
|
246
|
+
function oldCount(path, line) {
|
|
247
|
+
let counts = oldTreeCounts.get(path);
|
|
248
|
+
if (counts === undefined) {
|
|
249
|
+
counts = new Map();
|
|
250
|
+
for (const l of oldTreeLines(path)) counts.set(l, (counts.get(l) || 0) + 1);
|
|
251
|
+
oldTreeCounts.set(path, counts);
|
|
252
|
+
}
|
|
253
|
+
return counts.get(line) || 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let exempted = 0;
|
|
210
257
|
for (const [path, exp] of expected) {
|
|
211
258
|
const found = findings.get(path);
|
|
212
259
|
if (!found) terminal("content-mismatch", `no findings for changed file ${path}`);
|
|
@@ -215,10 +262,19 @@ for (const [path, exp] of expected) {
|
|
|
215
262
|
if (v === undefined) terminal("unreadable-result", `no ADDED finding for line in ${path}: ${line.slice(0, 60)}`);
|
|
216
263
|
if (v !== "PRESENT") terminal("content-mismatch", `added line ABSENT in ${path}: ${line.slice(0, 80)}`);
|
|
217
264
|
}
|
|
265
|
+
// The diff may remove the same line more than once; the old tree must
|
|
266
|
+
// account for every removal before a line counts as a collision.
|
|
267
|
+
const removedBudget = new Map();
|
|
268
|
+
for (const line of exp.removed) removedBudget.set(line, (removedBudget.get(line) || 0) + 1);
|
|
218
269
|
for (const line of exp.removed) {
|
|
219
270
|
const v = found.removed.get(line);
|
|
220
271
|
if (v === undefined) terminal("unreadable-result", `no REMOVED finding for line in ${path}: ${line.slice(0, 60)}`);
|
|
221
|
-
if (v
|
|
272
|
+
if (v === "ABSENT") continue;
|
|
273
|
+
if (oldCount(path, line) > removedBudget.get(line)) {
|
|
274
|
+
exempted += 1; // colliding pre-existing line: zero signal, cannot fail a good publish
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
terminal("content-mismatch", `removed line PRESENT in ${path}: ${line.slice(0, 80)}`);
|
|
222
278
|
}
|
|
223
279
|
}
|
|
224
280
|
|
|
@@ -259,7 +315,9 @@ if (!p || p.source_commit !== commit || p.task_id !== taskId || p.crew_release !
|
|
|
259
315
|
|
|
260
316
|
// --- 8. Terminal verified event + re-queue -----------------------------------
|
|
261
317
|
const verifiedMsg =
|
|
262
|
-
`publish: verified ${commit} (${inspectionId}) — read-back: all added lines PRESENT, all removed lines ABSENT
|
|
318
|
+
`publish: verified ${commit} (${inspectionId}) — read-back: all added lines PRESENT, all removed lines ABSENT` +
|
|
319
|
+
(exempted > 0 ? ` (${exempted} colliding removed line${exempted === 1 ? "" : "s"} exempted)` : "") +
|
|
320
|
+
`; ${buildNote}; no supersession (HEAD=${commit}).`;
|
|
263
321
|
api("log-event", { task_id: taskId, type: "note", message: verifiedMsg.slice(0, 1000) });
|
|
264
322
|
api("update-task", { id: taskId, state: "in_progress" });
|
|
265
323
|
process.stdout.write(JSON.stringify({ ok: true, verdict: "verified", commit, build_note: buildNote }) + "\n");
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// [--summary <text>] [--expected <text>] [--actual <text>]
|
|
10
10
|
// [--missing <json-array>] [--reason <text>] [--ts <iso>]
|
|
11
11
|
//
|
|
12
|
+
// --reason is REQUIRED and must be non-empty after trim when --verdict is
|
|
13
|
+
// FAIL or NOT_POSSIBLE; a missing or empty --reason fails with exit 2 and
|
|
14
|
+
// writes nothing (no verdict.json, no ledger line). A FAIL verdict must
|
|
15
|
+
// carry a machine-readable reason — an unreasoned FAIL can never be written.
|
|
16
|
+
//
|
|
12
17
|
// Writes two records:
|
|
13
18
|
// <phase-dir>/verdict.json — the LATEST verdict (what the workflow
|
|
14
19
|
// closeout reads). Overwritten on each call.
|
|
@@ -24,7 +29,8 @@
|
|
|
24
29
|
//
|
|
25
30
|
// missing_evidence names evidence that should exist but honestly does not
|
|
26
31
|
// (e.g. "no pixel-visual of the footer element: infinite scroll").
|
|
27
|
-
// reason carries the NOT POSSIBLE explanation
|
|
32
|
+
// reason carries the FAIL defect or the NOT POSSIBLE explanation — required
|
|
33
|
+
// for both verdicts, never optional.
|
|
28
34
|
//
|
|
29
35
|
// Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
|
|
30
36
|
// randomness; ts comes only from --ts and is omitted when not passed.
|
|
@@ -87,6 +93,17 @@ function main() {
|
|
|
87
93
|
if (!args.verdict) fail("missing --verdict <PASS|FAIL|NOT_POSSIBLE>");
|
|
88
94
|
if (!VERDICTS[args.verdict]) fail("unknown --verdict: " + args.verdict + " (PASS|FAIL|NOT_POSSIBLE)");
|
|
89
95
|
|
|
96
|
+
// A FAIL or NOT_POSSIBLE verdict without a machine-readable reason cannot
|
|
97
|
+
// be written — the workflow closeout reads verdict.json for the reason,
|
|
98
|
+
// and an unreasoned negative verdict is a broken record.
|
|
99
|
+
var reason = "";
|
|
100
|
+
if (args.verdict === "FAIL" || args.verdict === "NOT_POSSIBLE") {
|
|
101
|
+
if (args.reason === undefined || String(args.reason).trim() === "") {
|
|
102
|
+
fail("--reason is required for a " + args.verdict + " verdict and must be non-empty");
|
|
103
|
+
}
|
|
104
|
+
reason = String(args.reason).trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
const attempt = String(args.attempt).trim();
|
|
91
108
|
|
|
92
109
|
let missing = [];
|
|
@@ -109,7 +126,7 @@ function main() {
|
|
|
109
126
|
actual: args.actual || "",
|
|
110
127
|
missing_evidence: missing,
|
|
111
128
|
};
|
|
112
|
-
if (args.verdict === "NOT_POSSIBLE") record.reason =
|
|
129
|
+
if (args.verdict === "FAIL" || args.verdict === "NOT_POSSIBLE") record.reason = reason;
|
|
113
130
|
if (args.ts) record.ts = args.ts;
|
|
114
131
|
|
|
115
132
|
const dir = resolve(args.dir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muse-crew",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -25,5 +25,8 @@
|
|
|
25
25
|
"workflows",
|
|
26
26
|
"software-development"
|
|
27
27
|
],
|
|
28
|
-
"author": "emojimanegg1"
|
|
28
|
+
"author": "emojimanegg1",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"playwright-core": "1.63.0"
|
|
31
|
+
}
|
|
29
32
|
}
|
|
@@ -37,10 +37,14 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
37
37
|
|
|
38
38
|
If the dispatcher returned no claims or the claims array is empty, log NO_DISPATCH and CONTINUE to Step 4.5 — do NOT exit. Verification (4.5) and evidence (6) run independently of dispatch claims. A no-claims tick must still verify parked publishes and deliver evidence. (Fixed 2026-09-14: the old "exit on NO_DISPATCH" skipped verification permanently.)
|
|
39
39
|
|
|
40
|
-
4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects,
|
|
40
|
+
4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects, reads back, and certifies; you are only the ferry between the deterministic steps (scan → sensor → verifier).
|
|
41
41
|
- Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-verification-pending`
|
|
42
42
|
This atomically claims each verification-pending task (1-hour lease, so a second tick cannot double-verify) and reconciles verified-but-still-parked tasks to `in_progress`. It returns `{ to_verify: [...], reconciled: [...] }`. Log both lists. If the scan exits 2 (e.g. the active release cannot be resolved), log the error loudly and continue — do NOT work around it.
|
|
43
|
-
- **
|
|
43
|
+
- **Read-back (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish), then run:
|
|
44
|
+
`node {crewHome}/lib/readback-disk.js --repo-path "<repo_path>" --commit <commit> --base <base> --slug "<deploy_slug>" --task-id <task_id> > /tmp/readback-<task_id>.txt 2> /tmp/readback-<task_id>.err`
|
|
45
|
+
Use the entry's `repo_path` and `deploy_slug` verbatim. If the sensor exits 0, the result file holds the findings block — hand it to the verify step below. If it exits non-zero, do NOT save or use stdout: log `publish: verification-procedural-error <commit> <first line of the .err file>` and leave the task parked — the next tick retries. A sensor failure is procedural (the read could not be performed), never a content verdict. Do NOT judge content yourself, and do NOT stamp provenance.
|
|
46
|
+
- **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
|
|
47
|
+
Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
|
|
44
48
|
- Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The verify script's machine-checked comparison is the only certification.
|
|
45
49
|
|
|
46
50
|
5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
|
|
@@ -58,7 +62,7 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
58
62
|
- **Task-level error (not a platform error):** The workflow's own error handling applies. Stop monitoring this run.
|
|
59
63
|
- **If status is `running` or `paused`:** Continue polling.
|
|
60
64
|
- **Monitor ceiling:** 90 minutes from tick start. If a run is still not terminal then (pathological — the work phase caps each `agent()` call at 60 minutes), exit; the next tick's Step 0 continues recovery through the durable mapping.
|
|
61
|
-
- **Backstop (not the plan, the insurance):** If THIS launcher dies early for any reason (platform kill, cell recycle), the next tick's Step 0 detects the dead run through the durable platform run -> task mapping and retries it — at most ~
|
|
65
|
+
- **Backstop (not the plan, the insurance):** If THIS launcher dies early for any reason (platform kill, cell recycle), the next tick's Step 0 detects the dead run through the durable platform run -> task mapping and retries it — at most ~15 minutes later. The mapping exists so a dead launcher never strands a task for an hour; the monitor exists so the launcher rarely dies mid-run in the first place.
|
|
62
66
|
- When all launched workflows are terminal or retry-exhausted, continue to step 6 (evidence) — do not exit before delivering evidence for newly-done tasks.
|
|
63
67
|
|
|
64
68
|
6. **Deliver QA evidence to chat (Eric's screenshots):** You are the crew's voice to Eric. Whenever a task completes QA, its evidence screenshots must land in this report automatically — Eric explicitly asked for them. This step is mechanical discovery, not judgment.
|