seisin 0.5.0 → 0.6.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.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
@@ -12,10 +12,15 @@
12
12
  * not read: `--from-observations` lands as `.observed`, not as the config.
13
13
  */
14
14
  import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync, appendFileSync } from "node:fs";
15
- import { join, relative } from "node:path";
15
+ import { join, relative, resolve, isAbsolute } from "node:path";
16
16
  import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
17
17
  import { read, logPath, observed, generalise } from "../log.js";
18
18
  import { C, out } from "../render.js";
19
+ import { loadConfig } from "../config.js";
20
+ import { applyGrant } from "../requests.js";
21
+ import { setKey } from "../controls.js";
22
+ import { keyName } from "../owners.js";
23
+ import { toRepoRelative } from "../paths.js";
19
24
 
20
25
  /**
21
26
  * Three places a repo already says who does what, in order of how much it
@@ -133,6 +138,9 @@ export function renderConfig(found) {
133
138
  "# A Claude Code subagent runs inside its parent's process, so it has its",
134
139
  "# parent's role — give it its own by running it as its own `seisin run`.",
135
140
  "",
141
+ "# [runtime]",
142
+ '# read = "territory" # opt-in: keep data roots shut; roles declare reads/toolchain/verify',
143
+ "",
136
144
  ...(found.keyDir
137
145
  ? ["[keys]", `dir = ${tomlString(found.keyDir)} # every key lives here; roles name the files they may read`]
138
146
  : ["# [keys]", '# dir = ".secrets" # every key lives here; roles name the files they may read']),
@@ -157,64 +165,63 @@ export function renderConfig(found) {
157
165
  lines.push(`[roles.${tomlName(r.name)}]`);
158
166
  lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
159
167
  lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
168
+ lines.push('# reads = ["path/**"] # data outside the repo this role must read');
169
+ lines.push('# toolchain = ["~/.venvs/app"] # stack under a home or another data root');
170
+ lines.push('# verify = ["npm", "test"] # seisin check --verify runs this inside the role');
160
171
  lines.push("");
161
172
  }
162
173
  return lines.join("\n");
163
174
  }
164
175
 
165
- /** The observed config, as TOML. Pure: takes the log entries. */
166
- export function renderObserved(config, entries) {
176
+ /** A proposal that adds observed permissions to the current policy text. */
177
+ export function renderObserved(config, entries, text = readFileSync(config.path, "utf8")) {
178
+ // Start from the policy being proposed for promotion. Rebuilding writes and
179
+ // keys alone discarded isolate, never_writes, providers, per-role network,
180
+ // MCP and every other setting, including those of roles that did not act.
181
+ config = loadConfig(config.path, text);
167
182
  const roles = observed(entries);
168
- const lines = [
169
- `# ${CONFIG_NAME} — written from ${entries.length} observed action(s).`,
170
- "#",
171
- "# This is what your agents actually did, generalised to directories. Read it",
172
- "# before you trust it: an agent that touched a file once by mistake asked for",
173
- "# that directory here, and observation cannot tell intent from accident.",
174
- "",
175
- ];
176
- if (config.keyDirs.length)
177
- lines.push("[keys]", `dir = [${config.keyDirs.map(tomlString).join(", ")}]`, "");
178
- lines.push("[network]", `allow = [${config.allowedDomains.map(tomlString).join(", ")}]`, "");
179
-
180
- /**
181
- * Every declared role appears, whether it acted or not.
182
- *
183
- * This used to emit only the roles the log had seen, and the file says "diff
184
- * it, then move it" — so moving it deleted the territory of every role that
185
- * happened to be idle during the observation window, silently. A role that
186
- * did nothing is not a role that needs nothing; it is a role nobody watched.
187
- *
188
- * So observation *adds*. What was declared stays, what was seen is appended,
189
- * and the comment beside each role says which part came from where — because
190
- * the whole reason to read this file is to tell the two apart.
191
- */
192
- for (const name of new Set([...Object.keys(config.roles), ...roles.keys()])) {
193
- const declared = config.roles[name];
183
+ let toml = text;
184
+ const notes = [];
185
+ for (const name of Object.keys(config.roles)) {
194
186
  const seen = roles.get(name);
195
-
196
- const from = declared ? declared.writesDeclared ?? declared.writes : [];
197
- const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
198
- const keys = [
199
- ...(declared ? declared.keys : []),
200
- ...(seen ? [...new Set([...seen.keys].map((k) => k.replace(/^.*\//, "")))] : []),
187
+ if (!seen) {
188
+ notes.push(`# ${name}: this role did nothing while observing — kept as written`);
189
+ continue;
190
+ }
191
+ // A host path is not a repo-relative grant. Unknown roles and outside
192
+ // paths from older logs cannot silently become territory in the proposal.
193
+ const paths = [...seen.writes].map((p) => toRepoRelative(config, resolve(config.root, p)))
194
+ .filter((p) => !isAbsolute(p) && p !== ".." && !p.startsWith("../"));
195
+ const additions = [
196
+ ...generalise(paths).map((grant) => ({ action: "write", grant })),
197
+ ...[...seen.keys].map((target) => ({ action: "read", grant: keyName(config, target) })),
201
198
  ];
202
-
203
- // Every value below came out of the log, and the log records paths the
204
- // agent chose. tomlString refuses what the format cannot hold rather than
205
- // emitting a file that parses into something else.
206
- lines.push(`[roles.${tomlName(name)}]`);
207
- lines.push(`writes = [${[...from, ...found].map(tomlString).join(", ")}]`);
208
- lines.push(`keys = [${[...new Set(keys)].map(tomlString).join(", ")}]`);
209
- lines.push(
210
- seen
211
- ? `# declared ${from.length}; observation added ${found.length} ` +
212
- `(${seen.writes.size} path(s) written, ${seen.keys.size} key(s) read)`
213
- : `# declared ${from.length}; this role did nothing while observing — kept as written`
214
- );
215
- lines.push("");
199
+ let added = 0;
200
+ for (const request of additions) {
201
+ const field = request.action === "read" ? "keys" : "writes";
202
+ // A valid role can omit either list. Insert only a missing list and
203
+ // retry the same grant; existing values and comments stay untouched.
204
+ let result;
205
+ try {
206
+ result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
207
+ } catch (e) {
208
+ if (!e.message.includes(`has no ${field} list`)) throw e;
209
+ toml = setKey(toml, `roles.${name}`, field, "[]", "", "from observation");
210
+ result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
211
+ }
212
+ toml = result.toml;
213
+ if (result.changed) added++;
214
+ }
215
+ notes.push(`# ${name}: observation added ${added}; all other settings kept as written`);
216
216
  }
217
- return { toml: lines.join("\n"), roles: roles.size };
217
+ loadConfig(config.path, toml);
218
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
219
+ const heading = [
220
+ `# ${CONFIG_NAME} — proposal from ${entries.length} observed action(s).`,
221
+ "# Observation adds territory and keys; read the diff before promoting this file.",
222
+ ...notes, "",
223
+ ].join(eol);
224
+ return { toml: heading + eol + toml, roles: [...roles.keys()].filter((name) => config.roles[name]).length };
218
225
  }
219
226
 
220
227
  export function init(cwd = process.cwd(), { force = false } = {}) {
@@ -4,10 +4,12 @@
4
4
  * These live in the CLI and in the console, and deliberately NOT in the MCP
5
5
  * server. An agent may ask and may draft; turning a request into policy takes a
6
6
  * person acting in a channel the agent does not have. That is the invariant the
7
- * whole feature rests on — see docs/permission-requests.md.
7
+ * whole feature rests on — see docs/requests.md.
8
8
  */
9
- import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale, editPolicy, withDeclarers, shellId } from "../requests.js";
9
+ import { settle, applyGrant, refuseIfBarred, requestsPath, editPolicy, queueOf, queueData, shellId, asFileGrant, lastAgentOf, fileOnlyNote } from "../requests.js";
10
+ import { describeHandoff } from "../handoff.js";
10
11
  import { keyHolders } from "../owners.js";
12
+ import { loadConfig } from "../config.js";
11
13
  import { read, logPath } from "../log.js";
12
14
  import { C, out, safe } from "../render.js";
13
15
 
@@ -17,7 +19,7 @@ import { C, out, safe } from "../render.js";
17
19
  * "Stable while you read it" was the old claim and it is false exactly where it
18
20
  * matters. The queue is filled by agents that are still running: on a live
19
21
  * repository it grew and shrank between a listing and the next command, and
20
- * `seisin deny 1` (now `decline`) settled a different request than the one printed as #1 seconds
22
+ * `seisin decline 1` settled a different request than the one printed as #1 seconds
21
23
  * earlier. That is time-of-check-to-time-of-use in the one command whose whole
22
24
  * job is deciding a request, and it happened twice in one session.
23
25
  *
@@ -56,14 +58,11 @@ function pick(config, n, verb = "grant") {
56
58
  : " Use its number, or the id printed under it by `seisin requests`."));
57
59
  }
58
60
 
59
- /** The pending queue, with keys in the policy's own form and their declarers. */
60
- export function queueOf(config) {
61
- return withDeclarers(config, pending(requestsPath(config.root), { keyDirs: config.keyDirs }));
62
- }
61
+ export { queueOf };
63
62
 
64
- export function requests(config) {
65
- const queue = markStale(queueOf(config), read(logPath(config.root)));
66
- out(renderQueue(queue));
63
+ export function requests(config, argv = []) {
64
+ const queue = queueOf(config, read(logPath(config.root)));
65
+ out(argv.includes("--json") ? JSON.stringify(queueData(queue), null, 2) + "\n" : renderQueue(queue));
67
66
  return queue;
68
67
  }
69
68
 
@@ -73,7 +72,7 @@ export function renderQueue(queue) {
73
72
 
74
73
  const lines = [`\n ${C.yellow}${queue.length} pending request(s)${C.off}\n\n`];
75
74
  queue.forEach((r, i) => lines.push(...entryLines(r, i + 1)));
76
- lines.push(`\n ${C.dim}seisin grant <n|id> [--reason "…"] · seisin decline <n|id> [--reason "…"]${C.off}\n`);
75
+ lines.push(`\n ${C.dim}seisin grant <n|id> [--file] [--reason "…"] · seisin decline <n|id> [--reason "…"]${C.off}\n`);
77
76
  // Said, because it bites: grant #1 and the old #2 is #1 now.
78
77
  lines.push(` ${C.dim}#n is a position and shifts as requests are settled; the id does not.${C.off}\n\n`);
79
78
  return lines.join("");
@@ -107,68 +106,44 @@ function entryLines(r, n) {
107
106
  // Marked, not moved: the number above is what `grant <n>` is typed against.
108
107
  if (r.stale)
109
108
  lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
110
- const note = handoffNote(r.handoff);
111
- if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
109
+ // Where it goes, decided now. A route is the expected case and reads as
110
+ // information; anything that needs a person is said in yellow.
111
+ const note = describeHandoff(r.handoff, r.attempt, r.chainStopped);
112
+ if (note) lines.push(` ${r.handoff?.type === "route" ? C.dim : C.yellow}${safe(note)}${C.off}\n`);
112
113
  }
113
114
  return lines;
114
115
  }
115
116
 
116
- /**
117
- * One line saying why no worker is running for a request that is still open.
118
- *
119
- * Deliberately not a scheduler view. The failure this closes is narrow and
120
- * expensive: work that was refused admission, left the queue looking idle, and
121
- * was never done by anybody. A row that says which limit held it back is the
122
- * whole fix; deciding who retries and when is a separate question that this
123
- * does not have to answer first.
124
- */
125
- export function handoffNote(h) {
126
- if (!h) return "";
127
- switch (h.outcome) {
128
- case "throttled":
129
- return `handoff held: ${limitWords(h.limit, h.role)} ${h.max}/${h.max} — still to do`;
130
- case "cycle":
131
- return `handoff stopped: ${h.role} is already in this handoff chain — needs a person to split the work`;
132
- case "depth":
133
- return `handoff stopped: handoff chain reached ${h.depth} of ${h.max} — needs a person`;
134
- case "human":
135
- return `handoff stopped: ${h.reason === "ambiguous" ? "more than one role owns this" : "no role owns this"}`;
136
- case "route":
137
- return `handed to ${h.role}`;
138
- case "resolved":
139
- return `no handoff needed — the policy moved and ${h.role} owns it now`;
140
- default:
141
- return `handoff outcome: ${h.outcome}`;
142
- }
143
- }
144
-
145
- /** The limit names are internal; what a person reads should not be a field name. */
146
- function limitWords(limit, role) {
147
- if (limit === "senderChains") return "open handoff chains for this role";
148
- if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
149
- return limit ?? "a limit";
150
- }
151
-
152
117
  export function grant(config, argv = []) {
153
118
  const i = argv.indexOf("--reason");
154
119
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
120
+ // `--file` grants the file the request was asked over, not the folder it is
121
+ // keyed by. The id stays first, as usage says; the flag may come after it.
122
+ const only = argv.includes("--file");
123
+ const which = argv[0] === "--file" ? argv[1] : argv[0];
155
124
 
156
125
  // Looked up, applied and settled under one lock — the one the console's
157
126
  // decide takes too. Picking before the lock let this and the console both
158
127
  // find the same request open and act on it twice.
159
128
  let req;
160
129
  const { changed } = underLock(() => editPolicy(config, (before) => {
161
- req = pick(config, argv[0], "grant");
162
- refuseIfBarred(config, req);
130
+ const current = loadConfig(config.path, before);
131
+ req = pick(current, which, "grant");
132
+ if (only) req = asFileGrant(req);
133
+ refuseIfBarred(current, req);
163
134
  return applyGrant(before, req, reason);
164
- }, { after: () => settle(requestsPath(config.root), req.key, "granted", reason) }));
135
+ }, { after: () => settle(requestsPath(config.root), req.key, "granted", reason, { only: req.only }) }));
136
+ // Said once, beside the grant it is about: a literal file is useless to an
137
+ // agent that saves through a temporary beside it.
138
+ const caveat = only ? fileOnlyNote(req.role, lastAgentOf(read(logPath(config.root)), req.role)) : "";
139
+ const warn = caveat ? ` ${C.yellow}${caveat}${C.off}\n` : "";
165
140
  // Already in the policy: the request is answered all the same, as the
166
141
  // console answers it. Throwing here left it pending forever — the person had
167
142
  // said yes, the role had the path, and the queue kept asking.
168
143
  if (!changed) {
169
144
  out(
170
145
  `\n ${C.green}already granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
171
- ` ${C.dim}${config.path} already gives it; nothing written, the request is closed.${C.off}\n\n`
146
+ ` ${C.dim}${config.path} already gives it; nothing written, the request is closed.${C.off}\n` + warn + "\n"
172
147
  );
173
148
  out(remaining(config));
174
149
  return { request: req, grant: req.grant, changed: false };
@@ -182,7 +157,7 @@ export function grant(config, argv = []) {
182
157
  `\n ${C.green}granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
183
158
  (others.length
184
159
  ? ` ${C.yellow}shared: ${req.grant} is also declared by ${others.join(", ")} — both roles read it now${C.off}\n`
185
- : "") +
160
+ : "") + warn +
186
161
  ` ${C.dim}written into ${config.path} with its provenance. It applies on the next run.${C.off}\n\n`
187
162
  );
188
163
  out(remaining(config));
@@ -200,7 +175,7 @@ function underLock(fn) {
200
175
  }
201
176
  }
202
177
 
203
- export function deny(config, argv = []) {
178
+ export function decline(config, argv = []) {
204
179
  const i = argv.indexOf("--reason");
205
180
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
206
181
 
@@ -210,7 +185,7 @@ export function deny(config, argv = []) {
210
185
  underLock(() => editPolicy(config, (before) => {
211
186
  req = pick(config, argv[0], "decline");
212
187
  return { toml: before, changed: false };
213
- }, { after: () => settle(requestsPath(config.root), req.key, "denied", reason) }));
188
+ }, { after: () => settle(requestsPath(config.root), req.key, "declined", reason) }));
214
189
  out(
215
190
  `\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
216
191
  ` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
@@ -15,12 +15,22 @@
15
15
  * would mean a correct policy cannot go green.
16
16
  */
17
17
  import { review } from "../review.js";
18
- import { C, out } from "../render.js";
18
+ import { parseSince } from "../views.js";
19
+ import { C, out, safe } from "../render.js";
19
20
  import { wired } from "./wire.js";
20
21
 
21
22
  export function reviewCommand(config, argv = []) {
22
23
  const i = argv.indexOf("--min");
23
- const r = review(config, { minDenials: i === -1 ? 3 : Number(argv[i + 1]) });
24
+ /**
25
+ * `--since 2026-10-01` or `--since 7d`. The MCP ignores a filter it cannot
26
+ * read; a person typed this one, and a review of the whole log presented as
27
+ * the last week is the wrong answer said confidently — so it is refused.
28
+ */
29
+ const s = argv.indexOf("--since");
30
+ const since = s === -1 ? null : parseSince(argv[s + 1]);
31
+ if (s !== -1 && !since)
32
+ 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 });
24
34
  // Ten rows per section unless --all: on a real 32-role log the full report
25
35
  // was 1,557 lines, which nobody reads.
26
36
  const all = argv.includes("--all");
@@ -31,13 +41,16 @@ export function reviewCommand(config, argv = []) {
31
41
  };
32
42
  const who = (owners) => owners.length > 3 ? `${owners.slice(0, 3).join(", ")} +${owners.length - 3}` : owners.join(", ");
33
43
 
44
+ const day = (s) => (s ?? "").slice(0, 10);
34
45
  if (!r.entries) {
35
- out(`\n ${C.dim}nothing recorded yet. Run an agent through seisin first.${C.off}\n\n`);
46
+ out(since
47
+ ? `\n ${C.dim}nothing recorded since ${day(since)}.${C.off}\n\n`
48
+ : `\n ${C.dim}nothing recorded yet. Run an agent through seisin first.${C.off}\n\n`);
36
49
  return r;
37
50
  }
38
51
 
39
- const day = (s) => (s ?? "").slice(0, 10);
40
- out(`\n ${C.dim}${r.entries} entries, ${day(r.window.from)} to ${day(r.window.to)}${C.off}\n`);
52
+ out(`\n ${C.dim}${r.entries} entries, ${day(r.window.from)} to ${day(r.window.to)}` +
53
+ `${since ? ` (since ${day(since)})` : ""}${C.off}\n`);
41
54
 
42
55
  if (r.friction.length) {
43
56
  out(`\n ${C.b}Denied repeatedly${C.off}\n`);
@@ -51,6 +64,20 @@ export function reviewCommand(config, argv = []) {
51
64
  out(`\n ${C.dim}seisin grant, or move the territory. Either way it is a decision, not noise.${C.off}\n`);
52
65
  }
53
66
 
67
+ /**
68
+ * Repeated, refused correctly, and nothing to decide: Claude Code updating
69
+ * its plugins from inside the sandbox (kinds.js expectedOf). Under the
70
+ * heading above it read as a policy that is wrong, and the only grant that
71
+ * would quiet it is a write into what runs outside the sandbox.
72
+ */
73
+ if (r.expected?.length) {
74
+ out(`\n ${C.b}Denied as expected${C.off}\n`);
75
+ out(` ${C.dim}${r.expected[0].expected.hint}${C.off}\n\n`);
76
+ for (const f of cap(r.expected))
77
+ out(` ${C.dim}${String(f.times).padStart(4)}×${C.off} ${C.b}${f.role}${C.off} ${f.action} ${f.where}\n`);
78
+ more(r.expected);
79
+ }
80
+
54
81
  /**
55
82
  * The same repetition, and the opposite conclusion.
56
83
  *
@@ -139,7 +166,7 @@ export function reviewCommand(config, argv = []) {
139
166
  out(` ${C.dim}by kind: ${[...tally].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n.toLocaleString("en")}×`).join(" · ")}${C.off}\n`);
140
167
  }
141
168
 
142
- if (!r.friction.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)
169
+ if (!r.friction.length && !r.expected?.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)
143
170
  out(`\n ${C.green}nothing to report${C.off} ${C.dim}— no repeated denials, no unused grants, no unowned paths${C.off}\n`);
144
171
 
145
172
  out("\n");