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.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- 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 +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- 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 +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/commands/scan.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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;
|
package/src/commands/ui.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ${
|
|
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
|
+
}
|
package/src/commands/walls.js
CHANGED
|
@@ -1,30 +1,81 @@
|
|
|
1
|
-
/** `seisin walls <role>` — what that role keeps being
|
|
2
|
-
import {
|
|
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 {
|
|
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 <
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
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
|
|
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
|
}
|
package/src/commands/whose.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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}.
|
|
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
|
|
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.
|
package/src/commands/wire.js
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
95
|
-
` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n
|
|
96
|
-
` ${C.dim}
|
|
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
|
}
|