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.
Files changed (50) hide show
  1. package/README.md +151 -67
  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 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  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 +261 -27
  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 +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  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 +203 -42
  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 +1366 -529
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * seisin — give each agent its own folders and its own keys.
4
4
  *
5
5
  * This file is dispatch and nothing else: parse argv, load the config, call one
6
- * command, turn a thrown error into an exit code. Every command lives in
6
+ * command, make a thrown error an exit code. Every command lives in
7
7
  * `commands/` and every decision lives in a module beside it, so that a change
8
8
  * to how something is printed cannot change what is allowed.
9
9
  *
@@ -19,21 +19,19 @@ import { fileURLToPath } from "node:url";
19
19
  import { findConfig, loadConfig } from "./config.js";
20
20
  import { CONFIG_NAME } from "./layout.js";
21
21
  import { C, out, err } from "./render.js";
22
+ import { COMMAND_HELP, flagsOf, renderHelp } from "./help.js";
23
+ import { nearest, checkFlags } from "./suggest.js";
22
24
 
23
- import { run } from "./commands/run.js";
24
- import { check } from "./commands/check.js";
25
- import { explainCommand } from "./commands/explain.js";
26
- import { scanCommand } from "./commands/scan.js";
27
- import { init, initFromObservations } from "./commands/init.js";
28
- import { log, watch } from "./commands/log.js";
29
- import { hook } from "./commands/hook.js";
30
- import { ui } from "./commands/ui.js";
31
- import { requests, grant, deny } from "./commands/requests.js";
32
- import { whose } from "./commands/whose.js";
33
- import { wallsCommand } from "./commands/walls.js";
34
- import { reviewCommand } from "./commands/review.js";
35
- import { wire } from "./commands/wire.js";
36
- import { serveMcp } from "./mcp.js";
25
+ /**
26
+ * Each command is imported when it is the one being run, not before.
27
+ *
28
+ * All fifteen used to load up front — 42 of 47 modules, the MCP server and the
29
+ * HTTP console among them — and `seisin hook` paid for every one on every tool
30
+ * call of every agent: 218 ms measured, where the hook itself needs a fraction
31
+ * of that. A dispatcher that loads everything to run one thing charges the
32
+ * most frequent command for the rarest ones. So each entry in COMMANDS below
33
+ * does its own `import()`.
34
+ */
37
35
 
38
36
  const HERE = dirname(fileURLToPath(import.meta.url));
39
37
  const [, , command, ...argv] = process.argv;
@@ -43,23 +41,26 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
43
41
 
44
42
  seisin run <role> -- <command...> run a command as that role
45
43
  seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
46
- seisin check [role] print the map, run nothing
47
- seisin explain <role> read|write <path>
48
- seisin whose <path> who owns it — safe to call from inside the box
49
- seisin scan find secrets outside the declared key dirs
44
+ seisin check [role] [--verbose] print the map, run nothing
45
+ seisin explain <role> read|write|mcp <path, key or server>
46
+ seisin whose <path> who owns it — safe to call from inside the sandbox
47
+ seisin scan [--all] find secrets outside the declared key dirs
50
48
  seisin review [--all] what the log says about the policy
51
49
  seisin walls <role> [--all] what that role keeps being denied, and what it cost
52
50
  seisin wire let the agent record what it does
53
51
  seisin requests what the agents asked for and cannot have
54
- seisin grant <n> [--reason "…"] approve one, with its provenance
55
- seisin decline <n> [--reason "…"] turn one down, and record why
56
- seisin log [--role r] [--verdict denied]
52
+ seisin grant <n> [--reason "…"] grant one: add it to ${CONFIG_NAME}, with its provenance
53
+ seisin decline <n> [--reason "…"] decline one, and record why
54
+ seisin log [--role r] [--verdict denied] [--limit n]
57
55
  seisin log verify does the log's hash chain hold
58
56
  seisin watch follow the log live
59
- seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
57
+ seisin init [--force] propose a ${CONFIG_NAME} for this repo
58
+ seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
60
59
  seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
61
60
  seisin mcp an MCP server on stdio — read-only
62
61
 
62
+ seisin <command> --help lists a command's flags, examples and exit codes.
63
+
63
64
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
64
65
  seisin decides what to ask for, and says whose file it was when the answer is no.
65
66
  `;
@@ -71,6 +72,12 @@ function config() {
71
72
  return loadConfig(path);
72
73
  }
73
74
 
75
+ /** The policy here, or null — for a command that can answer without one. */
76
+ function maybeConfig() {
77
+ const path = findConfig();
78
+ return path ? loadConfig(path) : null;
79
+ }
80
+
74
81
  /**
75
82
  * Commands that need a config get it; commands that make one do not.
76
83
  *
@@ -78,24 +85,29 @@ function config() {
78
85
  * the exceptions — they hand the process over and never come back here.
79
86
  */
80
87
  const COMMANDS = {
81
- run: async () => await run(config(), argv),
88
+ run: async () => await (await import("./commands/run.js")).run(config(), argv),
82
89
  // Non-zero when the policy cannot be enforced as written, so `seisin check`
83
90
  // composes in a pre-commit hook or CI the way `explain` and `scan` already do.
84
91
  // Warnings about a config that WILL work still exit 0 — failing on those
85
92
  // would make the command unusable within a week.
86
- check: () => (check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
87
- explain: () => (explainCommand(config(), argv).allowed ? 0 : 1),
88
- scan: () => (scanCommand(config()).certain.length ? 1 : 0),
89
- log: () => void log(config(), argv),
90
- watch: () => void watch(config()),
91
- init: () => void (argv.includes("--from-observations") ? initFromObservations(config()) : init()),
92
- ui: () => ui(config(), argv),
93
- whose: () => void whose(config(), argv),
94
- wire: () => void wire(config()),
95
- review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
96
- walls: () => (wallsCommand(config(), argv).length ? 1 : 0),
97
- requests: () => (requests(config()).length ? 1 : 0),
98
- grant: () => void grant(config(), argv),
93
+ check: async () => ((await import("./commands/check.js")).check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
94
+ explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
95
+ scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
96
+ log: async () => void (await import("./commands/log.js")).log(config(), argv),
97
+ watch: async () => void (await import("./commands/log.js")).watch(config()),
98
+ init: async () => {
99
+ const { init, initFromObservations } = await import("./commands/init.js");
100
+ void (argv.includes("--from-observations") ? initFromObservations(config()) : init(process.cwd(), { force: argv.includes("--force") }));
101
+ },
102
+ // --link only reads a record; it answers without a policy here too, and
103
+ // says so when the console it finds serves another one.
104
+ ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
105
+ whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
106
+ wire: async () => void (await import("./commands/wire.js")).wire(config()),
107
+ review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
108
+ walls: async () => ((await import("./commands/walls.js")).wallsCommand(config(), argv).length ? 1 : 0),
109
+ requests: async () => ((await import("./commands/requests.js")).requests(config()).length ? 1 : 0),
110
+ grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
99
111
  /**
100
112
  * A person DECLINES a request; the boundary DENIES a write. One word each,
101
113
  * because they are opposite events and `deny` used to name both — a queue
@@ -105,15 +117,22 @@ const COMMANDS = {
105
117
  * `deny` stays and is not documented. Renaming a published command over a
106
118
  * word is not worth breaking somebody's script; teaching the new one is.
107
119
  */
108
- decline: () => void deny(config(), argv),
109
- deny: () => void deny(config(), argv),
120
+ decline: async () => void (await import("./commands/requests.js")).deny(config(), argv),
121
+ deny: async () => void (await import("./commands/requests.js")).deny(config(), argv),
110
122
  mcp: async () => {
111
- await serveMcp(version());
123
+ await (await import("./mcp.js")).serveMcp(version());
112
124
  return 0;
113
125
  },
126
+ // Always 0. Claude Code reads a non-zero exit from a PreToolUse hook as
127
+ // "block", so an internal error here would stop the agent's tool call — and
128
+ // the hook explains, it never enforces. The error goes to stderr instead.
114
129
  hook: async () => {
115
- const decision = await hook();
116
- if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
130
+ try {
131
+ const decision = await (await import("./commands/hook.js")).hook();
132
+ if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
133
+ } catch (e) {
134
+ err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
135
+ }
117
136
  return 0;
118
137
  },
119
138
  "--version": () => void out(version() + "\n"),
@@ -124,16 +143,96 @@ function version() {
124
143
  return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
125
144
  }
126
145
 
127
- const chosen = COMMANDS[command];
146
+ /**
147
+ * `--help` is a question, never an instruction to do the thing.
148
+ *
149
+ * No command looked for it, so each one took it as an argument: `ui --help`
150
+ * started a server on 4178, `wire --help` wrote .claude/settings.json, `watch
151
+ * --help` followed the log until ctrl-c, `mcp --help` sat waiting on stdin, and
152
+ * `seisin --help` printed the usage and exited 2. Answered here, before any
153
+ * command is loaded or any policy read, so it works in a directory with none.
154
+ *
155
+ * Only seisin's own part of the line is searched: after `--` the words are the
156
+ * agent's, and so is everything after `run <role> <command>` without one —
157
+ * `seisin run dev -- ls --help` asks `ls`. The value of a flag that takes one
158
+ * (`--reason -h`) is a value.
159
+ */
160
+ const HELP = new Set(["--help", "-h"]);
161
+ const TAKES_VALUE = new Set(["--port", "--role", "--verdict", "--limit", "--min", "--since", "--reason"]);
162
+
163
+ function asksHelp(name, args) {
164
+ const split = args.indexOf("--");
165
+ let ours = split === -1 ? args : args.slice(0, split);
166
+ if (name === "run" && split === -1) {
167
+ let i = 1;
168
+ while (i < args.length && args[i].startsWith("-")) i++;
169
+ ours = args.slice(0, i);
170
+ }
171
+ for (let i = 0; i < ours.length; i++) {
172
+ if (TAKES_VALUE.has(ours[i])) i++;
173
+ else if (HELP.has(ours[i])) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /** One command's help: its flags, examples and exit codes, or the whole usage. */
179
+ function helpFor(name) {
180
+ if (COMMAND_HELP[name]) return renderHelp(name, C);
181
+ const alias = name === "deny" ? "decline" : name;
182
+ const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${alias} `) || l === ` seisin ${alias}`);
183
+ if (!lines.length) return USAGE;
184
+ return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
185
+ }
186
+
187
+ if (command === undefined || command === "help" || HELP.has(command)) {
188
+ const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
189
+ out(about ? helpFor(about) : USAGE);
190
+ process.exit(0);
191
+ }
192
+
193
+ const chosen = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : null;
128
194
  if (!chosen) {
129
- out(USAGE);
130
- process.exit(command ? 2 : 0);
195
+ // One line, and the nearest command. The whole usage, printed for a typo,
196
+ // pushed the one line that mattered off the screen.
197
+ const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
198
+ : nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-") && c !== "deny"));
199
+ err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
200
+ `${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
201
+ process.exit(2);
202
+ }
203
+ if (!command.startsWith("-") && asksHelp(command, argv)) {
204
+ out(helpFor(command));
205
+ process.exit(0);
131
206
  }
132
207
 
133
208
  try {
209
+ refuseUnknownFlags(command, argv);
134
210
  const code = await chosen();
135
211
  if (typeof code === "number") process.exit(code);
136
212
  } catch (e) {
137
213
  err(`${C.red}seisin:${C.off} ${e.message}\n`);
138
- process.exit(2);
214
+ // A command that was not found is 127 wherever a shell runs it; everything
215
+ // else seisin refuses is a usage or policy error.
216
+ process.exit(e.exitCode ?? 2);
217
+ }
218
+
219
+ /**
220
+ * A flag a command does not take is an error, never ignored: `check --verbsoe`
221
+ * printed the short map as if nothing had been asked. For `run`, only seisin's
222
+ * own part of the line — before `--`, or the leading options — is checked.
223
+ */
224
+ function refuseUnknownFlags(name, args) {
225
+ const f = flagsOf(name);
226
+ if (!f || name === "hook" || name === "mcp") return;
227
+ let ours = args;
228
+ if (name === "run") {
229
+ const split = args.indexOf("--");
230
+ if (split !== -1) ours = args.slice(1, split);
231
+ else {
232
+ let i = 1;
233
+ while (i < args.length && args[i].startsWith("-")) i++;
234
+ ours = args.slice(1, i);
235
+ }
236
+ }
237
+ checkFlags(name === "deny" ? "decline" : name, ours, f.known, f.values);
139
238
  }
@@ -3,7 +3,8 @@ import { inspect } from "../inspect.js";
3
3
  import { renderReport, out } from "../render.js";
4
4
 
5
5
  export function check(config, argv = []) {
6
- const report = inspect(config, argv[0] ?? null, config.path);
7
- out(renderReport(report));
6
+ const role = argv.find((a) => !a.startsWith("-")) ?? null;
7
+ const report = inspect(config, role, config.path);
8
+ out(renderReport(report, { verbose: argv.includes("--verbose") }));
8
9
  return report;
9
10
  }
@@ -5,9 +5,11 @@
5
5
  * That is the only reason the exit code is not always 0 — a denial is an answer,
6
6
  * not a failure, but a shell has one channel for both.
7
7
  */
8
- import { toRepoRelative } from "../paths.js";
8
+ import { toRepoRelative, fromCwd } from "../paths.js";
9
9
  import { isAbsolute } from "node:path";
10
- import { explain } from "../owners.js";
10
+ import { explain, explainFileRead, readTarget } from "../owners.js";
11
+ import { CREDENTIAL_HOMES, expand } from "../grants.js";
12
+ import { unknownRole } from "../suggest.js";
11
13
  import { twinsOf, whereIs } from "../worktree.js";
12
14
  import { renderVerdict, C, out } from "../render.js";
13
15
 
@@ -15,7 +17,11 @@ export function explainCommand(config, argv = []) {
15
17
  const [role, action, target] = argv;
16
18
  if (!role || !action || !target)
17
19
  throw new Error("usage: seisin explain <role> <read|write|mcp> <path, key or MCP server>");
18
- if (!config.roles[role]) throw new Error(`unknown role "${role}"`);
20
+ if (!config.roles[role]) throw unknownRole(config, role);
21
+ // Anything else used to be answered as a write, confidently, to a question
22
+ // nobody asked. An unknown action is a usage error, like an unknown flag.
23
+ if (!["read", "write", "mcp"].includes(action))
24
+ throw new Error(`unknown action "${action}" — use read, write or mcp`);
19
25
 
20
26
  if (action === "mcp") {
21
27
  const verdict = explain(config, role, "mcp", target);
@@ -23,7 +29,16 @@ export function explainCommand(config, argv = []) {
23
29
  return { ...verdict, worktree: [] };
24
30
  }
25
31
 
26
- const verb = action.startsWith("r") ? "read" : "write";
32
+ const verb = action;
33
+
34
+ if (verb === "read") {
35
+ const asked = readTarget(config, target);
36
+ const verdict = asked.key
37
+ ? explain(config, role, "read", asked.target)
38
+ : explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
39
+ out(renderVerdict(role, verb, asked.target, verdict));
40
+ return { ...verdict, worktree: [] };
41
+ }
27
42
 
28
43
  /**
29
44
  * An absolute path inside the repo is the same question as the relative one.
@@ -36,17 +51,23 @@ export function explainCommand(config, argv = []) {
36
51
  * check a boundary was the one that was wrong.
37
52
  *
38
53
  * Wrong in the direction that matters, too: it reported a path as unowned and
39
- * refused while the kernel allowed it — see "Refusing beats widening" in
54
+ * denied while the kernel allowed it — see "Refusing beats widening" in
40
55
  * docs/decisions.md. A path outside the root is left exactly as written,
41
56
  * because that is a question about somewhere else and it still deserves its
42
57
  * honest "no owner".
43
58
  */
44
- const asked = toRepoRelative(config, target);
59
+ //
60
+ // A relative path is relative to where you are standing, as in every other
61
+ // command line tool: from frontend/src, `app.js` is frontend/src/app.js. It
62
+ // was read against the policy's root, so the same question asked from a
63
+ // subdirectory came back "no owner". The answer prints the path the policy
64
+ // was asked about, so the reader sees which file was meant.
65
+ const asked = toRepoRelative(config, fromCwd(config, target));
45
66
 
46
67
  const verdict = explain(config, role, verb, asked);
47
- out(renderVerdict(role, verb, target, verdict));
68
+ out(renderVerdict(role, verb, asked, verdict));
48
69
 
49
- const worktree = verb === "write" ? elsewhere(config, role, target, verdict) : [];
70
+ const worktree = elsewhere(config, role, isAbsolute(target) ? target : asked, verdict);
50
71
  for (const w of worktree) out(renderElsewhere(w));
51
72
  return { ...verdict, worktree };
52
73
  }
@@ -55,7 +76,7 @@ export function explainCommand(config, argv = []) {
55
76
  * The same question, asked about the same file in the other checkouts.
56
77
  *
57
78
  * Only a twin that gets a DIFFERENT answer is worth a line. A worktree where
58
- * the role is equally allowed, or equally refused, changes nothing about what
79
+ * the role is equally allowed, or equally denied, changes nothing about what
59
80
  * was just printed, and a diagnosis that mentions it anyway is the noise this
60
81
  * tool keeps having to remove.
61
82
  *
@@ -73,7 +94,7 @@ function elsewhere(config, role, target, verdict) {
73
94
 
74
95
  /**
75
96
  * Three lines: where the other checkout is, what the answer is there, and
76
- * which of the two the policy names — the sentence the refusal never carried.
97
+ * which of the two the policy names — the sentence the denial never carried.
77
98
  */
78
99
  function renderElsewhere(t) {
79
100
  const there = t.verdict.allowed
@@ -82,12 +103,12 @@ function renderElsewhere(t) {
82
103
  const named = t.asked.allowed ? t.here : t;
83
104
  const other = t.asked.allowed ? t : t.here;
84
105
  // "allowed" is the answer that misleads, so it is the one that gets told
85
- // where the refusal is. After "denied" that clause would only repeat it.
86
- const refused = t.asked.allowed ? ` — a write in ${kind(other)} is refused` : "";
106
+ // where the denial is. After "denied" that clause would only repeat it.
107
+ const denied = t.asked.allowed ? ` — a write in ${kind(other)} is denied` : "";
87
108
  return (
88
109
  ` ${C.yellow}worktree${C.off} ${t.where}\n` +
89
110
  ` ${C.dim} the same file there is ${C.off}${C.b}${t.rel}${C.off}${C.dim}, and it ${there}.\n` +
90
- ` The policy names ${kind(named)}, not ${kind(other)}${refused}.${C.off}\n\n`
111
+ ` The policy names ${kind(named)}, not ${kind(other)}${denied}.${C.off}\n\n`
91
112
  );
92
113
  }
93
114
 
@@ -4,7 +4,7 @@
4
4
  * Claude Code runs this once per tool call, so every path through it is short
5
5
  * and every failure is silent-and-permissive. That asymmetry is deliberate: the
6
6
  * hook explains, the kernel enforces. A hook that crashes should cost you an
7
- * explanation, never a blocked turn — and it cannot cost you a boundary,
7
+ * explanation, never a stopped tool call — and it cannot cost you a boundary,
8
8
  * because it was never the boundary.
9
9
  */
10
10
  import { findConfig, loadConfig } from "../config.js";
@@ -15,7 +15,7 @@ import { flush } from "../spool.js";
15
15
 
16
16
  export async function hook(stdin = process.stdin, env = process.env) {
17
17
  const role = env.SEISIN_ROLE;
18
- if (!role) return null; // not launched by seisin: say nothing, block nothing
18
+ if (!role) return null; // not launched by seisin: say nothing, stop nothing
19
19
 
20
20
  let raw = "";
21
21
  for await (const chunk of stdin) raw += chunk;
@@ -24,7 +24,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
24
24
  try {
25
25
  event = JSON.parse(raw || "{}");
26
26
  } catch {
27
- return null; // an unreadable event is not grounds to block work
27
+ return null; // an unreadable event is not grounds to stop work
28
28
  }
29
29
 
30
30
  let config;
@@ -44,7 +44,17 @@ export async function hook(stdin = process.stdin, env = process.env) {
44
44
  if (event_ === "SessionStart") {
45
45
  try { return atSessionStart(config, role, event, { file: logPath(config.root) }); } catch { return null; }
46
46
  }
47
- const decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
47
+ // Caught like the two halves above, and for a sharper reason: PreToolUse is
48
+ // the one event where a crash is not silent. cli.js turns a throw into exit
49
+ // 2, and Claude Code reads exit 2 from this hook as "block the tool". A bug
50
+ // here would stop the agent's work — the one thing this command promises
51
+ // never to do. Said on stderr so the bug is still findable.
52
+ let decision = null;
53
+ try {
54
+ decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
55
+ } catch (e) {
56
+ process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
57
+ }
48
58
  // The entries went to a socket, and cli.js exits as soon as we return. An
49
59
  // exit does not drain a socket, so the record would be lost precisely on the
50
60
  // turns that produced one.
@@ -11,7 +11,7 @@
11
11
  * The second is better and needs a run first. Neither writes a policy you did
12
12
  * not read: `--from-observations` lands as `.observed`, not as the config.
13
13
  */
14
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync, appendFileSync } from "node:fs";
15
15
  import { join, relative } from "node:path";
16
16
  import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
17
17
  import { read, logPath, observed, generalise } from "../log.js";
@@ -22,43 +22,104 @@ import { C, out } from "../render.js";
22
22
  * actually means. None is a policy, so all of them are proposals.
23
23
  */
24
24
  export function discover(root) {
25
+ const keyDir = KEY_DIRS.find((d) => isDir(join(root, d))) ?? null;
25
26
  const agents = join(root, ".claude", "agents");
26
27
  if (existsSync(agents)) {
27
28
  const roles = readdirSync(agents)
28
29
  .filter((f) => f.endsWith(".md"))
29
30
  .map((f) => ({ name: f.replace(/\.md$/, ""), writes: [], keys: [] }));
30
- if (roles.length) return { source: ".claude/agents/", roles };
31
+ if (roles.length) return { source: ".claude/agents/", roles, agents: roles.length, keyDir };
31
32
  }
32
33
 
33
34
  for (const p of ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]) {
34
35
  const file = join(root, p);
35
36
  if (!existsSync(file)) continue;
36
- const paths = readFileSync(file, "utf8")
37
- .split(/\r?\n/)
38
- .map((l) => l.replace(/#.*$/, "").trim())
39
- .filter(Boolean)
40
- .map((l) => l.split(/\s+/)[0])
41
- .filter((g) => g && g !== "*");
42
- if (paths.length)
43
- return {
44
- source: p,
45
- roles: [...new Set(paths)].slice(0, 8).map((g, i) => ({
46
- name: g.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
47
- writes: [g.endsWith("/") ? g + "**" : g],
48
- keys: [],
49
- })),
50
- };
37
+ const roles = fromCodeowners(readFileSync(file, "utf8"));
38
+ if (roles.length) return { source: p, roles, agents: 0, keyDir };
51
39
  }
52
40
 
41
+ const dirs = codeDirs(root);
53
42
  return {
54
- source: "nothing to read — this is a blank start",
55
- roles: [
56
- { name: "frontend", writes: ["src/web/**"], keys: [] },
57
- { name: "backend", writes: ["src/api/**"], keys: [] },
58
- ],
43
+ source: dirs.length
44
+ ? "the top-level folders that hold code — one role each"
45
+ : "nothing to read — this is a blank start",
46
+ roles: dirs.length
47
+ ? dirs.map((d) => ({ name: roleName(d), writes: [`${d}/**`], keys: [] }))
48
+ : [{ name: "agent", writes: [], keys: [] }],
49
+ agents: 0,
50
+ keyDir,
59
51
  };
60
52
  }
61
53
 
54
+ /** Where a repo keeps its keys by convention, in the order they are looked for. */
55
+ const KEY_DIRS = [".secrets", "secrets", ".keys"];
56
+
57
+ /**
58
+ * One role per owner, with every path that owner has.
59
+ *
60
+ * It was one role per LINE, named after the path, and cut at eight — so a
61
+ * team's ownership came back as eight roles called `apps-web`, `apps-api`… with
62
+ * the rest dropped and nothing said. An owner is the thing a role stands for.
63
+ * A line's first owner takes it: CODEOWNERS lets several share a path, and
64
+ * seisin would rather print one proposal a person corrects than invent an
65
+ * overlap. `*` is skipped — it is the fallback owner, not a territory.
66
+ */
67
+ export function fromCodeowners(text) {
68
+ const byOwner = new Map();
69
+ for (const raw of text.split(/\r?\n/)) {
70
+ const [glob, owner] = raw.replace(/#.*$/, "").trim().split(/\s+/);
71
+ if (!glob || glob === "*" || !owner) continue;
72
+ // CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
73
+ // are already repo-relative, so a literal "/apps/**" would ask the
74
+ // kernel for the machine's /apps and make `explain` (which reads it as
75
+ // outside the repo) disagree with what the profile grants. Strip the
76
+ // anchor. A trailing "/" means a directory, so it becomes "/**".
77
+ const rel = glob.replace(/^\/+/, "");
78
+ const path = rel.endsWith("/") ? rel + "**" : rel;
79
+ const name = roleName(owner.replace(/^@/, "").replace(/^.*\//, "")) || `role-${byOwner.size + 1}`;
80
+ const role = byOwner.get(name) ?? byOwner.set(name, { name, writes: [], keys: [] }).get(name);
81
+ if (!role.writes.includes(path)) role.writes.push(path);
82
+ }
83
+ return [...byOwner.values()];
84
+ }
85
+
86
+ const roleName = (s) => String(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
87
+
88
+ /**
89
+ * The top-level folders that hold code, for a repo that says nothing else.
90
+ *
91
+ * It proposed `src/web/**` and `src/api/**` whatever the repo looked like, so
92
+ * the first `check` was about folders that did not exist. Kept simple on
93
+ * purpose: a folder counts when some file under it (a few levels down) has a
94
+ * source extension. Dot-folders, dependencies and build output are skipped,
95
+ * and a folder of only documents or images proposes nothing — it is somebody's
96
+ * territory, but not the first thing a person wants to decide.
97
+ */
98
+ const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|sh|sql|vue|svelte|astro|dart|scala|ex|exs|lua|pl|r|m|html|css|scss)$/i;
99
+ const SKIP = new Set(["node_modules", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", "venv"]);
100
+
101
+ function codeDirs(root) {
102
+ let top;
103
+ try { top = readdirSync(root, { withFileTypes: true }); } catch { return []; }
104
+ return top
105
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && roleName(e.name))
106
+ .map((e) => e.name)
107
+ .filter((d) => holdsCode(join(root, d), 4))
108
+ .sort();
109
+ }
110
+
111
+ function holdsCode(dir, depth) {
112
+ let entries;
113
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
114
+ if (entries.some((e) => e.isFile() && CODE.test(e.name))) return true;
115
+ if (depth <= 1) return false;
116
+ return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && holdsCode(join(dir, e.name), depth - 1));
117
+ }
118
+
119
+ function isDir(p) {
120
+ try { return statSync(p).isDirectory(); } catch { return false; }
121
+ }
122
+
62
123
  /** The proposed config, as TOML. Pure: takes what `discover` found. */
63
124
  export function renderConfig(found) {
64
125
  const lines = [
@@ -67,9 +128,14 @@ export function renderConfig(found) {
67
128
  `# Proposed from: ${found.source}`,
68
129
  "# Nothing here is enforced until you run the agent through `seisin run`.",
69
130
  "# Anything not listed is denied. There is no permissive default.",
131
+ "#",
132
+ "# Each role runs as its own process: seisin run <role> -- <agent>",
133
+ "# A Claude Code subagent runs inside its parent's process, so it has its",
134
+ "# parent's role — give it its own by running it as its own `seisin run`.",
70
135
  "",
71
- "# [keys]",
72
- '# dir = ".secrets" # every key lives here; roles name the files they may read',
136
+ ...(found.keyDir
137
+ ? ["[keys]", `dir = ${tomlString(found.keyDir)} # every key lives here; roles name the files they may read`]
138
+ : ["# [keys]", '# dir = ".secrets" # every key lives here; roles name the files they may read']),
73
139
  "",
74
140
  "[network]",
75
141
  "# The agent's own API comes first. Leave it out and the agent cannot even",
@@ -87,6 +153,7 @@ export function renderConfig(found) {
87
153
  "",
88
154
  ];
89
155
  for (const r of found.roles) {
156
+ if (!r.writes.length) lines.push("# writes nothing yet: add the folders this role owns, e.g. \"app/**\"");
90
157
  lines.push(`[roles.${tomlName(r.name)}]`);
91
158
  lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
92
159
  lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
@@ -150,25 +217,54 @@ export function renderObserved(config, entries) {
150
217
  return { toml: lines.join("\n"), roles: roles.size };
151
218
  }
152
219
 
153
- export function init(cwd = process.cwd()) {
220
+ export function init(cwd = process.cwd(), { force = false } = {}) {
154
221
  const target = join(cwd, CONFIG_NAME);
155
- if (existsSync(target))
156
- throw new Error(`${CONFIG_NAME} already exists here. Delete it first if you meant to start over.`);
222
+ const had = existsSync(target);
223
+ if (had && !force)
224
+ throw new Error(`${CONFIG_NAME} already exists here. seisin init --force replaces it, keeping the old one as ${CONFIG_NAME}.bak`);
157
225
 
158
226
  const found = discover(cwd);
227
+ if (had) copyFileSync(target, target + ".bak");
159
228
  writeFileSync(target, renderConfig(found));
229
+ const ignored = ignoreState(cwd);
160
230
  out(
161
231
  `\n wrote ${C.b}${CONFIG_NAME}${C.off} with ${found.roles.length} role(s) from ${found.source}\n` +
162
- ` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n\n` +
163
- ` next: seisin check\n\n`
232
+ (had ? ` ${C.dim}the previous one is ${CONFIG_NAME}.bak${C.off}\n` : "") +
233
+ (found.keyDir ? ` ${C.dim}[keys] dir = "${found.keyDir}" — it is there, so it is declared${C.off}\n` : "") +
234
+ (ignored ? ` ${C.dim}added .seisin/ to .gitignore — the log and the request queue belong to this machine${C.off}\n` : "") +
235
+ ` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n` +
236
+ // Said once, here, where someone with one agent decides whether to go on.
237
+ (found.agents <= 1
238
+ ? ` ${C.dim}one agent? its own sandbox covers most of this. seisin adds a built environment, a key dir no role reads\n` +
239
+ ` unless it is declared, and an owner named on every denial — it earns its setup at two roles.${C.off}\n`
240
+ : "") +
241
+ `\n next: seisin check\n\n`
164
242
  );
165
243
  return found;
166
244
  }
167
245
 
246
+ /**
247
+ * `.seisin/` into the repo's .gitignore, when it has one and it is not there.
248
+ * The state directory is this machine's record; committed, every clone would
249
+ * start with somebody else's log and queue. No .gitignore, nothing written.
250
+ */
251
+ function ignoreState(cwd) {
252
+ const file = join(cwd, ".gitignore");
253
+ if (!existsSync(file)) return false;
254
+ const text = readFileSync(file, "utf8");
255
+ if (/^\/?\.seisin\/?\s*$/m.test(text)) return false;
256
+ appendFileSync(file, `${text.endsWith("\n") || !text ? "" : "\n"}.seisin/\n`);
257
+ return true;
258
+ }
259
+
168
260
  export function initFromObservations(config) {
169
261
  const entries = read(logPath(config.root), { verdict: "observed" });
170
262
  if (entries.length === 0)
171
- throw new Error("nothing observed yet. Run: seisin run <role> --observe -- <command>");
263
+ throw new Error(
264
+ "nothing observed yet.\n" +
265
+ " observations come from the hook: run `seisin wire` once, then observe an agent —\n" +
266
+ " seisin run <role> --observe -- claude (or codex, …)\n" +
267
+ " a plain shell command goes through no hook and records nothing. keys stay denied while observing.");
172
268
 
173
269
  const { toml, roles } = renderObserved(config, entries);
174
270
  const file = join(config.root, CONFIG_NAME + ".observed");