seisin 0.6.3 → 0.6.5

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.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -45,7 +45,7 @@
45
45
  "@anthropic-ai/sandbox-runtime": "0.0.81"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@carlostapiacl/tartaros": "0.1.0-alpha.16"
48
+ "@carlostapiacl/tartaros": "0.1.0-alpha.18"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "node --test test/*.test.js",
package/src/canary.js CHANGED
@@ -75,7 +75,7 @@ export async function runCanary({ srt, wrap = null, settingsFile, settings, env,
75
75
  // exists when the sandbox starts is one of them.
76
76
  const insideDir = writable ? join(writable, `.seisin-canary-${nonce}`) : null;
77
77
  const inside = insideDir ? join(insideDir, "in") : null;
78
- // The runtime's shared temp directory (srt.js, SHARED_TMP): granted by the
78
+ // The runtime's shared temp directory (srt-settings.js, SHARED_TMP): granted by the
79
79
  // runtime to every sandbox, taken back by seisin. Probed where it exists;
80
80
  // where it does not, there is nothing beside which to write.
81
81
  const shared = isDir(SHARED_TMP_DIR) ? join(SHARED_TMP_DIR, `.seisin-canary-${nonce}`) : null;
package/src/cli.js CHANGED
@@ -13,11 +13,12 @@
13
13
  * never know: which role a path belongs to, and therefore who to ask next.
14
14
  */
15
15
  import { readFileSync } from "node:fs";
16
- import { join, dirname } from "node:path";
16
+ import { join, dirname, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { findConfig, loadConfig } from "./config.js";
20
20
  import { CONFIG_NAME } from "./layout.js";
21
+ import { setStateDir } from "./state.js";
21
22
  import { C, out, err } from "./render.js";
22
23
  import { COMMAND_HELP, flagsOf, renderHelp, renderLimits } from "./help.js";
23
24
  import { nearest, checkFlags } from "./suggest.js";
@@ -36,8 +37,46 @@ import { nearest, checkFlags } from "./suggest.js";
36
37
  const HERE = dirname(fileURLToPath(import.meta.url));
37
38
  const [, , command, ...argv] = process.argv;
38
39
 
40
+ /**
41
+ * `--policy <file>` and `--state-dir <dir>`, for a launcher that keeps both out
42
+ * of the repo it runs in (an orchestrator with a folder per run). The policy is
43
+ * read from the file and rooted at the working directory; the log and the
44
+ * request queue go to the state directory. Both are taken out of seisin's own
45
+ * part of the line before the command reads it (after `--`, or after `run
46
+ * <role> <command>`, the words are the agent's), and both reach everything a
47
+ * run starts through the environment (SEISIN_CONFIG, SEISIN_ROOT,
48
+ * SEISIN_STATE_DIR).
49
+ */
50
+ function ownEnd() {
51
+ const split = argv.indexOf("--");
52
+ if (split !== -1) return split;
53
+ if (command !== "run") return argv.length;
54
+ let i = 1;
55
+ const values = new Set([...(flagsOf("run")?.values ?? []), "--policy", "--state-dir"]);
56
+ while (i < argv.length && argv[i].startsWith("-")) i += values.has(argv[i]) ? 2 : 1;
57
+ return i;
58
+ }
59
+ function takeGlobal(name) {
60
+ const i = argv.indexOf(name);
61
+ if (i === -1 || i >= ownEnd()) return null;
62
+ const v = argv[i + 1];
63
+ if (v === undefined || v.startsWith("-")) {
64
+ err(`${C.red}seisin:${C.off} ${name} needs a value\n`);
65
+ process.exit(2);
66
+ }
67
+ argv.splice(i, 2);
68
+ return v;
69
+ }
70
+ const policyFlag = takeGlobal("--policy");
71
+ const stateFlag = takeGlobal("--state-dir");
72
+ if (stateFlag) setStateDir(stateFlag);
73
+ const rootFlag = policyFlag ? process.cwd() : null;
74
+ if (policyFlag) {
75
+ process.env.SEISIN_ROOT = rootFlag;
76
+ }
77
+
39
78
  const USAGE = `
40
- ${C.b}seisin${C.off} — give each agent its own folders and its own keys
79
+ ${C.b}seisin${C.off}: give each agent its own folders and its own keys
41
80
 
42
81
  seisin run <role> -- <command...> run a command as that role
43
82
  seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
@@ -61,14 +100,18 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
61
100
  seisin mcp an MCP server on stdio — read-only
62
101
 
63
102
  seisin <command> --help lists a command's flags, examples and exit codes.
103
+ Any command takes --policy <file> (the policy, rooted at the working directory) and
104
+ --state-dir <dir> (where the log and the request queue go) for a launcher that keeps
105
+ both outside the repo (docs/runs.md).
64
106
  seisin help limits says what seisin cannot do, in any repo.
65
107
 
66
- Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
108
+ Enforcement comes from the sandbox (sandbox-runtime by default, or tartaros), which asks the OS.
67
109
  seisin decides what to ask for, and says whose file it was when the answer is no.
68
110
  `;
69
111
 
70
112
  /** Loads the policy, or explains where to get one. */
71
113
  function config() {
114
+ if (policyFlag) return loadConfig(resolve(policyFlag), undefined, { root: rootFlag });
72
115
  const path = findConfig();
73
116
  if (!path) throw new Error(`no ${CONFIG_NAME} found here or above. Run "seisin init" to write one.`);
74
117
  return loadConfig(path);
@@ -76,6 +119,7 @@ function config() {
76
119
 
77
120
  /** The policy here, or null — for a command that can answer without one. */
78
121
  function maybeConfig() {
122
+ if (policyFlag) return config();
79
123
  const path = findConfig();
80
124
  return path ? loadConfig(path) : null;
81
125
  }
@@ -3,22 +3,42 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { inspect } from "../inspect.js";
5
5
  import { renderReport, out, C } from "../render.js";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { stateDir } from "../state.js";
9
+
10
+ const say = out;
6
11
  import { measureSystemLog } from "../violations.js";
7
12
  import { join } from "node:path";
8
- import { settingsFor } from "../srt.js";
13
+ import { settingsFor } from "../srt-settings.js";
9
14
  import { weigh } from "../sandboxes/index.js";
10
15
  import { runsRoot } from "../rundir.js";
11
16
  import { channelName } from "../spool.js";
12
17
 
18
+ /**
19
+ * What this seisin can be asked to do, for a launcher deciding which flags to
20
+ * pass: one list, instead of a version compared against minimums or a search
21
+ * through `--help`. A name is added when the feature ships and never removed
22
+ * while it works.
23
+ */
24
+ export const CAPABILITIES = [
25
+ "policy-flag", "state-dir", "run-id", "expect-denied", "agent-flag", "handoff", "handoff-false",
26
+ "browsers", "local-ports", "key-mode-inject", "check-json", "ui-runs",
27
+ ];
28
+
13
29
  export async function check(config, argv = []) {
14
30
  if (argv.includes("--parallel") && !argv.includes("--verify"))
15
31
  throw new Error("--parallel is experimental and requires --verify");
32
+ const json = argv.includes("--json");
16
33
  const role = argv.find((a) => !a.startsWith("-")) ?? null;
17
34
  const report = inspect(config, role, config.path);
18
- out(renderReport(report, { verbose: argv.includes("--verbose") }));
19
- if (config.sandbox?.provider) report.sandboxes = sandboxes(config, role);
20
- if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"));
21
- if (argv.includes("--system-log")) report.systemLog = await systemLog();
35
+ if (!json) out(renderReport(report, { verbose: argv.includes("--verbose") }));
36
+ if (config.sandbox?.provider || json) report.sandboxes = sandboxes(config, role, { quiet: json });
37
+ // What a flag asked for runs in --json too, quietly, and lands in the JSON:
38
+ // skipping it answered "ok" about a check that never ran.
39
+ if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"), { quiet: json });
40
+ if (argv.includes("--system-log")) report.systemLog = await systemLog({ quiet: json });
41
+ if (json) out(JSON.stringify(asJson(config, report), null, 2) + "\n");
22
42
  return report;
23
43
  }
24
44
 
@@ -30,7 +50,8 @@ export async function check(config, argv = []) {
30
50
  * it before, with who is writing the most — the only lead to a fix. A flag,
31
51
  * because it takes seconds and `check` otherwise answers at once.
32
52
  */
33
- async function systemLog() {
53
+ async function systemLog({ quiet = false } = {}) {
54
+ const out = quiet ? () => {} : say;
34
55
  const m = await measureSystemLog({ seconds: SYSTEM_LOG_SECONDS });
35
56
  if (!m.available) {
36
57
  out(`\n${C.dim}system log: not measured — ${m.reason}${C.off}\n`);
@@ -56,16 +77,17 @@ const SYSTEM_LOG_SECONDS = 3;
56
77
  * too. Printed only when `[sandbox] provider` is set: without it every role
57
78
  * runs under srt, as it always has, and there is nothing to choose.
58
79
  */
59
- export function sandboxes(config, only = null, { platform = process.platform } = {}) {
80
+ export function sandboxes(config, only = null, { platform = process.platform, quiet = false } = {}) {
81
+ const out = quiet ? () => {} : say;
60
82
  const sock = join(runsRoot(), "check", channelName(platform));
61
83
  const rows = [];
62
- out(`\n${C.b}sandboxes${C.off} (${config.sandbox.provider.join(" → ")})\n`);
84
+ out(`\n${C.b}sandboxes${C.off} (${(config.sandbox?.provider ?? ["srt"]).join(" → ")})\n`);
63
85
  for (const r of Object.values(config.roles)) {
64
86
  if (only && r.name !== only) continue;
65
87
  let settings;
66
88
  try { settings = settingsFor(config, r.name, sock); } catch (e) { rows.push({ role: r.name, chosen: null, error: e.message }); continue; }
67
89
  const { chosen, options } = weigh(config, settings, { platform, role: r.name });
68
- rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null,
90
+ rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null, bin: chosen?.bin ?? null,
69
91
  options: options.map((o) => ({ name: o.sandbox.name, missing: o.missing, unmet: o.unmet, warnings: o.warnings })) });
70
92
  out(chosen ? ` ${C.green}✓${C.off} ${r.name}: ${chosen.sandbox.name}\n` : ` ${C.red}✗${C.off} ${r.name}: no sandbox can hold it\n`);
71
93
  // Held, but not the way the role may expect: the sandbox's own words.
@@ -82,6 +104,34 @@ export function sandboxes(config, only = null, { platform = process.platform } =
82
104
  return rows;
83
105
  }
84
106
 
107
+ /** `--version`'s first line from a sandbox binary, or null: what a launcher would otherwise parse itself. */
108
+ function versionOf(bin) {
109
+ if (!bin || bin === "(not looked up)") return null;
110
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
111
+ return r.status === 0 ? (r.stdout || "").split("\n")[0].trim() || null : null;
112
+ }
113
+
114
+ /** `check --json`: the same answer as the text, for a program. */
115
+ function asJson(config, report) {
116
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
117
+ return {
118
+ seisin: pkg.version,
119
+ platform: process.platform,
120
+ capabilities: CAPABILITIES,
121
+ policy: config.path,
122
+ root: config.root,
123
+ stateDir: stateDir(config.root),
124
+ roles: (report.sandboxes ?? []).map((s) => ({
125
+ role: s.role, sandbox: s.chosen, bin: s.bin ?? null, version: versionOf(s.bin), error: s.error ?? null,
126
+ unmet: (s.options ?? []).filter((o) => o.name !== s.chosen).map((o) => ({ sandbox: o.name, missing: o.missing, unmet: o.unmet })),
127
+ })),
128
+ warnings: report.warnings.map((w) => ({ kind: w.kind, role: w.role ?? null, headline: w.headline ?? w.message ?? null })),
129
+ ...(report.verify ? { verify: report.verify.map((v) => ({ role: v.role, ok: v.ok, status: v.status ?? null })) } : {}),
130
+ ...(report.systemLog ? { systemLog: { available: report.systemLog.available, dropped: report.systemLog.dropped ?? 0, reason: report.systemLog.reason ?? null } } : {}),
131
+ ok: checkStatus(report) === 0,
132
+ };
133
+ }
134
+
85
135
  /**
86
136
  * The exit status: 1 when a role cannot be enforced as written, or when what a
87
137
  * flag asked for failed. `--system-log` is asked for like `--verify` and fails
@@ -105,15 +155,21 @@ export function checkStatus(report) {
105
155
  * The one thing `check` executes, which is why it is a flag: without it
106
156
  * `check` still runs nothing.
107
157
  */
108
- async function verify(config, only, parallel = false) {
158
+ async function verify(config, only, parallel = false, { quiet = false } = {}) {
159
+ const out = quiet ? () => {} : say;
109
160
  const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
110
161
  const roles = Object.values(config.roles).filter((r) => (only ? r.name === only : true) && r.verify);
111
162
  const results = [];
112
163
  if (!roles.length) out(`\n${C.yellow}verify: no role ${only ? `"${only}" ` : ""}declares a verify command${C.off}\n`);
164
+ // The policy check read, passed on by name: one given with --policy (a JSON
165
+ // file outside the repo) is not where the child would look for seisin.toml.
166
+ // The state directory already travels as SEISIN_STATE_DIR.
167
+ const policy = config.path ? ["--policy", config.path] : [];
113
168
  const run = async (r) => {
169
+ const argv = [cli, "run", ...policy, r.name, "--agent", "none", "--", ...r.verify];
114
170
  const res = parallel
115
- ? await runParallel(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], config.root)
116
- : spawnSync(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], {
171
+ ? await runParallel(process.execPath, argv, config.root)
172
+ : spawnSync(process.execPath, argv, {
117
173
  cwd: config.root, encoding: "utf8", timeout: 300_000, env: process.env,
118
174
  });
119
175
  const ok = res.status === 0;
@@ -30,7 +30,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
30
30
 
31
31
  let config;
32
32
  try {
33
- config = loadConfig(env.SEISIN_CONFIG ?? findConfig());
33
+ config = loadConfig(env.SEISIN_CONFIG ?? findConfig(), undefined, { root: env.SEISIN_ROOT || null });
34
34
  } catch {
35
35
  return null;
36
36
  }
@@ -8,7 +8,9 @@
8
8
  * Reading is the easy half. Writing goes through spool.js when the writer is
9
9
  * inside a sandbox, so that the record is not editable by what it records.
10
10
  */
11
- import { createReadStream, watch as watchDir } from "node:fs";
11
+ import { closeSync, openSync, readSync, watch as watchDir } from "node:fs";
12
+ import { StringDecoder } from "node:string_decoder";
13
+ import { lineReader } from "../lines.js";
12
14
  import { dirname, relative } from "node:path";
13
15
  import { read, logPath, size, verifyLog, LOG_NAME } from "../log.js";
14
16
  import { renderEntry, C, out } from "../render.js";
@@ -120,21 +122,26 @@ export function watch(config, { interval = 500 } = {}) {
120
122
  let at = size(file);
121
123
  out(`\n ${C.dim}watching ${relative(process.cwd(), file)} — ctrl-c to stop${C.off}\n\n`);
122
124
 
125
+ // One reader across reads: a line that arrives in two pieces is shown once
126
+ // it is whole, instead of being dropped as half-written. Read synchronously,
127
+ // so a filesystem event and the interval never interleave two reads.
128
+ const show = (line) => { try { out(renderEntry(JSON.parse(line))); } catch { /* not a log line */ } };
129
+ let reader = lineReader(show), text = new StringDecoder("utf8");
123
130
  const drain = () => {
124
131
  const end = size(file);
125
- if (end <= at) { at = end; return; } // truncated, or nothing new
126
- const stream = createReadStream(file, { start: at, end: end - 1, encoding: "utf8" });
127
- at = end;
128
- let buf = "";
129
- stream.on("data", (d) => (buf += d));
130
- stream.on("end", () => {
131
- for (const line of buf.split("\n")) {
132
- if (!line.trim()) continue;
133
- try { out(renderEntry(JSON.parse(line))); } catch { /* half-written line */ }
134
- }
135
- });
132
+ if (end < at) { reader = lineReader(show); text = new StringDecoder("utf8"); } // truncated or rotated: start over
133
+ if (end <= at) { at = end; return; }
134
+ const fd = openSync(file, "r");
135
+ try {
136
+ const chunk = Buffer.alloc(end - at);
137
+ readSync(fd, chunk, 0, chunk.length, at);
138
+ at = end;
139
+ reader(text.write(chunk));
140
+ } finally { closeSync(fd); }
136
141
  };
137
142
 
138
- try { watchDir(dirname(file), () => drain()); } catch { /* platform without events */ }
139
- return setInterval(drain, interval);
143
+ let dir = null;
144
+ try { dir = watchDir(dirname(file), () => drain()); } catch { /* platform without events */ }
145
+ const timer = setInterval(drain, interval);
146
+ return { stop: () => { clearInterval(timer); dir?.close(); } };
140
147
  }
@@ -6,10 +6,10 @@
6
6
  * person acting in a channel the agent does not have. That is the invariant the
7
7
  * whole feature rests on — see docs/requests.md.
8
8
  */
9
- import { settle, applyGrant, refuseIfBarred, requestsPath, editPolicy, queueOf, queueData, shellId, asFileGrant, lastAgentOf, fileOnlyNote } from "../requests.js";
9
+ import { queueOf, queueData, shellId, lastAgentOf, fileOnlyNote } from "../requests.js";
10
10
  import { describeHandoff } from "../handoff.js";
11
11
  import { keyHolders } from "../owners.js";
12
- import { loadConfig } from "../config.js";
12
+ import { decideRequest } from "../decide-request.js";
13
13
  import { read, logPath } from "../log.js";
14
14
  import { C, out, safe } from "../render.js";
15
15
 
@@ -125,14 +125,10 @@ export function grant(config, argv = []) {
125
125
  // Looked up, applied and settled under one lock — the one the console's
126
126
  // decide takes too. Picking before the lock let this and the console both
127
127
  // find the same request open and act on it twice.
128
- let req;
129
- const { changed } = underLock(() => editPolicy(config, (before) => {
130
- const current = loadConfig(config.path, before);
131
- req = pick(current, which, "grant");
132
- if (only) req = asFileGrant(req);
133
- refuseIfBarred(current, req);
134
- return applyGrant(before, req, reason);
135
- }, { after: () => settle(requestsPath(config.root), req.key, "granted", reason, { only: req.only }) }));
128
+ const { request: req, changed } = underLock(() => decideRequest(config, {
129
+ decision: "granted", reason, only,
130
+ select: (current) => pick(current, which, "grant"),
131
+ }));
136
132
  // Said once, beside the grant it is about: a literal file is useless to an
137
133
  // agent that saves through a temporary beside it.
138
134
  const caveat = only ? fileOnlyNote(req.role, lastAgentOf(read(logPath(config.root)), req.role)) : "";
@@ -181,11 +177,10 @@ export function decline(config, argv = []) {
181
177
 
182
178
  // The policy is not edited, but the lock is still the policy's: it is the one
183
179
  // every decision takes, so a decline cannot settle what a grant is applying.
184
- let req;
185
- underLock(() => editPolicy(config, (before) => {
186
- req = pick(config, argv[0], "decline");
187
- return { toml: before, changed: false };
188
- }, { after: () => settle(requestsPath(config.root), req.key, "declined", reason) }));
180
+ const { request: req } = underLock(() => decideRequest(config, {
181
+ decision: "declined", reason, reload: false, includeOnly: false,
182
+ select: (current) => pick(current, argv[0], "decline"),
183
+ }));
189
184
  out(
190
185
  `\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
191
186
  ` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
@@ -15,6 +15,7 @@
15
15
  * would mean a correct policy cannot go green.
16
16
  */
17
17
  import { review } from "../review.js";
18
+ import { positiveInt } from "../suggest.js";
18
19
  import { parseSince } from "../views.js";
19
20
  import { C, out, safe } from "../render.js";
20
21
  import { wired } from "./wire.js";
@@ -30,7 +31,7 @@ export function reviewCommand(config, argv = []) {
30
31
  const since = s === -1 ? null : parseSince(argv[s + 1]);
31
32
  if (s !== -1 && !since)
32
33
  throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(argv[s + 1] ?? "")}"`);
33
- const r = review(config, { minDenials: i === -1 ? 3 : Number(argv[i + 1]), since });
34
+ const r = review(config, { minDenials: i === -1 ? 3 : positiveInt("--min", argv[i + 1]), since });
34
35
  // Ten rows per section unless --all: on a real 32-role log the full report
35
36
  // was 1,557 lines, which nobody reads.
36
37
  const all = argv.includes("--all");