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.
@@ -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
+ });