muse-crew 0.7.10 → 0.7.12
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 +36 -14
- package/docs/guide.md +5 -5
- package/docs/ooda-report.md +150 -0
- package/docs/publish-verification.md +277 -88
- package/docs/visual-verdict.md +81 -67
- package/lib/AGENTS.md +8 -0
- package/lib/append-ooda-step.js +167 -0
- package/lib/build-readback-request.js +130 -0
- package/lib/compose-evidence-caption.js +141 -0
- package/lib/crew-api.js +467 -43
- package/lib/edit-image.py +216 -0
- package/lib/read-ooda-verdict.js +94 -0
- package/lib/readback-disk.js +186 -0
- package/lib/render-html.js +142 -0
- package/lib/see-act.js +327 -0
- package/lib/serve-artifact.js +203 -0
- package/lib/verify-publish.js +323 -0
- package/lib/write-ooda-verdict.js +147 -0
- package/package.json +1 -1
- package/seed/cron-body-template.md +30 -4
- package/seed/crons.json +1 -1
- package/workflows/bugfix.js +512 -220
- package/workflows/chore.js +389 -119
- package/workflows/crew-dispatch.js +72 -13
- package/workflows/docs.js +11 -2
- package/workflows/standard.js +413 -235
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""edit-image.py — deterministic image editing for experiential evidence.
|
|
3
|
+
|
|
4
|
+
The see-act driver captures full frames; this tool derives closer looks
|
|
5
|
+
from them without a browser: crop a region, zoom into a point, caption a
|
|
6
|
+
frame, or compose an n-up grid. Pillow only — no network, no randomness,
|
|
7
|
+
no timestamps; same inputs always produce byte-identical outputs.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 edit-image.py crop --in <png> --out <png> --region x,y,w,h
|
|
11
|
+
python3 edit-image.py zoom --in <png> --out <png> --factor <f> [--center x,y]
|
|
12
|
+
python3 edit-image.py label --in <png> --out <png> --text <caption>
|
|
13
|
+
python3 edit-image.py nup --out <png> --cols <n> --in <png> [--in <png> ...]
|
|
14
|
+
[--labels "first|second|third"]
|
|
15
|
+
|
|
16
|
+
crop: extract the pixel region (x, y, width, height). Out of bounds -> error.
|
|
17
|
+
zoom: crop a (w/f, h/f) box centered at --center (default: image center),
|
|
18
|
+
then scale it back up to the original frame size with NEAREST
|
|
19
|
+
(pixel-crisp, so wrapped text vs single-line text stays unambiguous).
|
|
20
|
+
label: append a caption bar under the image with --text.
|
|
21
|
+
nup: arrange --in frames left-to-right, top-to-bottom in --cols columns;
|
|
22
|
+
cells are padded to the largest frame; --labels are drawn as caption
|
|
23
|
+
bars, one per frame, in order (| separated).
|
|
24
|
+
|
|
25
|
+
Stdout is one JSON line: {"ok": true, "out": <path>, "size": [w, h]}.
|
|
26
|
+
Errors print {"ok": false, "error": <msg>} and exit 2.
|
|
27
|
+
|
|
28
|
+
Every derived frame is evidence: the caller must log it (append-ooda-step.js
|
|
29
|
+
--action crop|zoom|label|nup) — a frame produced but not logged is not evidence.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
37
|
+
except ImportError:
|
|
38
|
+
Image = None
|
|
39
|
+
|
|
40
|
+
CAPTION_PX = 30
|
|
41
|
+
CAPTION_BG = (24, 24, 24)
|
|
42
|
+
CAPTION_FG = (245, 245, 245)
|
|
43
|
+
GRID_BG = (240, 240, 240)
|
|
44
|
+
GRID_GAP = 8
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fail(msg):
|
|
48
|
+
sys.stdout.write(json.dumps({"ok": False, "error": msg}) + "\n")
|
|
49
|
+
sys.exit(2)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_pair(text, name):
|
|
53
|
+
try:
|
|
54
|
+
x, y = text.split(",")
|
|
55
|
+
return int(x), int(y)
|
|
56
|
+
except Exception:
|
|
57
|
+
fail("--%s must be x,y in pixels, got: %s" % (name, text))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_region(text):
|
|
61
|
+
try:
|
|
62
|
+
x, y, w, h = text.split(",")
|
|
63
|
+
x, y, w, h = int(x), int(y), int(w), int(h)
|
|
64
|
+
except Exception:
|
|
65
|
+
fail("--region must be x,y,w,h in pixels, got: " + text)
|
|
66
|
+
if w < 1 or h < 1:
|
|
67
|
+
fail("--region width and height must be positive, got: " + text)
|
|
68
|
+
return x, y, w, h
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(path):
|
|
72
|
+
try:
|
|
73
|
+
return Image.open(path).convert("RGB")
|
|
74
|
+
except Exception as e:
|
|
75
|
+
fail("cannot read input " + path + ": " + str(e))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save(img, path):
|
|
79
|
+
try:
|
|
80
|
+
img.save(path, format="PNG")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
fail("cannot write output " + path + ": " + str(e))
|
|
83
|
+
sys.stdout.write(json.dumps({"ok": True, "out": path, "size": [img.width, img.height]}) + "\n")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_crop(args):
|
|
87
|
+
x, y, w, h = parse_region(args.region)
|
|
88
|
+
img = load(args.in_path)
|
|
89
|
+
if x < 0 or y < 0 or x + w > img.width or y + h > img.height:
|
|
90
|
+
fail("region %s is outside the %dx%d frame" % (args.region, img.width, img.height))
|
|
91
|
+
save(img.crop((x, y, x + w, y + h)), args.out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_zoom(args):
|
|
95
|
+
try:
|
|
96
|
+
f = float(args.factor)
|
|
97
|
+
except Exception:
|
|
98
|
+
fail("--factor must be a number, got: " + args.factor)
|
|
99
|
+
if f < 1.01:
|
|
100
|
+
fail("--factor must be > 1, got: " + args.factor)
|
|
101
|
+
img = load(args.in_path)
|
|
102
|
+
cx, cy = parse_pair(args.center, "center") if args.center else (img.width // 2, img.height // 2)
|
|
103
|
+
bw, bh = int(img.width / f), int(img.height / f)
|
|
104
|
+
if bw < 1 or bh < 1:
|
|
105
|
+
fail("--factor %s is too large for a %dx%d frame" % (args.factor, img.width, img.height))
|
|
106
|
+
x, y = cx - bw // 2, cy - bh // 2
|
|
107
|
+
if x < 0 or y < 0 or x + bw > img.width or y + bh > img.height:
|
|
108
|
+
fail("zoom box (%d,%d %dx%d) is outside the %dx%d frame" % (x, y, bw, bh, img.width, img.height))
|
|
109
|
+
box = img.crop((x, y, x + bw, y + bh))
|
|
110
|
+
# NEAREST keeps source pixels crisp — a zoom for inspection must not blur
|
|
111
|
+
# the very pixels (wrapped vs single-line text) under judgment.
|
|
112
|
+
save(box.resize((img.width, img.height), Image.NEAREST), args.out)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def caption_bar(img, text):
|
|
116
|
+
bar = Image.new("RGB", (img.width, CAPTION_PX), CAPTION_BG)
|
|
117
|
+
draw = ImageDraw.Draw(bar)
|
|
118
|
+
font = ImageFont.load_default()
|
|
119
|
+
draw.text((8, 8), text, font=font, fill=CAPTION_FG)
|
|
120
|
+
canvas = Image.new("RGB", (img.width, img.height + CAPTION_PX), GRID_BG)
|
|
121
|
+
canvas.paste(img, (0, 0))
|
|
122
|
+
canvas.paste(bar, (0, img.height))
|
|
123
|
+
return canvas
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_label(args):
|
|
127
|
+
if not args.text:
|
|
128
|
+
fail("--text must not be empty")
|
|
129
|
+
save(caption_bar(load(args.in_path), args.text), args.out)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_nup(args):
|
|
133
|
+
if not args.ins:
|
|
134
|
+
fail("nup needs at least one --in <png>")
|
|
135
|
+
try:
|
|
136
|
+
cols = int(args.cols)
|
|
137
|
+
except Exception:
|
|
138
|
+
fail("--cols must be a positive integer, got: " + args.cols)
|
|
139
|
+
if cols < 1:
|
|
140
|
+
fail("--cols must be a positive integer, got: " + args.cols)
|
|
141
|
+
labels = args.labels.split("|") if args.labels else []
|
|
142
|
+
if labels and len(labels) != len(args.ins):
|
|
143
|
+
fail("--labels has %d entries for %d inputs" % (len(labels), len(args.ins)))
|
|
144
|
+
frames = [load(p) for p in args.ins]
|
|
145
|
+
if labels:
|
|
146
|
+
frames = [caption_bar(fr, tx) for fr, tx in zip(frames, labels)]
|
|
147
|
+
cw, ch = max(fr.width for fr in frames), max(fr.height for fr in frames)
|
|
148
|
+
rows = (len(frames) + cols - 1) // cols
|
|
149
|
+
canvas = Image.new(
|
|
150
|
+
"RGB",
|
|
151
|
+
(cols * cw + (cols - 1) * GRID_GAP, rows * ch + (rows - 1) * GRID_GAP),
|
|
152
|
+
GRID_BG,
|
|
153
|
+
)
|
|
154
|
+
for i, fr in enumerate(frames):
|
|
155
|
+
r, c = divmod(i, cols)
|
|
156
|
+
ox = c * (cw + GRID_GAP) + (cw - fr.width) // 2
|
|
157
|
+
oy = r * (ch + GRID_GAP) + (ch - fr.height) // 2
|
|
158
|
+
canvas.paste(fr, (ox, oy))
|
|
159
|
+
save(canvas, args.out)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main():
|
|
163
|
+
if Image is None:
|
|
164
|
+
fail("Pillow (PIL) is required but not installed")
|
|
165
|
+
if len(sys.argv) < 2:
|
|
166
|
+
fail("usage: edit-image.py <crop|zoom|label|nup> [flags]")
|
|
167
|
+
cmd = sys.argv[1]
|
|
168
|
+
rest = sys.argv[2:]
|
|
169
|
+
|
|
170
|
+
# Minimal flag parser (no argparse: keep the tool dependency-free and the
|
|
171
|
+
# surface identical to the other lib scripts).
|
|
172
|
+
flags = {"in": [], "labels": None, "text": None, "region": None,
|
|
173
|
+
"factor": None, "center": None, "cols": None, "out": None}
|
|
174
|
+
i = 0
|
|
175
|
+
while i < len(rest):
|
|
176
|
+
a = rest[i]
|
|
177
|
+
if a == "--in":
|
|
178
|
+
i += 1
|
|
179
|
+
flags["in"].append(rest[i])
|
|
180
|
+
elif a in ("--out", "--region", "--factor", "--center", "--cols", "--text", "--labels"):
|
|
181
|
+
i += 1
|
|
182
|
+
flags[a[2:]] = rest[i]
|
|
183
|
+
else:
|
|
184
|
+
fail("unknown flag: " + a)
|
|
185
|
+
i += 1
|
|
186
|
+
|
|
187
|
+
class Args: # noqa: too few public methods — simple namespace
|
|
188
|
+
pass
|
|
189
|
+
args = Args()
|
|
190
|
+
args.in_path = flags["in"][0] if flags["in"] else None
|
|
191
|
+
args.ins = flags["in"]
|
|
192
|
+
args.out = flags["out"]
|
|
193
|
+
args.region = flags["region"]
|
|
194
|
+
args.factor = flags["factor"]
|
|
195
|
+
args.center = flags["center"]
|
|
196
|
+
args.cols = flags["cols"]
|
|
197
|
+
args.text = flags["text"]
|
|
198
|
+
args.labels = flags["labels"]
|
|
199
|
+
|
|
200
|
+
if cmd in ("crop", "zoom", "label"):
|
|
201
|
+
if not args.in_path:
|
|
202
|
+
fail(cmd + " needs --in <png>")
|
|
203
|
+
if not args.out:
|
|
204
|
+
fail(cmd + " needs --out <png>")
|
|
205
|
+
if cmd == "crop" and not args.region:
|
|
206
|
+
fail("crop needs --region x,y,w,h")
|
|
207
|
+
if cmd == "zoom" and not args.factor:
|
|
208
|
+
fail("zoom needs --factor <f>")
|
|
209
|
+
if cmd == "nup" and not args.out:
|
|
210
|
+
fail("nup needs --out <png>")
|
|
211
|
+
|
|
212
|
+
{"crop": cmd_crop, "zoom": cmd_zoom, "label": cmd_label, "nup": cmd_nup}.get(cmd, lambda a: fail("unknown command: " + cmd))(args)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|
|
@@ -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");
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// render-html.js — render an HTML evidence layout to PNG via headless Chromium.
|
|
2
|
+
//
|
|
3
|
+
// The reef-qa pattern (2026-09-14): the agent composes evidence as HTML —
|
|
4
|
+
// before/after pairs, annotated callouts, n-up grids with real typography —
|
|
5
|
+
// and renders it with the same Chromium the see-act driver uses. Pillow
|
|
6
|
+
// covers pixel ops (crop/zoom); HTML covers composition. This tool is the
|
|
7
|
+
// deterministic renderer: fixed viewport width, system fonts only, hermetic.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node render-html.js --in <page.html> --out <shot.png> [--width <px>]
|
|
11
|
+
//
|
|
12
|
+
// The HTML references images by relative path (resolved against the HTML
|
|
13
|
+
// file's directory) or file:// URL. No http(s) subresources: the render is
|
|
14
|
+
// hermetic, so any http(s) request is aborted and the render fails loudly
|
|
15
|
+
// (exit 2) listing the blocked URLs — a composition that silently drops a
|
|
16
|
+
// remote asset is not evidence.
|
|
17
|
+
//
|
|
18
|
+
// Stdout is one JSON line: {ok:true, out, size:[w,h]}.
|
|
19
|
+
// Exit codes: 0 = ok, 2 = usage/render error, 3 = NOT POSSIBLE (environment:
|
|
20
|
+
// missing playwright-core or Chromium).
|
|
21
|
+
//
|
|
22
|
+
// Determinism: fixed --width (default 1200), deviceScaleFactor 1, full-page
|
|
23
|
+
// screenshot, no wall-clock reads, no randomness, no network. Same HTML +
|
|
24
|
+
// same assets = byte-identical PNG on this host.
|
|
25
|
+
//
|
|
26
|
+
// The rendered composition is evidence: log it with append-ooda-step.js
|
|
27
|
+
// (--action compose) and READ it — a composition you did not read is not
|
|
28
|
+
// evidence.
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
const { existsSync, readFileSync } = require("node:fs");
|
|
32
|
+
const { resolve, dirname } = require("node:path");
|
|
33
|
+
const { pathToFileURL } = require("node:url");
|
|
34
|
+
|
|
35
|
+
const DEFAULT_WIDTH = 1200;
|
|
36
|
+
|
|
37
|
+
function fail(code, obj) {
|
|
38
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
39
|
+
process.exit(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const out = { width: DEFAULT_WIDTH };
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a === "--in") out.in = argv[++i];
|
|
47
|
+
else if (a === "--out") out.out = argv[++i];
|
|
48
|
+
else if (a === "--width") out.width = parseInt(argv[++i], 10);
|
|
49
|
+
else fail(2, { ok: false, error: "unknown flag: " + a });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadPlaywright() {
|
|
55
|
+
try {
|
|
56
|
+
return require("playwright-core");
|
|
57
|
+
} catch (e) { /* fall through */ }
|
|
58
|
+
const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
|
|
59
|
+
if (envDir) {
|
|
60
|
+
try {
|
|
61
|
+
return require(envDir + "/playwright-core");
|
|
62
|
+
} catch (e) { /* fall through */ }
|
|
63
|
+
try {
|
|
64
|
+
return require(envDir);
|
|
65
|
+
} catch (e) { /* fall through */ }
|
|
66
|
+
}
|
|
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
|
+
fail(3, {
|
|
74
|
+
ok: false,
|
|
75
|
+
not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
|
|
76
|
+
"Install it (npm install playwright-core) or set PLAYWRIGHT_CORE_DIR.",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findChromium() {
|
|
81
|
+
const envPath = (process.env.CHROME_PATH || "").trim();
|
|
82
|
+
if (envPath && existsSync(envPath)) return envPath;
|
|
83
|
+
const sysPath = "/opt/meta-chromium/chrome";
|
|
84
|
+
if (existsSync(sysPath)) return sysPath;
|
|
85
|
+
fail(3, {
|
|
86
|
+
ok: false,
|
|
87
|
+
not_possible: "NOT POSSIBLE: no Chromium binary found. " +
|
|
88
|
+
"Set CHROME_PATH to a Chromium/Chrome executable.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const args = parseArgs(process.argv.slice(2));
|
|
94
|
+
if (!args.in) fail(2, { ok: false, error: "missing --in <page.html>" });
|
|
95
|
+
if (!args.out) fail(2, { ok: false, error: "missing --out <shot.png>" });
|
|
96
|
+
if (!Number.isFinite(args.width) || args.width < 200 || args.width > 4000) {
|
|
97
|
+
fail(2, { ok: false, error: "--width must be 200..4000, got: " + args.width });
|
|
98
|
+
}
|
|
99
|
+
const htmlPath = resolve(args.in);
|
|
100
|
+
if (!existsSync(htmlPath)) fail(2, { ok: false, error: "input not found: " + htmlPath });
|
|
101
|
+
void readFileSync(htmlPath, "utf8"); // fail fast on unreadable input
|
|
102
|
+
|
|
103
|
+
const { chromium } = loadPlaywright();
|
|
104
|
+
const exePath = findChromium();
|
|
105
|
+
|
|
106
|
+
let browser = null;
|
|
107
|
+
try {
|
|
108
|
+
browser = await chromium.launch({ executablePath: exePath, args: ["--no-sandbox"] });
|
|
109
|
+
const page = await browser.newPage({
|
|
110
|
+
viewport: { width: args.width, height: 900 },
|
|
111
|
+
deviceScaleFactor: 1,
|
|
112
|
+
});
|
|
113
|
+
// Hermetic render: abort any remote subresource. A composition that
|
|
114
|
+
// silently drops a remote asset is not evidence — fail loudly instead.
|
|
115
|
+
const blocked = [];
|
|
116
|
+
await page.route(/^(https?:)?\/\//, (route) => {
|
|
117
|
+
const url = route.request().url();
|
|
118
|
+
if (url.startsWith("file://")) return route.continue();
|
|
119
|
+
blocked.push(url.slice(0, 200));
|
|
120
|
+
return route.abort();
|
|
121
|
+
});
|
|
122
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "networkidle", timeout: 30000 });
|
|
123
|
+
await page.waitForTimeout(500);
|
|
124
|
+
if (blocked.length > 0) {
|
|
125
|
+
fail(2, { ok: false, error: "blocked remote subresources (render must be hermetic): " + blocked.join(", ") });
|
|
126
|
+
}
|
|
127
|
+
await page.screenshot({ path: resolve(args.out), fullPage: true });
|
|
128
|
+
const size = await page.evaluate(() => ({
|
|
129
|
+
w: document.documentElement.scrollWidth,
|
|
130
|
+
h: document.documentElement.scrollHeight,
|
|
131
|
+
}));
|
|
132
|
+
await browser.close();
|
|
133
|
+
process.stdout.write(JSON.stringify({ ok: true, out: resolve(args.out), size: [size.w, size.h] }) + "\n");
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (browser) await browser.close().catch(() => {});
|
|
136
|
+
fail(2, { ok: false, error: "render failed: " + ((e && e.message) || String(e)).slice(0, 500) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch((e) => {
|
|
141
|
+
fail(2, { ok: false, error: "driver error: " + ((e && e.message) || String(e)).slice(0, 500) });
|
|
142
|
+
});
|