seisin 0.4.2 → 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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -3,18 +3,33 @@
3
3
  *
4
4
  * Exits 1 only on a `certain` finding. A `review` line is a prompt to look, and
5
5
  * failing a build on those would make the command unusable in CI within a week.
6
+ *
7
+ * Where it looks: the repository the policy is in, or — when the policy sits
8
+ * in a folder of repositories — the repositories its territories live in (see
9
+ * territoryRoots). `--all` walks the whole folder either way.
6
10
  */
7
- import { scan } from "../scan.js";
11
+ import { relative } from "node:path";
12
+ import { scan, insideRepo, territoryRoots } from "../scan.js";
13
+ import { ownersOf } from "../owners.js";
8
14
  import { renderScan, out } from "../render.js";
9
15
 
10
- export function scanCommand(config) {
11
- const { hits, skipped, truncated } = scan(config.root, config.keyDirs, config.scanIgnore);
16
+ export function scanCommand(config, argv = []) {
17
+ const all = argv.includes("--all");
18
+ const roots = all || insideRepo(config.root) ? null : territoryRoots(config);
19
+ const { hits, skipped, nestedPaths, truncated, omitted } =
20
+ scan(config.root, config.keyDirs, config.scanIgnore, 500, { roots });
21
+ // Whose territory a finding is in: the role that will come across it first.
22
+ for (const h of hits) if (h.level !== "link") h.owners = ownersOf(config, h.file);
12
23
  const result = {
13
24
  certain: hits.filter((h) => h.level === "certain"),
25
+ named: hits.filter((h) => h.level === "named"),
14
26
  review: hits.filter((h) => h.level === "review"),
15
27
  links: hits.filter((h) => h.level === "link"),
16
28
  skipped,
29
+ nestedPaths,
17
30
  truncated,
31
+ omitted,
32
+ scope: roots ? roots.map((r) => relative(config.root, r) || ".") : null,
18
33
  };
19
34
  out(renderScan(result, config.keyDirs));
20
35
  return result;
@@ -14,7 +14,6 @@
14
14
  * failing. See uilink.js.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
- import { relative } from "node:path";
18
17
  import { serve } from "../serve.js";
19
18
  import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
20
19
  import { C, out } from "../render.js";
@@ -33,7 +32,8 @@ export async function ui(config, argv = []) {
33
32
  if (argv.includes("--link")) {
34
33
  const found = readUiLink(port);
35
34
  if (found && found.alive !== false) {
36
- out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
35
+ out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n` +
36
+ servesWhich(found.policy, config?.path) + "\n");
37
37
  open(found.url);
38
38
  return;
39
39
  }
@@ -53,7 +53,8 @@ export async function ui(config, argv = []) {
53
53
  if (found && found.alive !== false) {
54
54
  out(
55
55
  `\n ${C.b}${found.url}${C.off}\n` +
56
- ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
56
+ ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n` +
57
+ servesWhich(found.policy, config.path) + "\n"
57
58
  );
58
59
  open(found.url);
59
60
  return;
@@ -69,7 +70,7 @@ export async function ui(config, argv = []) {
69
70
  const bound = server.address().port;
70
71
  const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
71
72
  // Recorded before we print, so `--link` and a second `seisin ui` can find it.
72
- writeUiLink(bound, url);
73
+ writeUiLink(bound, url, undefined, config.path);
73
74
 
74
75
  // local_binding opens every port (macOS); local_ports opens the ones it
75
76
  // names, on every platform — so only a role that names THIS port counts.
@@ -79,7 +80,7 @@ export async function ui(config, argv = []) {
79
80
 
80
81
  out(
81
82
  `\n ${C.b}${url}${C.off}\n` +
82
- ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
83
+ ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n` +
83
84
  ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
84
85
  ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
85
86
  (reach.length
@@ -95,3 +96,15 @@ export async function ui(config, argv = []) {
95
96
  process.on("SIGTERM", stop);
96
97
  return server;
97
98
  }
99
+
100
+ /**
101
+ * Which policy a running console serves, and a warning when it is not the one
102
+ * this directory would use. `here` is null when there is no policy here.
103
+ */
104
+ export function servesWhich(served, here) {
105
+ if (!served) return ` ${C.dim}(started by an older seisin: which seisin.toml it serves is not recorded)${C.off}\n`;
106
+ if (here && served !== here)
107
+ return ` ${C.yellow}it serves ${served} — not ${here}, the policy for this directory.${C.off}\n` +
108
+ ` ${C.dim}for this one, start another: seisin ui --port <n>${C.off}\n`;
109
+ return ` ${C.dim}serving ${served}${C.off}\n`;
110
+ }
@@ -1,30 +1,81 @@
1
- /** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
2
- import { walls, render } from "../walls.js";
1
+ /** `seisin walls <role>` — what that role keeps being denied, and what it cost. */
2
+ import { unknownRole } from "../suggest.js";
3
+ import { walls, wasted } from "../walls.js";
3
4
  import { STALE_RUNS } from "../requests.js";
4
5
  import { logPath } from "../log.js";
5
- import { out } from "../render.js";
6
+ import { standingOf } from "../owners.js";
7
+ import { kindsOf } from "../kinds.js";
8
+ import { parseSince } from "../views.js";
9
+ import { C, out, safe } from "../render.js";
6
10
 
7
11
  export function wallsCommand(config, argv = []) {
8
12
  const role = argv[0];
9
- if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>] [--all]");
10
- if (!config.roles[role])
11
- throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
13
+ if (!role) throw new Error("usage: seisin walls <role> [--since <date|duration>] [--min <n>] [--all]");
14
+ if (!config.roles[role]) throw unknownRole(config, role);
12
15
  const at = (flag, fallback) => {
13
16
  const i = argv.indexOf(flag);
14
17
  return i === -1 ? fallback : argv[i + 1];
15
18
  };
19
+ // The same reading of `--since` as review and the MCP: a date or `7d`.
20
+ const since = argv.includes("--since") ? parseSince(at("--since", null)) : null;
21
+ if (argv.includes("--since") && !since)
22
+ throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(at("--since", "") ?? "")}"`);
16
23
  const every = walls(config, role, {
17
24
  file: logPath(config.root),
18
25
  min: Number(at("--min", 2)),
19
- since: at("--since", null),
26
+ since,
20
27
  limit: 0,
21
28
  });
22
29
  const all = argv.includes("--all");
23
30
  const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
24
31
  const old = every.filter((w) => w.stale).length;
25
- // Nothing to say prints nothing, the way `scan` does when a repo is clean.
26
- // A command that always speaks is one whose output stops being read.
27
- if (list.length) out(render(list));
32
+ const shown = withKinds(config, list);
33
+ if (shown.length) out(renderForPerson(role, shown));
34
+ else if (!every.length)
35
+ out(`\n ${C.dim}${safe(role)} has no repeated denials in the recorded runs. A path becomes a wall after ${at("--min", 2)} denials.${C.off}\n\n`);
36
+ else if (!all)
37
+ out(`\n ${C.dim}${safe(role)} has no recent walls; older ones are hidden. Use seisin walls ${safe(role)} --all.${C.off}\n\n`);
28
38
  if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
29
- return list;
39
+ return shown;
40
+ }
41
+
42
+ /**
43
+ * An unowned path gets the kind and hint the console gives it (kinds.js).
44
+ *
45
+ * The bare reason said "has no owner — no role can write it until one claims
46
+ * it" about `dist/` and `.env` alike: an invitation to give a build folder or a
47
+ * credential an owner, while the console said what kind of thing each was and
48
+ * what to do instead. Same classifier, same sentence, on both screens.
49
+ */
50
+ function withKinds(config, list) {
51
+ const stand = standingOf(config);
52
+ const unowned = list.filter((w) => w.action === "write" && !w.owners?.length &&
53
+ stand({ kind: "file", target: w.target }).kind === "unowned");
54
+ const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: config.keyDirs ?? [] });
55
+ return list.map((w) => (unowned.includes(w) ? { ...w, ...natures.get(w.target) } : w));
56
+ }
57
+
58
+ /**
59
+ * The same walls, said to the person at the terminal.
60
+ *
61
+ * `render` speaks to the agent — "you have already been denied these… do not
62
+ * retry" — because the hook and the MCP server put it in a prompt. Printed at
63
+ * a terminal, that voice addressed the person reading it as if they were the agent.
64
+ */
65
+ export function renderForPerson(role, list) {
66
+ const lines = [`\n ${C.b}${safe(role)}${C.off} keeps hitting:\n\n`];
67
+ for (const w of list) {
68
+ if (w.expected) {
69
+ lines.push(` ${C.dim}${String(w.times).padStart(3)}× ${w.action} ${safe(w.target)} expected · ${safe(w.expected.hint)}${C.off}\n`);
70
+ continue;
71
+ }
72
+ const why = w.owners?.length ? `belongs to ${w.owners.map(safe).join(", ")}`
73
+ : w.kind ? `unowned · ${w.kind === "territory" ? "ownable" : w.kind}: ${safe(w.hint)}`
74
+ : safe(w.reason);
75
+ lines.push(` ${C.yellow}${String(w.times).padStart(3)}×${C.off} ${w.action} ${safe(w.target)} ${C.dim}${why}${C.off}\n`);
76
+ }
77
+ const n = wasted(list);
78
+ if (n > 0) lines.push(`\n ${C.dim}${n} of its calls went into retrying these.${C.off}\n`);
79
+ lines.push("\n");
80
+ return lines.join("");
30
81
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `seisin whose <path>` — the lookup a confined process is allowed to run.
3
3
  *
4
- * The README's opening claim is that a refusal names an owner. That is true
4
+ * The README's opening claim is that a denial names an owner. That is true
5
5
  * through the hook, and false for anyone who only wraps a process: the kernel
6
6
  * carries no reason, so the child sees `Operation not permitted` and guesses.
7
7
  * Measured in the field twice — an agent diagnosed a territory denial as
@@ -14,10 +14,11 @@
14
14
  *
15
15
  * It reads and prints. There is deliberately nothing here that could make it
16
16
  * worth attacking, because unlike everything else in this tool it is meant to
17
- * be called *from inside* the box.
17
+ * be called *from inside* the sandbox.
18
18
  */
19
- import { toRepoRelative } from "../paths.js";
19
+ import { toRepoRelative, fromCwd } from "../paths.js";
20
20
  import { ownersOf, keyHolders } from "../owners.js";
21
+ import { decideHandoff, nextStep, chainFromEnv } from "../handoff.js";
21
22
  import { twinsOf, whereIs } from "../worktree.js";
22
23
  import { C, out } from "../render.js";
23
24
 
@@ -25,7 +26,8 @@ export function whose(config, argv = []) {
25
26
  const target = argv[0];
26
27
  if (!target) throw new Error("usage: seisin whose <path>");
27
28
 
28
- const rel = toRepoRelative(config, target);
29
+ // Relative to where you stand, like explain — see fromCwd.
30
+ const rel = toRepoRelative(config, fromCwd(config, target));
29
31
  const isKey = (config.keyDirs ?? []).some((d) => rel === d || rel.startsWith(d + "/"));
30
32
  const owners = isKey ? keyHolders(config, rel) : ownersOf(config, rel);
31
33
 
@@ -51,9 +53,9 @@ export function whose(config, argv = []) {
51
53
  out(
52
54
  `\n ${C.b}${rel}${C.off} ${verb} ${C.b}${owners.join(", ")}${C.off}\n` +
53
55
  (mine
54
- ? ` ${C.green}that is you${C.off}${C.dim} — if it was refused, the reason is not ownership.${C.off}\n\n`
56
+ ? ` ${C.green}that is you${C.off}${C.dim} — if it was denied, the reason is not ownership.${C.off}\n\n`
55
57
  : asking
56
- ? ` ${C.dim}you are ${asking}. Hand it over rather than working around it.${C.off}\n\n`
58
+ ? ` ${C.dim}you are ${asking}. ${nextStep(decideHandoff({ request: { role: asking, action: isKey ? "read" : "write", target: rel }, config, chain: chainFromEnv() }), asking)}${C.off}\n\n`
57
59
  : "\n")
58
60
  );
59
61
  for (const w of worktree) out(renderElsewhere(w, asking));
@@ -64,7 +66,7 @@ export function whose(config, argv = []) {
64
66
  * The same file in the other checkouts of this repo, when it is owned
65
67
  * differently there.
66
68
  *
67
- * This is the line the EPERM was missing. An agent refused inside a worktree
69
+ * This is the line the EPERM was missing. An agent denied inside a worktree
68
70
  * asks whose the file is, hears "nobody", and concludes the map has a hole —
69
71
  * when the map is fine and names the canonical checkout. A twin owned exactly
70
72
  * the same way says nothing new and gets no line.
@@ -13,18 +13,76 @@
13
13
  * config: a permission tool that edits your machine's settings to install
14
14
  * itself has misread the room.
15
15
  */
16
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
- import { join } from "node:path";
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
17
+ import { join, dirname, delimiter } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawnSync } from "node:child_process";
18
20
  import { C, out } from "../render.js";
21
+ import { TOOL_MATCHER } from "../hook.js";
19
22
 
20
23
  const SETTINGS = join(".claude", "settings.json");
24
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
21
25
 
22
- /** Where the PreToolUse entry lives, and what it has to say. */
23
- export function hookEntry(command = "seisin hook") {
24
- return {
25
- matcher: "*",
26
- hooks: [{ type: "command", command }],
27
- };
26
+ /** A hook command that is seisin's, however it was spelled when it was wired. */
27
+ const isOurs = (c) => typeof c === "string" && /\bhook"?\s*$/.test(c) && (c.includes("seisin") || runsSeisinScript(c));
28
+
29
+ /**
30
+ * A hook written by its full path (`"<node>" "<…>/src/cli.js" hook`) names no
31
+ * `seisin` when the checkout sits in a folder called something else. It is
32
+ * ours when the script it runs belongs to a package named seisin.
33
+ */
34
+ function runsSeisinScript(command) {
35
+ const script = /"([^"]+\.js)"\s+hook\s*$/.exec(command)?.[1];
36
+ if (!script) return false;
37
+ try { return JSON.parse(readFileSync(join(dirname(script), "..", "package.json"), "utf8")).name === "seisin"; } catch { return false; }
38
+ }
39
+
40
+ /**
41
+ * The command that reaches THIS seisin from a hook, and how it was found.
42
+ *
43
+ * `seisin hook` was written for everyone. With seisin installed in a project
44
+ * rather than globally there is no `seisin` on PATH, the hook exits 127, and
45
+ * Claude Code carries on without it — nothing on screen, nothing in the log.
46
+ * So: the one on PATH when it is this version; the project's own
47
+ * node_modules/.bin when there is one; otherwise this file, by absolute path.
48
+ */
49
+ export function hookCommand(root, { path = process.env.PATH, cli = CLI, version = ownVersion() } = {}) {
50
+ const found = (path ?? "").split(delimiter).map((d) => d && join(d, "seisin")).find((p) => p && existsSync(p));
51
+ let onPath = null;
52
+ if (found) {
53
+ const same = realOrSelf(found) === realOrSelf(cli);
54
+ const v = same ? version : spawnSync(found, ["--version"], { encoding: "utf8", timeout: 5000 }).stdout?.trim();
55
+ onPath = { at: found, version: v };
56
+ if (v === version) return { command: "seisin hook", via: "path", onPath };
57
+ }
58
+ const local = join(root, "node_modules", ".bin", "seisin");
59
+ if (existsSync(local)) return { command: '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/seisin hook', via: "local", onPath };
60
+ return { command: `"${process.execPath}" "${realOrSelf(cli)}" hook`, via: "absolute", onPath };
61
+ }
62
+
63
+ function ownVersion() {
64
+ try { return JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version; } catch { return null; }
65
+ }
66
+
67
+ /** Is `dir` inside a git checkout? Kept here so wire does not load the scanner. */
68
+ function insideRepo(dir) {
69
+ for (let d = dir; ; d = dirname(d)) {
70
+ if (existsSync(join(d, ".git"))) return true;
71
+ if (dirname(d) === d) return false;
72
+ }
73
+ }
74
+
75
+ function realOrSelf(p) {
76
+ try { return realpathSync(p); } catch { return p; }
77
+ }
78
+
79
+ /** One sentence: which seisin the hook runs, and why that one. */
80
+ function saysWhich(h) {
81
+ const other = h.onPath && h.onPath.version !== ownVersion()
82
+ ? ` (the seisin on your PATH is ${h.onPath.version || "another version"}, not ${ownVersion()})` : "";
83
+ if (h.via === "path") return `the hook runs "seisin hook" — seisin ${ownVersion()} is on your PATH`;
84
+ if (h.via === "local") return `the hook runs this project's node_modules/.bin/seisin${other || " — seisin is not on your PATH"}`;
85
+ return `the hook runs this seisin by its full path${other || " — seisin is not on your PATH"}`;
28
86
  }
29
87
 
30
88
  /**
@@ -39,7 +97,7 @@ export function hookEntry(command = "seisin hook") {
39
97
  export function hookEntries(command = "seisin hook") {
40
98
  const h = [{ type: "command", command }];
41
99
  return {
42
- PreToolUse: [{ matcher: "*", hooks: h }],
100
+ PreToolUse: [{ matcher: TOOL_MATCHER, hooks: h }],
43
101
  PostToolUse: [{ matcher: "Bash", hooks: h }],
44
102
  PostToolUseFailure: [{ matcher: "*", hooks: h }],
45
103
  SessionStart: [{ matcher: "startup|resume|compact", hooks: h }],
@@ -55,14 +113,59 @@ export function wired(root) {
55
113
  // session-start hooks existed has only PreToolUse, and "already wired" there
56
114
  // would keep it from ever getting the rest.
57
115
  const hooks = JSON.parse(readFileSync(file, "utf8")).hooks ?? {};
58
- return Object.keys(hookEntries()).every((ev) => JSON.stringify(hooks[ev] ?? []).includes("seisin hook"));
116
+ return Object.keys(hookEntries()).every((ev) =>
117
+ (hooks[ev] ?? []).some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command))));
59
118
  } catch {
60
119
  return false; // unreadable settings is not "wired"
61
120
  }
62
121
  }
63
122
 
123
+ /**
124
+ * Narrows seisin's own `"*"` PreToolUse entry to {@link TOOL_MATCHER}. True if
125
+ * it changed anything.
126
+ *
127
+ * Only an entry that holds nothing but `seisin hook`: a `"*"` entry that also
128
+ * runs somebody else's command is theirs as much as ours, and narrowing it
129
+ * would silently stop their hook on every other tool.
130
+ */
131
+ function narrow(settings) {
132
+ let changed = false;
133
+ for (const entry of settings.hooks?.PreToolUse ?? []) {
134
+ if (broadOurs(entry)) {
135
+ entry.matcher = TOOL_MATCHER;
136
+ changed = true;
137
+ }
138
+ }
139
+ return changed;
140
+ }
141
+
142
+ /** A PreToolUse entry that runs only `seisin hook`, on every tool. */
143
+ function broadOurs(entry) {
144
+ const ours = Array.isArray(entry?.hooks) && entry.hooks.length > 0 &&
145
+ entry.hooks.every((h) => isOurs(h?.command));
146
+ return ours && (entry.matcher === "*" || entry.matcher === "" || entry.matcher === undefined);
147
+ }
148
+
149
+ /**
150
+ * Does this repo still run the hook on every tool call — the `"*"` matcher that
151
+ * versions before 0.5.0 installed? Correct, just slow: every Read, Glob and
152
+ * TodoWrite pays for a process start that answers nothing. `seisin wire` narrows
153
+ * it in place; this is how `check` knows to say so.
154
+ */
155
+ export function broadlyWired(root) {
156
+ const file = join(root, SETTINGS);
157
+ if (!existsSync(file)) return false;
158
+ try {
159
+ const pre = JSON.parse(readFileSync(file, "utf8")).hooks?.PreToolUse;
160
+ return Array.isArray(pre) && pre.some(broadOurs);
161
+ } catch {
162
+ return false; // unreadable settings is reported elsewhere, if at all
163
+ }
164
+ }
165
+
64
166
  export function wire(config) {
65
167
  const file = join(config.root, SETTINGS);
168
+ const how = hookCommand(config.root);
66
169
 
67
170
  // Merged, never replaced. Someone's hooks are their own and a tool that
68
171
  // overwrites them to add itself does not get a second chance.
@@ -76,24 +179,60 @@ export function wire(config) {
76
179
  }
77
180
 
78
181
  if (wired(config.root)) {
79
- out(`\n ${C.dim}already wired — ${SETTINGS} runs "seisin hook"${C.off}\n\n`);
182
+ // A bare `seisin hook` where no seisin is on PATH fails on every call
183
+ // without a word. Rewritten in place to the command that works here.
184
+ // Both repairs in one pass: an install from before 0.5.0 on a machine
185
+ // without seisin on PATH needs the command repointed AND the matcher
186
+ // narrowed, and returning after the first left it half-fixed.
187
+ const repointed = how.via !== "path" && repoint(settings, how.command);
188
+ // Wired before the matcher was narrowed: still correct, just slow, so
189
+ // `wired()` keeps saying yes and nobody is told to redo anything. Running
190
+ // `wire` again is the way to pick up the narrower one, in place.
191
+ const narrowed = narrow(settings);
192
+ if (repointed || narrowed) {
193
+ writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
194
+ if (repointed)
195
+ out(`\n ${C.green}repointed${C.off} ${SETTINGS}\n ${C.dim}"seisin hook" is not on your PATH, so it never ran. ${saysWhich(how)}.${C.off}\n\n`);
196
+ if (narrowed)
197
+ out(`\n ${C.green}narrowed${C.off} ${SETTINGS}\n` +
198
+ ` ${C.dim}PreToolUse now runs seisin's hook only for the tools it reads, not for every tool${C.off}\n\n`);
199
+ return { changed: true, file, ...(repointed && { how }) };
200
+ }
201
+ out(`\n ${C.dim}already wired — ${SETTINGS} runs seisin's hook${C.off}\n\n`);
80
202
  return { changed: false, file };
81
203
  }
82
204
 
83
205
  settings.hooks ??= {};
84
- for (const [ev, entries] of Object.entries(hookEntries())) {
206
+ for (const [ev, entries] of Object.entries(hookEntries(how.command))) {
85
207
  settings.hooks[ev] ??= [];
86
- if (!JSON.stringify(settings.hooks[ev]).includes("seisin hook")) settings.hooks[ev].push(...entries);
208
+ if (!settings.hooks[ev].some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command)))) settings.hooks[ev].push(...entries);
87
209
  }
210
+ narrow(settings); // an older PreToolUse entry that was kept
88
211
 
89
212
  mkdirSync(join(config.root, ".claude"), { recursive: true });
90
213
  writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
91
214
 
92
215
  out(
93
216
  `\n ${C.green}wired${C.off} ${SETTINGS}\n` +
94
- ` ${C.dim}the agent now runs "seisin hook" before each tool call, which is what fills${C.off}\n` +
95
- ` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n\n` +
96
- ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
217
+ ` ${C.dim}the agent now runs seisin's hook before each tool call, which is what fills${C.off}\n` +
218
+ ` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n` +
219
+ ` ${C.dim}${saysWhich(how)}.${C.off}\n\n` +
220
+ // Only in a repository: outside one there is nothing to commit it to.
221
+ (insideRepo(config.root)
222
+ ? how.via === "absolute"
223
+ ? ` ${C.dim}The command names a path on this machine; teammates should run seisin wire themselves.${C.off}\n\n`
224
+ : ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
225
+ : "")
97
226
  );
98
- return { changed: true, file };
227
+ return { changed: true, file, how };
228
+ }
229
+
230
+ /** Replaces a bare `seisin hook` command with `command`. True if it changed anything. */
231
+ function repoint(settings, command) {
232
+ let changed = false;
233
+ for (const entries of Object.values(settings.hooks ?? {}))
234
+ for (const e of Array.isArray(entries) ? entries : [])
235
+ for (const h of e?.hooks ?? [])
236
+ if (h?.command === "seisin hook") { h.command = command; changed = true; }
237
+ return changed;
99
238
  }