seisin 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
@@ -18,15 +18,30 @@ const flag = (argv, name) => {
18
18
  return i === -1 ? undefined : argv[i + 1];
19
19
  };
20
20
 
21
+ const VERDICTS = ["allowed", "denied", "observed"];
22
+
21
23
  export function log(config, argv = []) {
22
24
  if (argv[0] === "verify") return verify(config);
23
- const entries = read(logPath(config.root), {
24
- role: flag(argv, "--role"),
25
- verdict: flag(argv, "--verdict"),
26
- limit: Number(flag(argv, "--limit") ?? 40),
27
- });
25
+ const role = flag(argv, "--role");
26
+ const verdict = flag(argv, "--verdict");
27
+ const limit = flag(argv, "--limit") ?? "40";
28
+ // Checked, because a filter that matches nothing reads as an empty log.
29
+ if (verdict !== undefined && !VERDICTS.includes(verdict))
30
+ throw new Error(`unknown verdict "${verdict}" — use allowed, denied or observed`);
31
+ if (!/^[1-9]\d*$/.test(limit)) throw new Error(`--limit takes a positive number, got "${limit}"`);
32
+ // A role that is not in the policy is still a fair question: the log keeps
33
+ // the lines of a role that was renamed or removed. The empty answer says so.
34
+ const entries = read(logPath(config.root), { role, verdict, limit: Number(limit) });
28
35
 
29
36
  if (entries.length === 0) {
37
+ // Say which: nothing at all, or nothing for this filter.
38
+ const any = role || verdict ? read(logPath(config.root), { limit: 1000 }) : [];
39
+ if (any.length) {
40
+ const others = [...new Set(any.map((e) => e.role).filter(Boolean))];
41
+ out(`\n ${C.dim}no entries for ${[role && `role ${role}`, verdict && `verdict ${verdict}`].filter(Boolean).join(", ")} — ` +
42
+ `the log has ${any.length === 1000 ? "1000+" : any.length} for ${others.join(", ") || "other filters"}${C.off}\n\n`);
43
+ return entries;
44
+ }
30
45
  out(`\n ${C.dim}nothing recorded yet — run an agent through "seisin run"${C.off}\n\n`);
31
46
  return entries;
32
47
  }
@@ -56,14 +71,38 @@ function verify(config) {
56
71
  const dropped = r.dropped
57
72
  ? ` ${C.yellow}${r.dropped} entr${r.dropped === 1 ? "y was" : "ies were"} never written${C.off} another writer held the lock too long; see ${LOG_NAME}.dropped\n`
58
73
  : "";
74
+ // Where the chain starts is only checked once it has been recorded. Until
75
+ // then a log whose chain was stripped or rewritten whole reads like an
76
+ // intact one — so with chained lines and no record this is a warning, not a
77
+ // footnote. A record made after chained lines already stood pins whatever
78
+ // they said by then, and says so.
79
+ let genesis = "";
80
+ if (r.genesis === "unrecorded" && r.chained)
81
+ genesis = ` ${C.yellow}where the chain starts is not recorded${C.off} — a chain stripped or rewritten from its first line would not show here.\n` +
82
+ ` ${C.dim}the next entry records it, marked late: tampering before that cannot be excluded${C.off}\n`;
83
+ else if (r.genesis === "unrecorded" && r.unchained)
84
+ genesis = ` ${C.dim}where the chain starts is not recorded yet — it will be on the next entry${C.off}\n`;
85
+ else if (r.genesis === "pruned")
86
+ genesis = ` ${C.dim}the oldest segments were rotated away; the chain is checked from the oldest one kept${C.off}\n`;
87
+ else if (r.late)
88
+ genesis = ` ${C.yellow}start recorded late${C.off}${r.late.at ? ` (${r.late.at})` : ""}, after ${r.late.after ?? "some"} chained line(s) — earlier tampering cannot be excluded\n`;
59
89
  if (!r.breaks.length) {
60
- out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${dropped}\n`);
90
+ out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${genesis}${dropped}\n`);
61
91
  return r;
62
92
  }
63
93
  out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
64
- for (const b of r.breaks.slice(0, 10))
65
- out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
66
- out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n${dropped}\n`);
94
+ const foreign = r.breaks.find((b) => b.foreign);
95
+ for (const b of r.breaks.slice(0, 10)) {
96
+ if (b.foreign) out(` the genesis record belongs to another log: it pins a different start (${LOG_NAME}.genesis)\n`);
97
+ else out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
98
+ }
99
+ if (foreign)
100
+ out(` ${C.dim}if ${LOG_NAME} was archived or replaced by hand, move ${LOG_NAME}.genesis along with the archived log;\n` +
101
+ ` or remove it, and the next entry records this log's start (marked late). If nobody replaced the log,\n` +
102
+ ` it was rewritten from its first line.${C.off}\n`);
103
+ if (r.breaks.some((b) => !b.foreign))
104
+ out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n`);
105
+ out(`${genesis}${dropped}\n`);
67
106
  process.exitCode = 1;
68
107
  return r;
69
108
  }
@@ -1,12 +1,13 @@
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
7
  * whole feature rests on — see docs/permission-requests.md.
8
8
  */
9
- import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale, editPolicy } from "../requests.js";
9
+ import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale, editPolicy, withDeclarers, shellId } from "../requests.js";
10
+ import { keyHolders } from "../owners.js";
10
11
  import { read, logPath } from "../log.js";
11
12
  import { C, out, safe } from "../render.js";
12
13
 
@@ -16,9 +17,9 @@ import { C, out, safe } from "../render.js";
16
17
  * "Stable while you read it" was the old claim and it is false exactly where it
17
18
  * matters. The queue is filled by agents that are still running: on a live
18
19
  * 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
20
+ * `seisin deny 1` (now `decline`) settled a different request than the one printed as #1 seconds
20
21
  * 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.
22
+ * job is deciding a request, and it happened twice in one session.
22
23
  *
23
24
  * A position is still accepted, because reading a list and typing a number is
24
25
  * how anyone will use this. An argument that is not a number is matched against
@@ -28,8 +29,10 @@ import { C, out, safe } from "../render.js";
28
29
  * An ambiguous match is refused rather than resolved. Choosing for you is the
29
30
  * failure being fixed.
30
31
  */
31
- function pick(config, n) {
32
- const queue = pending(requestsPath(config.root));
32
+ function pick(config, n, verb = "grant") {
33
+ if (n === undefined || n === "" || String(n).startsWith("--"))
34
+ throw new Error(`usage: seisin ${verb} <n|id> [--reason "…"] — the numbers and ids are in: seisin requests`);
35
+ const queue = queueOf(config);
33
36
  if (!queue.length) throw new Error("no pending requests");
34
37
 
35
38
  const arg = String(n).trim();
@@ -53,8 +56,13 @@ function pick(config, n) {
53
56
  : " Use its number, or the id printed under it by `seisin requests`."));
54
57
  }
55
58
 
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
+ }
63
+
56
64
  export function requests(config) {
57
- const queue = markStale(pending(requestsPath(config.root)), read(logPath(config.root)));
65
+ const queue = markStale(queueOf(config), read(logPath(config.root)));
58
66
  out(renderQueue(queue));
59
67
  return queue;
60
68
  }
@@ -64,10 +72,34 @@ export function renderQueue(queue) {
64
72
  if (queue.length === 0) return `\n ${C.dim}no pending requests${C.off}\n\n`;
65
73
 
66
74
  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}`;
75
+ 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`);
77
+ // Said, because it bites: grant #1 and the old #2 is #1 now.
78
+ lines.push(` ${C.dim}#n is a position and shifts as requests are settled; the id does not.${C.off}\n\n`);
79
+ return lines.join("");
80
+ }
81
+
82
+ /**
83
+ * What one run added to the queue, for the end of `seisin run`. Each entry
84
+ * carries `n`, its number in the whole queue, so `grant <n>` works from here.
85
+ */
86
+ export function renderAdded(added, older) {
87
+ if (!added.length) return "";
88
+ const lines = ["\n"];
89
+ for (const r of added) lines.push(...entryLines(r, r.n));
90
+ lines.push(`\n ${C.dim}this run: ${added.length} new request(s)` +
91
+ `${older ? ` · ${older} older pending` : ""} — seisin requests${C.off}\n\n`);
92
+ return lines.join("");
93
+ }
94
+
95
+ function entryLines(r, n) {
96
+ const lines = [];
97
+ {
98
+ const owners = r.declared
99
+ ? ` ${C.dim}(${r.owners.length ? `declared by ${r.owners.join(", ")}` : "declared by no role"})${C.off}`
100
+ : r.owners.length ? ` ${C.dim}(owned by ${r.owners.join(", ")})${C.off}` : ` ${C.dim}(unowned)${C.off}`;
69
101
  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`);
102
+ 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
103
  lines.push(` ${C.dim}first asked over ${safe(r.target)}${C.off}\n`);
72
104
  // The stable way to name it: a number is a position in a queue that agents
73
105
  // are still writing to, and this does not move when the queue does.
@@ -77,9 +109,8 @@ export function renderQueue(queue) {
77
109
  lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
78
110
  const note = handoffNote(r.handoff);
79
111
  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("");
112
+ }
113
+ return lines;
83
114
  }
84
115
 
85
116
  /**
@@ -97,9 +128,9 @@ export function handoffNote(h) {
97
128
  case "throttled":
98
129
  return `handoff held: ${limitWords(h.limit, h.role)} ${h.max}/${h.max} — still to do`;
99
130
  case "cycle":
100
- return `handoff stopped: ${h.role} is already in this chain — needs a person to split the work`;
131
+ return `handoff stopped: ${h.role} is already in this handoff chain — needs a person to split the work`;
101
132
  case "depth":
102
- return `handoff stopped: chain reached ${h.depth} of ${h.max} — needs a person`;
133
+ return `handoff stopped: handoff chain reached ${h.depth} of ${h.max} — needs a person`;
103
134
  case "human":
104
135
  return `handoff stopped: ${h.reason === "ambiguous" ? "more than one role owns this" : "no role owns this"}`;
105
136
  case "route":
@@ -113,46 +144,92 @@ export function handoffNote(h) {
113
144
 
114
145
  /** The limit names are internal; what a person reads should not be a field name. */
115
146
  function limitWords(limit, role) {
116
- if (limit === "senderChains") return "open chains for this role";
147
+ if (limit === "senderChains") return "open handoff chains for this role";
117
148
  if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
118
149
  return limit ?? "a limit";
119
150
  }
120
151
 
121
152
  export function grant(config, argv = []) {
122
- const req = pick(config, argv[0]);
123
153
  const i = argv.indexOf("--reason");
124
154
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
125
155
 
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;
156
+ // Looked up, applied and settled under one lock — the one the console's
157
+ // decide takes too. Picking before the lock let this and the console both
158
+ // find the same request open and act on it twice.
159
+ let req;
160
+ const { changed } = underLock(() => editPolicy(config, (before) => {
161
+ req = pick(config, argv[0], "grant");
162
+ refuseIfBarred(config, req);
163
+ return applyGrant(before, req, reason);
164
+ }, { after: () => settle(requestsPath(config.root), req.key, "granted", reason) }));
165
+ // Already in the policy: the request is answered all the same, as the
166
+ // console answers it. Throwing here left it pending forever — the person had
167
+ // said yes, the role had the path, and the queue kept asking.
168
+ if (!changed) {
169
+ out(
170
+ `\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`
172
+ );
173
+ out(remaining(config));
174
+ return { request: req, grant: req.grant, changed: false };
136
175
  }
137
- if (!changed) throw new Error(`${req.role} already has ${req.grant} — nothing to add`);
138
176
 
139
- settle(requestsPath(config.root), req.key, "granted", reason);
177
+ // A key another role declares is shared by this grant, not moved: both roles
178
+ // read it from now on. That is sometimes the point, and never a thing to find
179
+ // out later.
180
+ const others = req.action === "read" ? keyHolders(config, req.target).filter((r) => r !== req.role) : [];
140
181
  out(
141
182
  `\n ${C.green}granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
183
+ (others.length
184
+ ? ` ${C.yellow}shared: ${req.grant} is also declared by ${others.join(", ")} — both roles read it now${C.off}\n`
185
+ : "") +
142
186
  ` ${C.dim}written into ${config.path} with its provenance. It applies on the next run.${C.off}\n\n`
143
187
  );
144
- return { request: req, grant: req.grant };
188
+ out(remaining(config));
189
+ return { request: req, grant: req.grant, shared: others };
190
+ }
191
+
192
+ /** A held lock is another decision in flight, not a failure of this one. */
193
+ function underLock(fn) {
194
+ try {
195
+ return fn();
196
+ } catch (e) {
197
+ if (e.code === "ELOCKED")
198
+ throw new Error("another decision is in progress — run this again in a moment");
199
+ throw e;
200
+ }
145
201
  }
146
202
 
147
203
  export function deny(config, argv = []) {
148
- const req = pick(config, argv[0]);
149
204
  const i = argv.indexOf("--reason");
150
205
  const reason = i === -1 ? "" : argv[i + 1] ?? "";
151
206
 
152
- settle(requestsPath(config.root), req.key, "denied", reason);
207
+ // The policy is not edited, but the lock is still the policy's: it is the one
208
+ // every decision takes, so a decline cannot settle what a grant is applying.
209
+ let req;
210
+ underLock(() => editPolicy(config, (before) => {
211
+ req = pick(config, argv[0], "decline");
212
+ return { toml: before, changed: false };
213
+ }, { after: () => settle(requestsPath(config.root), req.key, "denied", reason) }));
153
214
  out(
154
215
  `\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
155
216
  ` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
156
217
  );
218
+ out(remaining(config));
157
219
  return { request: req, reason };
158
220
  }
221
+
222
+ /**
223
+ * The queue after a decision, numbered as it now is.
224
+ *
225
+ * Numbers are positions: settling #1 makes the old #2 the new #1, and the next
226
+ * `grant 2` — typed from the listing still on screen — lands on what used to
227
+ * be #3. One line with the new numbering, so the screen is right again.
228
+ */
229
+ export function remaining(config) {
230
+ const queue = queueOf(config);
231
+ if (!queue.length) return ` ${C.dim}no requests left${C.off}\n\n`;
232
+ const shown = queue.slice(0, 5).map((r, i) => `#${i + 1} ${safe(r.role)} ${r.action} ${safe(r.grant)}`);
233
+ const more = queue.length > 5 ? ` · +${queue.length - 5} more` : "";
234
+ return ` ${C.dim}left, renumbered: ${shown.join(" · ")}${more}${C.off}\n\n`;
235
+ }
@@ -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
  *
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { review } from "../review.js";
18
18
  import { C, out } from "../render.js";
19
+ import { wired } from "./wire.js";
19
20
 
20
21
  export function reviewCommand(config, argv = []) {
21
22
  const i = argv.indexOf("--min");
@@ -36,11 +37,11 @@ export function reviewCommand(config, argv = []) {
36
37
  }
37
38
 
38
39
  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`);
40
+ out(`\n ${C.dim}${r.entries} entries, ${day(r.window.from)} to ${day(r.window.to)}${C.off}\n`);
40
41
 
41
42
  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`);
43
+ out(`\n ${C.b}Denied repeatedly${C.off}\n`);
44
+ out(` ${C.dim}a role denied on the same place over and over is a policy that is wrong,${C.off}\n`);
44
45
  out(` ${C.dim}not an agent that is misbehaving${C.off}\n\n`);
45
46
  for (const f of cap(r.friction)) {
46
47
  const whose = f.owners.length ? ` ${C.dim}— belongs to ${who(f.owners)}${C.off}` : "";
@@ -65,7 +66,7 @@ export function reviewCommand(config, argv = []) {
65
66
  * working either when the service is one the role was meant to reach.
66
67
  */
67
68
  if (r.connects?.length) {
68
- out(`\n ${C.b}Refused connections${C.off}\n`);
69
+ out(`\n ${C.b}Denied connections${C.off}\n`);
69
70
  out(` ${C.dim}a local port or socket the role kept dialling. Not a grant: a port goes in${C.off}\n`);
70
71
  out(` ${C.dim}local_ports, and a socket stays closed${C.off}\n\n`);
71
72
  for (const c of cap(r.connects))
@@ -75,7 +76,7 @@ export function reviewCommand(config, argv = []) {
75
76
  }
76
77
 
77
78
  if (r.guarded.length) {
78
- out(`\n ${C.b}Held at the keys${C.off}\n`);
79
+ out(`\n ${C.b}Denied at the key directories${C.off}\n`);
79
80
  out(` ${C.dim}a [keys] dir is closed to every role, so this is the policy working${C.off}\n`);
80
81
  out(` ${C.dim}— not a territory drawn wrong, and never a thing to grant${C.off}\n\n`);
81
82
  for (const g of cap(r.guarded))
@@ -90,32 +91,56 @@ export function reviewCommand(config, argv = []) {
90
91
  *
91
92
  * Without the hook the log holds denials and nothing else, so every grant
92
93
  * 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.
94
+ * have for making a policy smaller, printed over the whole policy.
94
95
  */
95
96
  if (!r.unusedKnowable) {
96
97
  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`);
98
+ out(` ${C.dim}nothing here records what was ALLOWED, only what was denied. Every grant${C.off}\n`);
99
+ // Wired already: telling someone to run `seisin wire` again sends them
100
+ // looking for a fault in a setup that is fine and simply has not run.
101
+ if (wired(config.root))
102
+ out(` ${C.dim}would look dead, so nothing is listed. wired, but no allowed action logged yet —${C.off}\n` +
103
+ ` ${C.dim}run an agent (claude, codex…) through seisin run and come back.${C.off}\n`);
104
+ else
105
+ out(` ${C.dim}would look dead, so nothing is listed. Run \`seisin wire\` and come back.${C.off}\n`);
99
106
  }
100
107
 
101
108
  if (r.unused.length) {
102
109
  out(`\n ${C.b}Granted, never used${C.off}\n`);
103
110
  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`);
111
+ out(` ${C.dim}anyone will ever have for making a policy smaller${C.off}\n\n`);
105
112
  for (const u of cap(r.unused)) out(` ${C.b}${u.role}${C.off} ${u.glob}\n`);
106
113
  more(r.unused);
107
114
  out(`\n ${C.dim}A short window proves nothing. Check the dates before you delete a line.${C.off}\n`);
108
115
  }
109
116
 
110
117
  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);
118
+ out(`\n ${C.b}Unowned${C.off}\n`);
119
+ out(` ${C.dim}touched by an agent, claimed by no role. Only an ownable path (kind \`territory\`)${C.off}\n`);
120
+ 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`);
121
+ out(` ${C.dim}of them is an owner${C.off}\n\n`);
122
+ // Territory first: it is the part that is a decision, and on a real log it
123
+ // was 22 paths under 279 of a test's scratch — past the ten-row cap.
124
+ const ordered = [...r.unowned.filter((u) => u.kind === "territory"), ...r.unowned.filter((u) => u.kind !== "territory")];
125
+ const shown = cap(ordered);
126
+ const width = Math.max(...shown.map((u) => (u.kind ?? "").length));
127
+ for (const u of shown)
128
+ out(` ${String(u.times).padStart(4)}× ${u.kind ? `${C.dim}${u.kind.padEnd(width)}${C.off} ` : ""}${u.where}\n`);
129
+ more(ordered);
130
+ // The advice once per kind shown, not once per row: a hundred rows of
131
+ // `.git` do not need the same sentence a hundred times.
132
+ const hints = new Map(shown.filter((u) => u.hint).map((u) => [u.kind, u.hint]));
133
+ if (hints.size) out("\n");
134
+ for (const [kind, hint] of hints) out(` ${C.dim}${kind}: ${hint}${C.off}\n`);
135
+ // All of it by kind, so the part past the cap is still said in one line.
136
+ const tally = new Map();
137
+ for (const u of r.unowned) if (u.kind) tally.set(u.kind, (tally.get(u.kind) ?? 0) + u.times);
138
+ if (tally.size > 1)
139
+ 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
140
  }
116
141
 
117
142
  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`);
143
+ out(`\n ${C.green}nothing to report${C.off} ${C.dim}— no repeated denials, no unused grants, no unowned paths${C.off}\n`);
119
144
 
120
145
  out("\n");
121
146
  return r;