seisin 0.4.2 → 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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -1,12 +1,15 @@
1
1
  /**
2
- * `seisin requests`, `grant` and `deny` — the human end of the loop.
2
+ * `seisin requests`, `grant` and `decline` — a person's end of the loop.
3
3
  *
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 } 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";
11
+ import { keyHolders } from "../owners.js";
12
+ import { loadConfig } from "../config.js";
10
13
  import { read, logPath } from "../log.js";
11
14
  import { C, out, safe } from "../render.js";
12
15
 
@@ -16,9 +19,9 @@ import { C, out, safe } from "../render.js";
16
19
  * "Stable while you read it" was the old claim and it is false exactly where it
17
20
  * matters. The queue is filled by agents that are still running: on a live
18
21
  * repository it grew and shrank between a listing and the next command, and
19
- * `deny 1` 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
20
23
  * earlier. That is time-of-check-to-time-of-use in the one command whose whole
21
- * job is deciding a permission, and it happened twice in one session.
24
+ * job is deciding a request, and it happened twice in one session.
22
25
  *
23
26
  * A position is still accepted, because reading a list and typing a number is
24
27
  * how anyone will use this. An argument that is not a number is matched against
@@ -28,8 +31,10 @@ import { C, out, safe } from "../render.js";
28
31
  * An ambiguous match is refused rather than resolved. Choosing for you is the
29
32
  * failure being fixed.
30
33
  */
31
- function pick(config, n) {
32
- const queue = pending(requestsPath(config.root));
34
+ function pick(config, n, verb = "grant") {
35
+ if (n === undefined || n === "" || String(n).startsWith("--"))
36
+ throw new Error(`usage: seisin ${verb} <n|id> [--reason "…"] — the numbers and ids are in: seisin requests`);
37
+ const queue = queueOf(config);
33
38
  if (!queue.length) throw new Error("no pending requests");
34
39
 
35
40
  const arg = String(n).trim();
@@ -53,9 +58,11 @@ function pick(config, n) {
53
58
  : " Use its number, or the id printed under it by `seisin requests`."));
54
59
  }
55
60
 
56
- export function requests(config) {
57
- const queue = markStale(pending(requestsPath(config.root)), read(logPath(config.root)));
58
- out(renderQueue(queue));
61
+ export { queueOf };
62
+
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));
59
66
  return queue;
60
67
  }
61
68
 
@@ -64,10 +71,34 @@ export function renderQueue(queue) {
64
71
  if (queue.length === 0) return `\n ${C.dim}no pending requests${C.off}\n\n`;
65
72
 
66
73
  const lines = [`\n ${C.yellow}${queue.length} pending request(s)${C.off}\n\n`];
67
- queue.forEach((r, i) => {
68
- const owners = r.owners.length ? ` ${C.dim}(owned by ${r.owners.join(", ")})${C.off}` : ` ${C.dim}(unowned)${C.off}`;
74
+ queue.forEach((r, i) => lines.push(...entryLines(r, i + 1)));
75
+ lines.push(`\n ${C.dim}seisin grant <n|id> [--file] [--reason "…"] · seisin decline <n|id> [--reason "…"]${C.off}\n`);
76
+ // Said, because it bites: grant #1 and the old #2 is #1 now.
77
+ lines.push(` ${C.dim}#n is a position and shifts as requests are settled; the id does not.${C.off}\n\n`);
78
+ return lines.join("");
79
+ }
80
+
81
+ /**
82
+ * What one run added to the queue, for the end of `seisin run`. Each entry
83
+ * carries `n`, its number in the whole queue, so `grant <n>` works from here.
84
+ */
85
+ export function renderAdded(added, older) {
86
+ if (!added.length) return "";
87
+ const lines = ["\n"];
88
+ for (const r of added) lines.push(...entryLines(r, r.n));
89
+ lines.push(`\n ${C.dim}this run: ${added.length} new request(s)` +
90
+ `${older ? ` · ${older} older pending` : ""} — seisin requests${C.off}\n\n`);
91
+ return lines.join("");
92
+ }
93
+
94
+ function entryLines(r, n) {
95
+ const lines = [];
96
+ {
97
+ const owners = r.declared
98
+ ? ` ${C.dim}(${r.owners.length ? `declared by ${r.owners.join(", ")}` : "declared by no role"})${C.off}`
99
+ : r.owners.length ? ` ${C.dim}(owned by ${r.owners.join(", ")})${C.off}` : ` ${C.dim}(unowned)${C.off}`;
69
100
  const times = r.times > 1 ? ` ${C.dim}· asked ${r.times}×${C.off}` : "";
70
- lines.push(` ${C.b}#${i + 1}${C.off} ${safe(r.role)} wants ${r.action} on ${C.b}${safe(r.grant)}${C.off}${owners}${times}\n`);
101
+ lines.push(` ${C.b}#${n}${C.off} ${safe(r.role)} wants ${r.action} on ${C.b}${safe(r.grant)}${C.off}${owners}${times}\n`);
71
102
  lines.push(` ${C.dim}first asked over ${safe(r.target)}${C.off}\n`);
72
103
  // The stable way to name it: a number is a position in a queue that agents
73
104
  // are still writing to, and this does not move when the queue does.
@@ -75,84 +106,105 @@ export function renderQueue(queue) {
75
106
  // Marked, not moved: the number above is what `grant <n>` is typed against.
76
107
  if (r.stale)
77
108
  lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
78
- const note = handoffNote(r.handoff);
79
- if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
80
- });
81
- lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]${C.off}\n\n`);
82
- return lines.join("");
83
- }
84
-
85
- /**
86
- * One line saying why no worker is running for a request that is still open.
87
- *
88
- * Deliberately not a scheduler view. The failure this closes is narrow and
89
- * expensive: work that was refused admission, left the queue looking idle, and
90
- * was never done by anybody. A row that says which limit held it back is the
91
- * whole fix; deciding who retries and when is a separate question that this
92
- * does not have to answer first.
93
- */
94
- export function handoffNote(h) {
95
- if (!h) return "";
96
- switch (h.outcome) {
97
- case "throttled":
98
- return `handoff held: ${limitWords(h.limit, h.role)} ${h.max}/${h.max} — still to do`;
99
- case "cycle":
100
- return `handoff stopped: ${h.role} is already in this chain — needs a person to split the work`;
101
- case "depth":
102
- return `handoff stopped: chain reached ${h.depth} of ${h.max} — needs a person`;
103
- case "human":
104
- return `handoff stopped: ${h.reason === "ambiguous" ? "more than one role owns this" : "no role owns this"}`;
105
- case "route":
106
- return `handed to ${h.role}`;
107
- case "resolved":
108
- return `no handoff needed — the policy moved and ${h.role} owns it now`;
109
- default:
110
- return `handoff outcome: ${h.outcome}`;
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`);
111
113
  }
112
- }
113
-
114
- /** The limit names are internal; what a person reads should not be a field name. */
115
- function limitWords(limit, role) {
116
- if (limit === "senderChains") return "open chains for this role";
117
- if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
118
- return limit ?? "a limit";
114
+ return lines;
119
115
  }
120
116
 
121
117
  export function grant(config, argv = []) {
122
- const req = pick(config, argv[0]);
123
118
  const i = argv.indexOf("--reason");
124
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];
125
124
 
126
- refuseIfBarred(config, req);
127
- // Under a lock, and written atomically: a concurrent grant (another terminal
128
- // or the console) must not clobber this one. See editPolicy.
129
- let changed;
130
- try {
131
- ({ changed } = editPolicy(config, (before) => applyGrant(before, req, reason)));
132
- } catch (e) {
133
- if (e.code === "ELOCKED")
134
- throw new Error("another grant is in progress — run this again in a moment");
135
- throw e;
125
+ // Looked up, applied and settled under one lock — the one the console's
126
+ // decide takes too. Picking before the lock let this and the console both
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 }) }));
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` : "";
140
+ // Already in the policy: the request is answered all the same, as the
141
+ // console answers it. Throwing here left it pending forever — the person had
142
+ // said yes, the role had the path, and the queue kept asking.
143
+ if (!changed) {
144
+ out(
145
+ `\n ${C.green}already granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
146
+ ` ${C.dim}${config.path} already gives it; nothing written, the request is closed.${C.off}\n` + warn + "\n"
147
+ );
148
+ out(remaining(config));
149
+ return { request: req, grant: req.grant, changed: false };
136
150
  }
137
- if (!changed) throw new Error(`${req.role} already has ${req.grant} — nothing to add`);
138
151
 
139
- settle(requestsPath(config.root), req.key, "granted", reason);
152
+ // A key another role declares is shared by this grant, not moved: both roles
153
+ // read it from now on. That is sometimes the point, and never a thing to find
154
+ // out later.
155
+ const others = req.action === "read" ? keyHolders(config, req.target).filter((r) => r !== req.role) : [];
140
156
  out(
141
157
  `\n ${C.green}granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
158
+ (others.length
159
+ ? ` ${C.yellow}shared: ${req.grant} is also declared by ${others.join(", ")} — both roles read it now${C.off}\n`
160
+ : "") + warn +
142
161
  ` ${C.dim}written into ${config.path} with its provenance. It applies on the next run.${C.off}\n\n`
143
162
  );
144
- return { request: req, grant: req.grant };
163
+ out(remaining(config));
164
+ return { request: req, grant: req.grant, shared: others };
165
+ }
166
+
167
+ /** A held lock is another decision in flight, not a failure of this one. */
168
+ function underLock(fn) {
169
+ try {
170
+ return fn();
171
+ } catch (e) {
172
+ if (e.code === "ELOCKED")
173
+ throw new Error("another decision is in progress — run this again in a moment");
174
+ throw e;
175
+ }
145
176
  }
146
177
 
147
- export function deny(config, argv = []) {
148
- const req = pick(config, argv[0]);
178
+ export function decline(config, argv = []) {
149
179
  const i = argv.indexOf("--reason");
150
180
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
151
181
 
152
- settle(requestsPath(config.root), req.key, "denied", reason);
182
+ // The policy is not edited, but the lock is still the policy's: it is the one
183
+ // 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) }));
153
189
  out(
154
190
  `\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
155
191
  ` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
156
192
  );
193
+ out(remaining(config));
157
194
  return { request: req, reason };
158
195
  }
196
+
197
+ /**
198
+ * The queue after a decision, numbered as it now is.
199
+ *
200
+ * Numbers are positions: settling #1 makes the old #2 the new #1, and the next
201
+ * `grant 2` — typed from the listing still on screen — lands on what used to
202
+ * be #3. One line with the new numbering, so the screen is right again.
203
+ */
204
+ export function remaining(config) {
205
+ const queue = queueOf(config);
206
+ if (!queue.length) return ` ${C.dim}no requests left${C.off}\n\n`;
207
+ const shown = queue.slice(0, 5).map((r, i) => `#${i + 1} ${safe(r.role)} ${r.action} ${safe(r.grant)}`);
208
+ const more = queue.length > 5 ? ` · +${queue.length - 5} more` : "";
209
+ return ` ${C.dim}left, renumbered: ${shown.join(" · ")}${more}${C.off}\n\n`;
210
+ }
@@ -5,8 +5,8 @@
5
5
  * would do; this reads what actually happened and tells you where the config
6
6
  * was wrong about it. Both run nothing.
7
7
  *
8
- * Exits 1 when there is friction, so it composes — a role blocked over and over
9
- * is a thing to fix, and a CI job that says so beats a human noticing in a
8
+ * Exits 1 on repeated denials, so it composes — a role denied over and over
9
+ * is a thing to fix, and a CI job that says so beats a person noticing in a
10
10
  * month. Unused grants and unowned paths exit 0: they are worth reading, not
11
11
  * worth failing a build over.
12
12
  *
@@ -15,11 +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";
20
+ import { wired } from "./wire.js";
19
21
 
20
22
  export function reviewCommand(config, argv = []) {
21
23
  const i = argv.indexOf("--min");
22
- 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 });
23
34
  // Ten rows per section unless --all: on a real 32-role log the full report
24
35
  // was 1,557 lines, which nobody reads.
25
36
  const all = argv.includes("--all");
@@ -30,17 +41,20 @@ export function reviewCommand(config, argv = []) {
30
41
  };
31
42
  const who = (owners) => owners.length > 3 ? `${owners.slice(0, 3).join(", ")} +${owners.length - 3}` : owners.join(", ");
32
43
 
44
+ const day = (s) => (s ?? "").slice(0, 10);
33
45
  if (!r.entries) {
34
- 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`);
35
49
  return r;
36
50
  }
37
51
 
38
- const day = (s) => (s ?? "").slice(0, 10);
39
- out(`\n ${C.dim}${r.entries} decisions, ${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`);
40
54
 
41
55
  if (r.friction.length) {
42
- out(`\n ${C.b}Stopped, repeatedly${C.off}\n`);
43
- out(` ${C.dim}a role blocked on the same place over and over is a policy that is wrong,${C.off}\n`);
56
+ out(`\n ${C.b}Denied repeatedly${C.off}\n`);
57
+ out(` ${C.dim}a role denied on the same place over and over is a policy that is wrong,${C.off}\n`);
44
58
  out(` ${C.dim}not an agent that is misbehaving${C.off}\n\n`);
45
59
  for (const f of cap(r.friction)) {
46
60
  const whose = f.owners.length ? ` ${C.dim}— belongs to ${who(f.owners)}${C.off}` : "";
@@ -50,6 +64,20 @@ export function reviewCommand(config, argv = []) {
50
64
  out(`\n ${C.dim}seisin grant, or move the territory. Either way it is a decision, not noise.${C.off}\n`);
51
65
  }
52
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
+
53
81
  /**
54
82
  * The same repetition, and the opposite conclusion.
55
83
  *
@@ -65,7 +93,7 @@ export function reviewCommand(config, argv = []) {
65
93
  * working either when the service is one the role was meant to reach.
66
94
  */
67
95
  if (r.connects?.length) {
68
- out(`\n ${C.b}Refused connections${C.off}\n`);
96
+ out(`\n ${C.b}Denied connections${C.off}\n`);
69
97
  out(` ${C.dim}a local port or socket the role kept dialling. Not a grant: a port goes in${C.off}\n`);
70
98
  out(` ${C.dim}local_ports, and a socket stays closed${C.off}\n\n`);
71
99
  for (const c of cap(r.connects))
@@ -75,7 +103,7 @@ export function reviewCommand(config, argv = []) {
75
103
  }
76
104
 
77
105
  if (r.guarded.length) {
78
- out(`\n ${C.b}Held at the keys${C.off}\n`);
106
+ out(`\n ${C.b}Denied at the key directories${C.off}\n`);
79
107
  out(` ${C.dim}a [keys] dir is closed to every role, so this is the policy working${C.off}\n`);
80
108
  out(` ${C.dim}— not a territory drawn wrong, and never a thing to grant${C.off}\n\n`);
81
109
  for (const g of cap(r.guarded))
@@ -90,32 +118,56 @@ export function reviewCommand(config, argv = []) {
90
118
  *
91
119
  * Without the hook the log holds denials and nothing else, so every grant
92
120
  * looks dead. The old text called itself the only evidence anyone will ever
93
- * have for making a permission file smaller, printed over the whole policy.
121
+ * have for making a policy smaller, printed over the whole policy.
94
122
  */
95
123
  if (!r.unusedKnowable) {
96
124
  out(`\n ${C.b}Granted, never used${C.off} ${C.dim}— cannot be answered from this log${C.off}\n`);
97
- out(` ${C.dim}nothing here records what was ALLOWED, only what was refused. Every grant${C.off}\n`);
98
- out(` ${C.dim}would look dead, so nothing is listed. Run \`seisin wire\` and come back.${C.off}\n`);
125
+ out(` ${C.dim}nothing here records what was ALLOWED, only what was denied. Every grant${C.off}\n`);
126
+ // Wired already: telling someone to run `seisin wire` again sends them
127
+ // looking for a fault in a setup that is fine and simply has not run.
128
+ if (wired(config.root))
129
+ out(` ${C.dim}would look dead, so nothing is listed. wired, but no allowed action logged yet —${C.off}\n` +
130
+ ` ${C.dim}run an agent (claude, codex…) through seisin run and come back.${C.off}\n`);
131
+ else
132
+ out(` ${C.dim}would look dead, so nothing is listed. Run \`seisin wire\` and come back.${C.off}\n`);
99
133
  }
100
134
 
101
135
  if (r.unused.length) {
102
136
  out(`\n ${C.b}Granted, never used${C.off}\n`);
103
137
  out(` ${C.dim}nothing was written here in the window above. This is the only evidence${C.off}\n`);
104
- out(` ${C.dim}anyone will ever have for making a permission file smaller${C.off}\n\n`);
138
+ out(` ${C.dim}anyone will ever have for making a policy smaller${C.off}\n\n`);
105
139
  for (const u of cap(r.unused)) out(` ${C.b}${u.role}${C.off} ${u.glob}\n`);
106
140
  more(r.unused);
107
141
  out(`\n ${C.dim}A short window proves nothing. Check the dates before you delete a line.${C.off}\n`);
108
142
  }
109
143
 
110
144
  if (r.unowned.length) {
111
- out(`\n ${C.b}Owned by nobody${C.off}\n`);
112
- out(` ${C.dim}touched by an agent, claimed by no role — holes in the map${C.off}\n\n`);
113
- for (const u of cap(r.unowned)) out(` ${String(u.times).padStart(4)}× ${u.where}\n`);
114
- more(r.unowned);
145
+ out(`\n ${C.b}Unowned${C.off}\n`);
146
+ out(` ${C.dim}touched by an agent, claimed by no role. Only an ownable path (kind \`territory\`)${C.off}\n`);
147
+ out(` ${C.dim}is a hole in the map; the rest is a kind of thing with its own answer, and none${C.off}\n`);
148
+ out(` ${C.dim}of them is an owner${C.off}\n\n`);
149
+ // Territory first: it is the part that is a decision, and on a real log it
150
+ // was 22 paths under 279 of a test's scratch — past the ten-row cap.
151
+ const ordered = [...r.unowned.filter((u) => u.kind === "territory"), ...r.unowned.filter((u) => u.kind !== "territory")];
152
+ const shown = cap(ordered);
153
+ const width = Math.max(...shown.map((u) => (u.kind ?? "").length));
154
+ for (const u of shown)
155
+ out(` ${String(u.times).padStart(4)}× ${u.kind ? `${C.dim}${u.kind.padEnd(width)}${C.off} ` : ""}${u.where}\n`);
156
+ more(ordered);
157
+ // The advice once per kind shown, not once per row: a hundred rows of
158
+ // `.git` do not need the same sentence a hundred times.
159
+ const hints = new Map(shown.filter((u) => u.hint).map((u) => [u.kind, u.hint]));
160
+ if (hints.size) out("\n");
161
+ for (const [kind, hint] of hints) out(` ${C.dim}${kind}: ${hint}${C.off}\n`);
162
+ // All of it by kind, so the part past the cap is still said in one line.
163
+ const tally = new Map();
164
+ for (const u of r.unowned) if (u.kind) tally.set(u.kind, (tally.get(u.kind) ?? 0) + u.times);
165
+ if (tally.size > 1)
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`);
115
167
  }
116
168
 
117
- if (!r.friction.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)
118
- out(`\n ${C.green}nothing to report${C.off} ${C.dim}— no repeated blocks, no dead grants, no unowned paths${C.off}\n`);
169
+ if (!r.friction.length && !r.expected?.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)
170
+ out(`\n ${C.green}nothing to report${C.off} ${C.dim}— no repeated denials, no unused grants, no unowned paths${C.off}\n`);
119
171
 
120
172
  out("\n");
121
173
  return r;