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