seisin 0.4.0 → 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 +151 -67
- 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 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- 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 +261 -27
- 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 +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- 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 +1366 -529
package/src/commands/log.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createReadStream, watch as watchDir } from "node:fs";
|
|
12
12
|
import { dirname, relative } from "node:path";
|
|
13
|
-
import { read, logPath, size, verifyLog } from "../log.js";
|
|
13
|
+
import { read, logPath, size, verifyLog, LOG_NAME } from "../log.js";
|
|
14
14
|
import { renderEntry, C, out } from "../render.js";
|
|
15
15
|
|
|
16
16
|
const flag = (argv, name) => {
|
|
@@ -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
|
}
|
|
@@ -50,14 +65,44 @@ function verify(config) {
|
|
|
50
65
|
}
|
|
51
66
|
const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
|
|
52
67
|
const segs = r.segments > 1 ? ` across ${r.segments} segments` : "";
|
|
68
|
+
// A dropped entry leaves no hole in the chain, so it is reported on its own
|
|
69
|
+
// line rather than as a break: nothing was tampered with, but something is
|
|
70
|
+
// missing, and an intact chain must not read as a complete record.
|
|
71
|
+
const dropped = r.dropped
|
|
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`
|
|
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`;
|
|
53
89
|
if (!r.breaks.length) {
|
|
54
|
-
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n\n`);
|
|
90
|
+
out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${genesis}${dropped}\n`);
|
|
55
91
|
return r;
|
|
56
92
|
}
|
|
57
93
|
out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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`);
|
|
61
106
|
process.exitCode = 1;
|
|
62
107
|
return r;
|
|
63
108
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
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 {
|
|
10
|
-
import {
|
|
9
|
+
import { pending, settle, applyGrant, refuseIfBarred, requestsPath, markStale, editPolicy, withDeclarers, shellId } from "../requests.js";
|
|
10
|
+
import { keyHolders } from "../owners.js";
|
|
11
11
|
import { read, logPath } from "../log.js";
|
|
12
|
-
import { C, out } from "../render.js";
|
|
12
|
+
import { C, out, safe } from "../render.js";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Which request you meant — by position, or by the identity it carries.
|
|
@@ -17,9 +17,9 @@ import { C, out } from "../render.js";
|
|
|
17
17
|
* "Stable while you read it" was the old claim and it is false exactly where it
|
|
18
18
|
* matters. The queue is filled by agents that are still running: on a live
|
|
19
19
|
* repository it grew and shrank between a listing and the next command, and
|
|
20
|
-
* `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
|
|
21
21
|
* earlier. That is time-of-check-to-time-of-use in the one command whose whole
|
|
22
|
-
* job is deciding a
|
|
22
|
+
* job is deciding a request, and it happened twice in one session.
|
|
23
23
|
*
|
|
24
24
|
* A position is still accepted, because reading a list and typing a number is
|
|
25
25
|
* how anyone will use this. An argument that is not a number is matched against
|
|
@@ -29,8 +29,10 @@ import { C, out } from "../render.js";
|
|
|
29
29
|
* An ambiguous match is refused rather than resolved. Choosing for you is the
|
|
30
30
|
* failure being fixed.
|
|
31
31
|
*/
|
|
32
|
-
function pick(config, n) {
|
|
33
|
-
|
|
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);
|
|
34
36
|
if (!queue.length) throw new Error("no pending requests");
|
|
35
37
|
|
|
36
38
|
const arg = String(n).trim();
|
|
@@ -40,20 +42,27 @@ function pick(config, n) {
|
|
|
40
42
|
return req;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Exactly, never by prefix. A prefix let an id that had just been settled
|
|
46
|
+
// select a newer request whose id merely starts the same (`lib` → `lib-x`),
|
|
47
|
+
// and an agent chooses the paths ids are made of.
|
|
48
|
+
const hit = queue.find((r) => r.key === arg);
|
|
49
|
+
if (hit) return hit;
|
|
50
|
+
const near = queue.filter((r) => r.key.startsWith(arg));
|
|
49
51
|
throw new Error(
|
|
50
|
-
`"${arg}"
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
`no pending request has the id "${arg}".\n` +
|
|
53
|
+
(near.length
|
|
54
|
+
? ` ${near.length} start with it:\n` + near.slice(0, 5).map((r) => ` ${r.key}`).join("\n") +
|
|
55
|
+
"\n Name one exactly. Choosing for you is the mistake this avoids."
|
|
56
|
+
: " Use its number, or the id printed under it by `seisin requests`."));
|
|
57
|
+
}
|
|
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 }));
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export function requests(config) {
|
|
56
|
-
const queue = markStale(
|
|
65
|
+
const queue = markStale(queueOf(config), read(logPath(config.root)));
|
|
57
66
|
out(renderQueue(queue));
|
|
58
67
|
return queue;
|
|
59
68
|
}
|
|
@@ -63,22 +72,45 @@ export function renderQueue(queue) {
|
|
|
63
72
|
if (queue.length === 0) return `\n ${C.dim}no pending requests${C.off}\n\n`;
|
|
64
73
|
|
|
65
74
|
const lines = [`\n ${C.yellow}${queue.length} pending request(s)${C.off}\n\n`];
|
|
66
|
-
queue.forEach((r, i) =>
|
|
67
|
-
|
|
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}`;
|
|
68
101
|
const times = r.times > 1 ? ` ${C.dim}· asked ${r.times}×${C.off}` : "";
|
|
69
|
-
lines.push(` ${C.b}#${
|
|
70
|
-
lines.push(` ${C.dim}first asked over ${r.target}${C.off}\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`);
|
|
103
|
+
lines.push(` ${C.dim}first asked over ${safe(r.target)}${C.off}\n`);
|
|
71
104
|
// The stable way to name it: a number is a position in a queue that agents
|
|
72
105
|
// are still writing to, and this does not move when the queue does.
|
|
73
|
-
lines.push(` ${C.dim}id ${r.key}${C.off}\n`);
|
|
106
|
+
lines.push(` ${C.dim}id ${safe(r.key)}${C.off}\n`);
|
|
74
107
|
// Marked, not moved: the number above is what `grant <n>` is typed against.
|
|
75
108
|
if (r.stale)
|
|
76
109
|
lines.push(` ${C.dim}not asked again in ${r.stale.runs} runs of ${r.role} since — likely no longer needed${C.off}\n`);
|
|
77
110
|
const note = handoffNote(r.handoff);
|
|
78
111
|
if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
|
|
79
|
-
}
|
|
80
|
-
lines
|
|
81
|
-
return lines.join("");
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
82
114
|
}
|
|
83
115
|
|
|
84
116
|
/**
|
|
@@ -96,9 +128,9 @@ export function handoffNote(h) {
|
|
|
96
128
|
case "throttled":
|
|
97
129
|
return `handoff held: ${limitWords(h.limit, h.role)} ${h.max}/${h.max} — still to do`;
|
|
98
130
|
case "cycle":
|
|
99
|
-
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`;
|
|
100
132
|
case "depth":
|
|
101
|
-
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`;
|
|
102
134
|
case "human":
|
|
103
135
|
return `handoff stopped: ${h.reason === "ambiguous" ? "more than one role owns this" : "no role owns this"}`;
|
|
104
136
|
case "route":
|
|
@@ -112,39 +144,92 @@ export function handoffNote(h) {
|
|
|
112
144
|
|
|
113
145
|
/** The limit names are internal; what a person reads should not be a field name. */
|
|
114
146
|
function limitWords(limit, role) {
|
|
115
|
-
if (limit === "senderChains") return "open chains for this role";
|
|
147
|
+
if (limit === "senderChains") return "open handoff chains for this role";
|
|
116
148
|
if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
|
|
117
149
|
return limit ?? "a limit";
|
|
118
150
|
}
|
|
119
151
|
|
|
120
152
|
export function grant(config, argv = []) {
|
|
121
|
-
const req = pick(config, argv[0]);
|
|
122
153
|
const i = argv.indexOf("--reason");
|
|
123
154
|
const reason = i === -1 ? "" : argv[i + 1] ?? "";
|
|
124
155
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 };
|
|
175
|
+
}
|
|
129
176
|
|
|
130
|
-
|
|
131
|
-
|
|
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) : [];
|
|
132
181
|
out(
|
|
133
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
|
+
: "") +
|
|
134
186
|
` ${C.dim}written into ${config.path} with its provenance. It applies on the next run.${C.off}\n\n`
|
|
135
187
|
);
|
|
136
|
-
|
|
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
|
+
}
|
|
137
201
|
}
|
|
138
202
|
|
|
139
203
|
export function deny(config, argv = []) {
|
|
140
|
-
const req = pick(config, argv[0]);
|
|
141
204
|
const i = argv.indexOf("--reason");
|
|
142
205
|
const reason = i === -1 ? "" : argv[i + 1] ?? "";
|
|
143
206
|
|
|
144
|
-
|
|
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) }));
|
|
145
214
|
out(
|
|
146
215
|
`\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
|
|
147
216
|
` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
|
|
148
217
|
);
|
|
218
|
+
out(remaining(config));
|
|
149
219
|
return { request: req, reason };
|
|
150
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;
|