incanto 0.46.0 → 0.48.0

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.
@@ -106,4 +106,8 @@ if (args.json) {
106
106
  }
107
107
  console.log(`\n${results.length - failed.length}/${results.length} scene(s) valid`);
108
108
  }
109
- process.exit(failed.length === 0 ? 0 : 1);
109
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
110
+ // asynchronously, and exiting discards whatever has not flushed. A --json
111
+ // report read by another program came back truncated — silently, and only
112
+ // when piped, which is the only way a program reads it.
113
+ process.exitCode = failed.length === 0 ? 0 : 1;
@@ -109,4 +109,8 @@ if (!args.json) {
109
109
  }
110
110
  }
111
111
 
112
- process.exit(report.player ? 0 : 1);
112
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
113
+ // asynchronously, and exiting discards whatever has not flushed. A --json
114
+ // report read by another program came back truncated — silently, and only
115
+ // when piped, which is the only way a program reads it.
116
+ process.exitCode = report.player ? 0 : 1;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  /**
4
4
  * incanto-frame — what is actually on screen, as numbers.
5
5
  *
@@ -26,6 +26,7 @@ const { listeningPorts, parseProcNetTcp } = await import(
26
26
  pathToFileURL(join(PKG, 'dist', 'vite.js')).href
27
27
  );
28
28
  const { frameText, diffText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
29
+ const { parseDrive } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
29
30
 
30
31
  const args = process.argv.slice(2);
31
32
  const asJson = args.includes('--json');
@@ -37,6 +38,10 @@ const flag = (name) => {
37
38
  return next && !next.startsWith('-') ? next : '';
38
39
  };
39
40
  const gridArg = flag('--grid');
41
+ const outArg = flag('--out');
42
+ const sizeArg = flag('--size');
43
+ const driveArg = flag('--do');
44
+ const driveFile = flag('--do-file');
40
45
  const remember = flag('--remember');
41
46
  const diff = flag('--diff');
42
47
  const threshold = flag('--threshold');
@@ -45,6 +50,10 @@ if (args.includes('--help') || args.includes('-h')) {
45
50
 
46
51
  --json the full report as JSON instead of prose
47
52
  --grid WxH cells across and down (default 16x9)
53
+ --out FILE also write the frame as a PNG you can look at
54
+ --size N longest side of that PNG (default 512)
55
+ --do SCRIPT drive the game first, THEN capture (see below)
56
+ --do-file FILE the same script, from a file
48
57
  --port N skip discovery and use this port
49
58
 
50
59
  --remember [L] keep this frame as the baseline named L (default "last")
@@ -52,7 +61,25 @@ if (args.includes('--help') || args.includes('-h')) {
52
61
  --threshold N a cell has changed when it moves this far, 0..255 (default 3)
53
62
 
54
63
  Run your game's dev server and open the preview page first: the pixels live in
55
- the browser, not in the dev server.
64
+ the browser, not in the dev server. Nothing headless is involved and no browser
65
+ is installed: the page that is already open does the rendering.
66
+
67
+ The report says whether anything was drawn, where the subject is and how much of
68
+ the frame it fills. When that is not enough, look:
69
+
70
+ incanto-frame --out shot.png # then open it, or read it
71
+
72
+ Every interesting state in a game is downstream of input, so --do drives the
73
+ running game before capturing — the same words incanto-play takes:
74
+
75
+ incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
76
+ incanto-frame --do "press jump; step 400" --out midair.png
77
+
78
+ press/release ACTION · vector ACTION X Y · key CODE down|up
79
+ pointer DX DY · step MS (semicolons or newlines separate)
80
+
81
+ step waits on the real clock: this is the live game, with its own loop.
82
+
56
83
 
57
84
  The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
58
85
  once a cell is averaged, and nothing can tell it apart from a thin rope in the
@@ -100,6 +127,24 @@ function bsdListeningPorts() {
100
127
  }
101
128
  }
102
129
 
130
+ /** The script to run before capturing, from `--do` or `--do-file`. */
131
+ const driveScript = driveFile ? read(driveFile) : driveArg;
132
+ if (driveFile && driveScript === null) {
133
+ console.error(`cannot read ${driveFile}`);
134
+ process.exit(1);
135
+ }
136
+ if (driveScript) {
137
+ const parsed = parseDrive(driveScript);
138
+ if (parsed.error) {
139
+ // Refuse here rather than in the page: a bad command that ran nothing would
140
+ // hand back the boot screen, and nothing about that frame would say so.
141
+ console.error(
142
+ `${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · pointer DX DY · step MS`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+ }
147
+
103
148
  const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
104
149
  const discovered = explicit ? [explicit] : await listeningPorts({ read });
105
150
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
@@ -138,19 +183,40 @@ if (!found) {
138
183
 
139
184
  const params = new URLSearchParams();
140
185
  if (gridArg !== null) params.set('grid', gridArg);
186
+ if (outArg) params.set('image', sizeArg || '512');
187
+ if (driveScript) {
188
+ params.set('drive', driveScript);
189
+ // Tell the server how long to wait: the script runs in real time.
190
+ const totalMs = parseDrive(driveScript).totalMs;
191
+ params.set('driveMs', String(totalMs));
192
+ }
141
193
  if (remember !== null) params.set('remember', remember);
142
194
  if (diff !== null) params.set('diff', diff);
143
195
  if (threshold) params.set('threshold', threshold);
144
196
  const q = params.toString();
145
197
  const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ? `?${q}` : ''}`, {
146
- signal: AbortSignal.timeout(10_000),
198
+ // Outlast the server's own budget, or the CLI gives up on an answer that
199
+ // was on its way.
200
+ signal: AbortSignal.timeout(15_000 + (driveScript ? parseDrive(driveScript).totalMs * 2 : 0)),
147
201
  });
148
202
  const body = await res.json().catch(() => null);
149
203
 
204
+ if (body?.error === 'page-not-drawing') {
205
+ console.error(
206
+ `the page on :${found.port} is connected but not drawing.\n` +
207
+ ' A frame is captured inside a render, and a browser stops rendering a tab it\n' +
208
+ ' considers hidden — including a window covered by another one. Bring the\n' +
209
+ ' preview to the front and run this again.',
210
+ );
211
+ process.exit(1);
212
+ }
150
213
  if (res.status === 504 || body?.error === 'no-page-connected') {
151
214
  console.error(
152
- `the dev server is running on :${found.port}, but no page is connected.\n` +
153
- ' Open the preview in a browser — the pixels are there, not in the server.',
215
+ `the dev server is running on :${found.port}, but no page answered.\n` +
216
+ ' Open the preview in a browser — the pixels are there, not in the server.\n' +
217
+ ' If it IS open: a window covered by another one counts as hidden, and a\n' +
218
+ ' hidden tab gets no animation frames — such a page never finishes loading\n' +
219
+ ' and cannot draw. Bring it to the front.',
154
220
  );
155
221
  process.exit(1);
156
222
  }
@@ -160,15 +226,53 @@ if (!res.ok || !body?.report) {
160
226
  }
161
227
 
162
228
  const report = body.report;
229
+ // A drive that advanced no frames moved nothing: the inputs landed on a game
230
+ // the browser had frozen, and the frame that came back is the one from before.
231
+ // Saying nothing here would be handing over a picture that quietly lies.
232
+ if (driveScript && report.droveFrames === 0) {
233
+ console.error(
234
+ 'the drive ran but the game never advanced — 0 frames drawn.\n' +
235
+ ' The page is hidden (a window covered by another counts) and the browser\n' +
236
+ ' stops animating it, so the inputs landed on a frozen game and this frame\n' +
237
+ ' is unchanged. Bring the preview to the front and try again.',
238
+ );
239
+ process.exit(1);
240
+ }
163
241
  if (report.error) {
164
242
  console.error(`the page could not capture a frame: ${report.error}`);
165
243
  process.exit(1);
166
244
  }
167
245
 
246
+ /** The picture, if one was asked for. Written before anything is printed, so a
247
+ * report that mentions the file is a report whose file exists. */
248
+ let wroteImage = null;
249
+ if (outArg) {
250
+ const dataUrl = typeof report.image === 'string' ? report.image : null;
251
+ const base64 = dataUrl?.slice(dataUrl.indexOf(',') + 1);
252
+ if (!base64) {
253
+ console.error(
254
+ 'the page returned no image — it is running an engine older than this CLI,\n' +
255
+ ' or its renderer cannot capture frames.',
256
+ );
257
+ } else {
258
+ writeFileSync(outArg, Buffer.from(base64, 'base64'));
259
+ wroteImage = outArg;
260
+ }
261
+ }
262
+
168
263
  if (asJson) {
169
- process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
264
+ // The base64 image is for the file, not for a terminal — the path is the
265
+ // useful part and a megabyte of it in a pipe is not.
266
+ const { image: _image, ...rest } = report;
267
+ process.stdout.write(
268
+ `${JSON.stringify({ ...body, report: rest, ...(wroteImage ? { image: wroteImage } : {}) }, null, 2)}\n`,
269
+ );
170
270
  } else {
171
271
  const lines = [frameText(report)];
272
+ if (typeof report.droveFrames === 'number') {
273
+ lines.push(`drove ${report.droveFrames} frames before capturing`);
274
+ }
275
+ if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
172
276
  if (body.diffError === 'no-baseline') {
173
277
  lines.push(
174
278
  `no baseline named "${diff || 'last'}" — run \`incanto-frame --remember${diff ? ` ${diff}` : ''}\` first,\n` +
@@ -183,6 +287,10 @@ if (asJson) {
183
287
  process.stdout.write(`${lines.join('\n')}\n`);
184
288
  }
185
289
  // A missing baseline is a usage error, not a picture: fail so a script notices.
186
- process.exit(report.black || body.diffError ? 1 : 0);
290
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
291
+ // asynchronously, and exiting discards whatever has not flushed. A --json
292
+ // report read by another program came back truncated — silently, and only
293
+ // when piped, which is the only way a program reads it.
294
+ process.exitCode = report.black || body.diffError ? 1 : 0;
187
295
 
188
296
  void parseProcNetTcp; // re-exported for tests; referenced so bundlers keep it
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-logs — what the RUNNING game is saying.
4
+ *
5
+ * bunx incanto-logs
6
+ * bunx incanto-logs --json
7
+ *
8
+ * `incanto-frame` gets the pixels out of the browser; this gets the WORDS. The
9
+ * engine writes diagnostics for exactly this purpose — a model whose fit came
10
+ * out at 934x, an asset that 404'd, a node that quarantined itself — and until
11
+ * now every one of them reached a human only, in the debug overlay's logs
12
+ * panel. An agent could see a game and not hear it.
13
+ *
14
+ * Unlike a frame this needs no render, so it answers from a tab the browser has
15
+ * stopped drawing.
16
+ *
17
+ * Exit 1 when the game reports something wrong: an error logged, an error
18
+ * swallowed to keep it alive, or an asset that never loaded.
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { listeningPorts } = await import(pathToFileURL(join(PKG, 'dist', 'vite.js')).href);
27
+ const { logText } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
28
+
29
+ const args = process.argv.slice(2);
30
+ const asJson = args.includes('--json');
31
+ if (args.includes('--help') || args.includes('-h')) {
32
+ console.error(`Usage: incanto-logs [--json] [--port N]
33
+
34
+ What the running game is saying: its warnings and errors, the errors it
35
+ swallowed to keep going, the assets that never loaded, and its frame rate.
36
+
37
+ Run your game's dev server and open the page first — the log buffer is in the
38
+ browser, not in the dev server. This needs no render, so a page the browser has
39
+ stopped drawing still answers.
40
+
41
+ Exit 1 when something is wrong.`);
42
+ process.exit(0);
43
+ }
44
+
45
+ const read = (path) => {
46
+ try {
47
+ return readFileSync(path, 'utf-8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Ports listening on a machine with no `/proc` (macOS, BSD) — see incanto-frame. */
54
+ function bsdListeningPorts() {
55
+ try {
56
+ const { execFileSync } = createRequire(import.meta.url)('node:child_process');
57
+ const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
58
+ encoding: 'utf-8',
59
+ timeout: 1500,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ });
62
+ const ports = new Set();
63
+ for (const line of out.split('\n')) {
64
+ const m = line.match(/:(\d+)\s*\(LISTEN\)/);
65
+ if (m) ports.add(Number(m[1]));
66
+ }
67
+ return [...ports];
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
74
+ const discovered = explicit ? [explicit] : await listeningPorts({ read });
75
+ const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
76
+
77
+ let found = null;
78
+ outer: for (const port of ports) {
79
+ for (const host of ['localhost', '127.0.0.1']) {
80
+ try {
81
+ const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
82
+ signal: AbortSignal.timeout(300),
83
+ });
84
+ if (!res.ok) continue;
85
+ const body = await res.json();
86
+ if (body?.incanto) {
87
+ found = { host, port };
88
+ break outer;
89
+ }
90
+ } catch {
91
+ // not listening, not ours, or not answering — next
92
+ }
93
+ }
94
+ }
95
+
96
+ if (!found) {
97
+ console.error(
98
+ 'no incanto dev server found.\n' +
99
+ ' Start your game (the dev server) and try again — the log buffer lives in\n' +
100
+ ' the running page, so there has to be one.',
101
+ );
102
+ process.exit(1);
103
+ }
104
+
105
+ const res = await fetch(`http://${found.host}:${found.port}/__incanto/logs`, {
106
+ signal: AbortSignal.timeout(10_000),
107
+ });
108
+ const body = await res.json().catch(() => null);
109
+
110
+ if (res.status === 504 || body?.error === 'no-page-connected') {
111
+ console.error(
112
+ `the dev server is running on :${found.port}, but no page answered.\n` +
113
+ ' Open the preview in a browser — the log buffer is there, not in the server.',
114
+ );
115
+ process.exit(1);
116
+ }
117
+ if (!res.ok || !body?.report || body.report.error) {
118
+ console.error(
119
+ `could not read the game's logs: ${body?.report?.error ?? body?.error ?? res.status}`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ console.log(asJson ? JSON.stringify(body.report, null, 2) : logText(body.report));
125
+ // `exitCode`, never `process.exit()` — stdout to a pipe is written
126
+ // asynchronously and exiting discards what has not flushed.
127
+ process.exitCode = body.report.ok ? 0 : 1;
@@ -9,14 +9,25 @@
9
9
  *
10
10
  * Bounding boxes come from glTF POSITION accessor min/max transformed through
11
11
  * each node's TRS chain — for skinned meshes that is the BIND pose.
12
+ *
13
+ * A URL works anywhere a path does. Finding an asset is the agent8 library's
14
+ * job (its vector search matches meaning, which no filename filter can); this
15
+ * answers the question that search cannot — what will this file DO in a scene.
12
16
  */
13
17
  import { readFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
22
+ const { modelVerdict, verdictText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
14
23
 
15
24
  function parseArgs(argv) {
16
- const args = { file: undefined, json: false };
17
- for (const a of argv) {
25
+ const args = { file: undefined, json: false, key: undefined };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
18
28
  if (a === '--json') args.json = true;
19
29
  else if (a === '--help' || a === '-h') args.help = true;
30
+ else if (a === '--key') args.key = argv[++i];
20
31
  else if (!a.startsWith('-') && !args.file) args.file = a;
21
32
  else {
22
33
  console.error(`unknown argument: ${a}`);
@@ -26,19 +37,49 @@ function parseArgs(argv) {
26
37
  return args;
27
38
  }
28
39
 
40
+ const isUrl = (s) => /^https?:\/\//i.test(s ?? '');
41
+
42
+ /** A key from a url or path: `.../goblin_king.glb` → `goblin_king`. */
43
+ function keyFrom(source) {
44
+ const file = (source.split(/[?#]/)[0] ?? '').split('/').pop() ?? '';
45
+ return (
46
+ file
47
+ .replace(/\.[^.]+$/, '')
48
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
49
+ .replace(/_{2,}/g, '_')
50
+ .replace(/^_+|_+$/g, '')
51
+ .toLowerCase() || 'model'
52
+ );
53
+ }
54
+
29
55
  const args = parseArgs(process.argv.slice(2));
30
56
  if (args.help || args.error || !args.file) {
31
- console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm> [--json]
57
+ console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm|URL> [--json] [--key NAME]
32
58
 
33
59
  Reports hierarchy, mesh stats, scene bounding box, animations, and VRM meta —
34
- the numbers you need to size and place a model in a scene.`);
60
+ the numbers you need to size and place a model in a scene — then says what the
61
+ file will DO in an incanto scene:
62
+
63
+ will the 3d/animations clips play on it (bone names decide, and a mismatch
64
+ keeps the bind pose with only a console warning)
65
+ is it a character's size, or authored in centimetres
66
+ does it stand on its own origin, or will a node at [0,0,0] put it in the sky
67
+
68
+ and prints the scene JSON to paste. A URL works anywhere a path does, so an
69
+ asset the agent8 library found can be checked before it is used.`);
35
70
  process.exit(args.help && !args.error ? 0 : 1);
36
71
  }
37
72
 
38
73
  // ---- container parsing --------------------------------------------------------------
39
74
 
40
- function readGltfJson(file) {
41
- const data = readFileSync(file);
75
+ async function bytesOf(source) {
76
+ if (!isUrl(source)) return readFileSync(source);
77
+ const res = await fetch(source);
78
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
79
+ return Buffer.from(await res.arrayBuffer());
80
+ }
81
+
82
+ function readGltfJson(data) {
42
83
  if (data.length >= 12 && data.readUInt32LE(0) === 0x46546c67) {
43
84
  // GLB container: header(12) then chunks [len][type][payload]
44
85
  let offset = 12;
@@ -186,11 +227,19 @@ function analyze(json) {
186
227
  if (node.skin !== undefined) entry.skinned = true;
187
228
  nodes.push(entry);
188
229
  const mesh = node.mesh !== undefined ? meshes[node.mesh] : null;
230
+ // A SKINNED mesh is placed by its joint matrices, not by the node it hangs
231
+ // off — the node's own TRS is ignored at draw time and is routinely a
232
+ // hundredth-scale leftover from the export. Measured on the stock
233
+ // `base-model.glb`: transforming through the chain reported a 1.5-unit
234
+ // character as 0.003 units tall, because its root carries scale 0.01. Take
235
+ // the vertices as authored for those, and the transformed corners for
236
+ // everything else, where the chain IS the placement.
237
+ const identity = node.skin !== undefined;
189
238
  if (mesh?.localMin && mesh.localMax) {
190
239
  for (const x of [mesh.localMin[0], mesh.localMax[0]]) {
191
240
  for (const y of [mesh.localMin[1], mesh.localMax[1]]) {
192
241
  for (const z of [mesh.localMin[2], mesh.localMax[2]]) {
193
- const p = transformPoint(mat, [x, y, z]);
242
+ const p = identity ? [x, y, z] : transformPoint(mat, [x, y, z]);
194
243
  sceneMin = sceneMin ? sceneMin.map((v, i) => Math.min(v, p[i])) : [...p];
195
244
  sceneMax = sceneMax ? sceneMax.map((v, i) => Math.max(v, p[i])) : [...p];
196
245
  }
@@ -246,21 +295,30 @@ function analyze(json) {
246
295
  // ---- output --------------------------------------------------------------------------
247
296
 
248
297
  let report;
298
+ let verdict;
249
299
  try {
250
- const json = readGltfJson(args.file);
300
+ const data = await bytesOf(args.file);
301
+ const json = readGltfJson(data);
251
302
  if (!json.asset?.version) throw new Error('missing glTF asset header');
252
303
  report = {
253
304
  file: args.file,
254
- format: readFileSync(args.file).readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
305
+ format: data.readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
255
306
  ...analyze(json),
256
307
  };
308
+ verdict = modelVerdict(
309
+ { ...report, meshes: report.meshes.length },
310
+ {
311
+ url: isUrl(args.file) ? args.file : args.file,
312
+ key: args.key ?? keyFrom(args.file),
313
+ },
314
+ );
257
315
  } catch (error) {
258
316
  console.error(`not a glTF/GLB/VRM file: ${String(error.message ?? error)}`);
259
317
  process.exit(1);
260
318
  }
261
319
 
262
320
  if (args.json) {
263
- console.log(JSON.stringify(report, null, 2));
321
+ console.log(JSON.stringify({ ...report, verdict }, null, 2));
264
322
  } else {
265
323
  const out = [];
266
324
  out.push(`${report.file} (${report.format}${report.vrm ? ` · VRM ${report.vrm.spec}` : ''})`);
@@ -292,5 +350,16 @@ if (args.json) {
292
350
  for (const anim of report.animations) {
293
351
  out.push(` anim ${anim.name}: ${anim.duration}s`);
294
352
  }
353
+ // The verdict is the part you act on, so it goes last — closest to the
354
+ // prompt, where a reader stops.
355
+ out.push('', verdictText(verdict));
356
+ if (verdict.sceneJson) {
357
+ const { assets, node } = verdict.sceneJson;
358
+ out.push(
359
+ '',
360
+ 'paste into your scene:',
361
+ JSON.stringify(node ? { assets, node } : { assets }, null, 2),
362
+ );
363
+ }
295
364
  console.log(out.join('\n'));
296
365
  }
@@ -133,4 +133,8 @@ if (!args.noReplays && !args.json) {
133
133
  }
134
134
  }
135
135
 
136
- process.exit(report.runs.some((r) => r.outcome === 'won') ? 0 : 1);
136
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
137
+ // asynchronously, and exiting discards whatever has not flushed. A --json
138
+ // report read by another program came back truncated — silently, and only
139
+ // when piped, which is the only way a program reads it.
140
+ process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;