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.
- package/README.md +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1356 -537
package/src/commands/log.js
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `seisin requests`, `grant` and `
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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}#${
|
|
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
|
|
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
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/review.js
CHANGED
|
@@ -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
|
|
9
|
-
* is a thing to fix, and a CI job that says so beats 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}
|
|
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}
|
|
43
|
-
out(` ${C.dim}a role
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
98
|
-
|
|
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
|
|
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}
|
|
112
|
-
out(` ${C.dim}touched by an agent, claimed by no role
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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;
|