cortad 0.3.1-rc.9 → 0.3.2

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 (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/lib/runner.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // The one process holding a project's app up, and the app's state as that process last wrote it.
2
+ // ~/.cortad/<project>/runner.json is both the lock and the record:
3
+ // { pid, startedAt, by, version, state: "starting" | "up" | "failed", at, port?, host?, app?, error? }
4
+ // It exists only while its runner lives, so "stopped" is the absence of one. The runner is the only
5
+ // writer; every reader takes the app's state from here and never works it out again.
6
+ import { execFileSync } from "node:child_process";
7
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homeOf } from "./home.mjs";
10
+ import { elapsedMs } from "./proc.mjs";
11
+
12
+ const fileOf = (project, base) => join(homeOf(project, base), "runner.json");
13
+ const read = (file) => { try { const r = JSON.parse(readFileSync(file, "utf8")); return Number.isInteger(r?.pid) ? r : null; } catch { return null; } };
14
+ const same = (a, b) => a?.pid === b?.pid && a?.startedAt === b?.startedAt;
15
+
16
+ // When a process started, to the second: with the pid, what tells a runner from a later process that
17
+ // was handed the same pid.
18
+ export function startedAtOf(pid = process.pid) {
19
+ if (pid === process.pid) return new Date(Date.now() - process.uptime() * 1000).toISOString();
20
+ try { return new Date(Date.now() - elapsedMs(execFileSync("ps", ["-o", "etime=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim())).toISOString(); } catch { return null; }
21
+ }
22
+
23
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
24
+ // The pid is alive and is still the process that wrote the record; ps rounds to the second.
25
+ // ponytail: a machine with no ps is taken at the pid alone, as before this check existed.
26
+ function isLive(runner) {
27
+ if (!alive(runner.pid)) return false;
28
+ if (runner.pid === process.pid) return true;
29
+ const now = startedAtOf(runner.pid);
30
+ return now === null || Math.abs(Date.parse(now) - Date.parse(runner.startedAt)) < 3000;
31
+ }
32
+
33
+ export function readRunner(project, base) {
34
+ const runner = read(fileOf(project, base));
35
+ return runner && isLive(runner) ? runner : null;
36
+ }
37
+
38
+ // Takes the lock for this process and returns null, or returns the live runner that holds it. A
39
+ // record left by a runner that has ended is taken over. The record is written whole and then linked
40
+ // into place, which fails if the file exists: two processes asking at once never both get it.
41
+ // ponytail: two processes finding the same stale record in the same millisecond can race on its
42
+ // removal; each re-reads it first, which leaves a window of one file operation.
43
+ export function claimRunner(project, record, base) {
44
+ const file = fileOf(project, base);
45
+ const mine = `${file}.${record.pid}`;
46
+ mkdirSync(homeOf(project, base), { recursive: true, mode: 0o700 });
47
+ writeFileSync(mine, JSON.stringify(record), { mode: 0o600 });
48
+ try {
49
+ for (let tries = 0; tries < 5; tries++) {
50
+ try { linkSync(mine, file); return null; } catch (e) { if (e.code !== "EEXIST") throw e; }
51
+ const held = read(file);
52
+ if (held && isLive(held)) return held;
53
+ if (same(read(file), held)) { try { rmSync(file); } catch { /* taken first; asked again */ } }
54
+ }
55
+ throw new Error("could not take this project's lock: another process keeps rewriting it");
56
+ } finally { rmSync(mine, { force: true }); }
57
+ }
58
+
59
+ // The runner's own record, rewritten whole with each change of state and renamed into place, so a
60
+ // reader never sees half of it.
61
+ export function writeRunner(project, record, base) {
62
+ const file = fileOf(project, base);
63
+ writeFileSync(`${file}.${record.pid}`, JSON.stringify(record), { mode: 0o600 });
64
+ renameSync(`${file}.${record.pid}`, file);
65
+ }
66
+
67
+ // Removed only by the process that holds it: a runner ending late never takes its successor's lock.
68
+ export function releaseRunner(project, base, pid = process.pid) {
69
+ if (read(fileOf(project, base))?.pid === pid) rmSync(fileOf(project, base), { force: true });
70
+ }
71
+
72
+ const until = async (done, ms) => { for (const end = Date.now() + ms; !done() && Date.now() < end;) await new Promise((r) => setTimeout(r, 100)); return done(); };
73
+
74
+ // Ends the runner holding this project: asked, then made to, and its app waited out, so the next
75
+ // start never meets the old app on its port. A runner killed outright has its app stopped by the
76
+ // watchdog lib/proc.mjs ties to it.
77
+ export async function replaceRunner(held, { askMs = 15_000, appMs = 10_000 } = {}) {
78
+ try { process.kill(held.pid, "SIGTERM"); } catch { /* ended on its own */ }
79
+ if (!(await until(() => !alive(held.pid), askMs))) {
80
+ try { process.kill(held.pid, "SIGKILL"); } catch { /* ended */ }
81
+ await until(() => !alive(held.pid), 2000);
82
+ }
83
+ if (Number.isInteger(held.app)) await until(() => !alive(held.app), appMs);
84
+ return !alive(held.pid);
85
+ }
package/lib/service.mjs CHANGED
@@ -3,11 +3,36 @@
3
3
  // apps/api. A recipe that names such a service (src/setup/road-identities.ts) is minted against
4
4
  // that service instead, so nobody is asked to send a message by hand. Cookies set on localhost
5
5
  // apply to every port, which is what makes the session work on the AI service after.
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { relative, join } from "node:path";
7
8
  import { startPlan } from "./start.mjs";
8
9
 
9
10
  export const serviceBase = (port) => `http://127.0.0.1:${port}`;
10
11
 
12
+ const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
13
+ // The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
14
+ // vite with them and neither uses its port.
15
+ const FRAMEWORK_PORT = [["next", 3000], ["nuxt", 3000], ["@remix-run/serve", 3000], ["@remix-run/dev", 3000], ["@sveltejs/kit", 5173], ["astro", 4321], ["vite", 5173]];
16
+ // No scan of the usual ports: whatever answers on 3000 or 8080 on this machine is very often not
17
+ // this workspace, and a test account made against somebody else's service is the worst kind of
18
+ // wrong. Only the port this workspace itself names, and if nothing is there, it is started.
19
+
20
+ // What a workspace serves on: its own script says so, or the framework it is written in does.
21
+ export function servicePort(cwd) {
22
+ let pkg = null;
23
+ try { pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { /* not a Node workspace */ }
24
+ const script = ["dev", "develop", "start:dev", "serve", "start"].map((s) => pkg?.scripts?.[s]).find(Boolean) ?? "";
25
+ const named = Number(PORT_IN_SCRIPT.exec(script)?.[1]);
26
+ if (named) return named;
27
+ const deps = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
28
+ const framework = FRAMEWORK_PORT.find(([dep]) => deps.includes(dep))?.[1];
29
+ if (framework) return framework;
30
+ if (existsSync(join(cwd, "manage.py"))) return 8000;
31
+ if (existsSync(join(cwd, "Gemfile"))) return 3000;
32
+ const py = ["requirements.txt", "pyproject.toml"].map((f) => { try { return readFileSync(join(cwd, f), "utf8"); } catch { return ""; } }).join("\n");
33
+ return /\bdjango\b/i.test(py) ? 8000 : /\bflask\b/i.test(py) ? 5000 : null;
34
+ }
35
+
11
36
  // The recipes to mint against a service other than the running app, grouped by that service's
12
37
  // workspace, each with how it starts itself. A recipe whose service is the app's own dir stays with
13
38
  // the app and is not returned here.
package/lib/spec.mjs CHANGED
@@ -3,14 +3,17 @@ import { isAbsolute } from "node:path";
3
3
 
4
4
  // Which cortad the coding agents on this machine start, and the skill and stick name: the
5
5
  // CORTAD_CLI_SPEC the connect ran with (cortad@next, or an absolute folder for an unpublished
6
- // build), else cortad@next while this package is a prerelease, else cortad@latest. The spec ends up
7
- // in a shell line in the repository (lib/stick.mjs), so anything else in the variable is ignored.
6
+ // build), else this very version: under cortad@next the agents ran rc.15 while the connector they
7
+ // talked to was the rc.14 the connect command pinned. The spec ends up in a shell line in the
8
+ // repository (lib/stick.mjs), so anything else in the variable is ignored.
8
9
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
9
- const SAFE = /^(?:cortad(?:@[A-Za-z0-9._-]+)?|\/[A-Za-z0-9._/-]+)$/;
10
+ // Also how lib/stick.mjs tells a line of its own from another tool's.
11
+ export const SPEC = "(?:cortad(?:@[A-Za-z0-9._-]+)?|/[A-Za-z0-9._/-]+)";
12
+ const SAFE = new RegExp(`^${SPEC}$`);
10
13
 
11
14
  export function cliSpec({ env = process.env, version = VERSION } = {}) {
12
15
  if (env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC)) return env.CORTAD_CLI_SPEC;
13
- return version.includes("-") ? "cortad@next" : "cortad@latest";
16
+ return `cortad@${version}`;
14
17
  }
15
18
 
16
19
  // npx needs -y to fetch a package without asking; a local folder is not fetched.
package/lib/start.mjs CHANGED
@@ -119,6 +119,8 @@ function pythonStart(dir, onPath) {
119
119
  const BOUND_APP = /^(app|application)\s*=\s*[\w.]+\(/m;
120
120
  const IMPORTED_APP = /^from\s+[\w.]+\s+import\s+(?:\(\s*[^)]*?\b(app|application)\b[^)]*\)|[^\n(]*\b(app|application)\b)/m;
121
121
  const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py", "Pipfile", "uv.lock"];
122
+ // A folder whose app the hooks can load into: Node, Bun or Python. Any other is seen through lib/proxy.mjs.
123
+ export const hookable = (dir) => KNOWN.some((f) => existsSync(join(dir, f)));
122
124
 
123
125
  const startOf = (dir, onPath) => nodeStart(dir, onPath) ?? pythonStart(dir, onPath);
124
126
 
@@ -132,6 +134,32 @@ function weight(dir, name) {
132
134
  return (model ? 4 : 0) + (server ? 2 : 0) + (/back|api|server|service/i.test(name) ? 1 : 0) - (!model && /front|web|client|ui|mobile|docs|site|landing|admin/i.test(name) ? 3 : 0);
133
135
  }
134
136
 
137
+ // A folder whose start only bundles a page for the browser: the AI is never behind it, whatever it
138
+ // imports (wecom-sales-agent's console/ imports hono for its client's types). A framework that also
139
+ // serves routes is not a page.
140
+ const PAGE_BUNDLER = /\b(?:vite|webpack(?:-dev-server)?|react-scripts|parcel|rsbuild|storybook)\b/;
141
+ const FULLSTACK = /^(?:next|nuxt|@remix-run\/.+|@react-router\/dev|@sveltejs\/kit|astro|@solidjs\/start|@tanstack\/(?:react-)?start|vinxi|@analogjs\/platform|@builder\.io\/qwik-city)$/;
142
+ function pageOnly(dir) {
143
+ const pkg = json(join(dir, "package.json"));
144
+ if (!pkg?.scripts) return false;
145
+ const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
146
+ const command = String(pkg.scripts[serverScript(pkg.scripts, deps)] ?? "");
147
+ return PAGE_BUNDLER.test(command) && !RUNS_SERVER.test(command.replace(PAGE_BUNDLER, "")) && !deps.some((d) => FULLSTACK.test(d));
148
+ }
149
+ const weighed = (dir, name) => weight(dir, name) - (pageOnly(dir) ? 4 : 0);
150
+
151
+ // A monorepo root that serves the AI itself: its start runs a file of its own, not one inside a
152
+ // workspace or a helper under scripts/. A root's dependencies are often every workspace's, hoisted,
153
+ // so they say nothing about whether the root serves: TavernHeadless's root lists fastify and the AI
154
+ // SDK and its dev only runs a menu script.
155
+ function servesItself(root, members) {
156
+ const pkg = json(join(root, "package.json"));
157
+ const deps = Object.keys({ ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) });
158
+ const command = String(pkg?.scripts?.[serverScript(pkg?.scripts, deps)] ?? "");
159
+ return [...command.matchAll(/(?:^|\s)(?:\.\/)?([\w./-]+\.(?:[cm]?[jt]s|py))\b/g)].map((m) => join(root, m[1]))
160
+ .some((file) => existsSync(file) && !/\/(?:scripts|tools|bin)\//.test(file.slice(root.length)) && !members.some((dir) => file.startsWith(`${dir}/`)));
161
+ }
162
+
135
163
  const SKIP = /^(?:node_modules|\.git|dist|build|\.next|coverage|\.venv|venv|__pycache__|\.turbo|\.cache)$/;
136
164
  export function workspaces(root) {
137
165
  const found = [];
@@ -160,9 +188,12 @@ export function startPlan({ root, typed, onPath }) {
160
188
  const pythonFirst = Boolean(rootPkg) && PY_MODEL.test(rootPy) && !Object.keys({ ...(rootPkg.dependencies ?? {}), ...(rootPkg.devDependencies ?? {}) }).some((d) => MODEL_SDK.test(d));
161
189
  const own = mono ? null : pythonFirst ? (pythonStart(root, onPath) ?? nodeStart(root, onPath)) : startOf(root, onPath);
162
190
  if (own) return { cmd: own.cmd, cwd: root };
163
- const ranked = workspaces(root).map((dir) => ({ dir, start: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.start).sort((a, b) => b.weight - a.weight);
191
+ // A monorepo root can serve the AI itself (wecom-sales-agent's root runs src/server.ts), so it is
192
+ // weighed beside its workspaces.
193
+ const members = workspaces(root);
194
+ const ranked = [...(mono && servesItself(root, members) ? [root] : []), ...members].map((dir) => ({ dir, start: startOf(dir, onPath), weight: weighed(dir, relative(root, dir)) })).filter((w) => w.start).sort((a, b) => b.weight - a.weight);
164
195
  const best = ranked[0];
165
- const at = (w) => ({ cmd: w.start.cmd, cwd: w.dir, within: relative(root, w.dir) });
196
+ const at = (w) => ({ cmd: w.start.cmd, cwd: w.dir, ...(w.dir === root ? {} : { within: relative(root, w.dir) }) });
166
197
  if (best && best.weight > 0 && (ranked.length === 1 || best.weight > ranked[1].weight)) return at(best);
167
198
  // A root that does have a start of its own after all (a monorepo whose root script runs everything).
168
199
  const rootStart = startOf(root, onPath);
package/lib/stick.mjs CHANGED
@@ -1,47 +1,81 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve, sep } from "node:path";
3
- import { cliSpec, npxName } from "./spec.mjs";
3
+ import { cliSpec, npxName, SPEC } from "./spec.mjs";
4
4
 
5
5
  // `npx cortad stick`: one line in each coding agent's own instructions file in this repository, and
6
6
  // a hook in the two clients that run one after an edit. `unstick` takes out exactly what stick put
7
7
  // in. Both are idempotent, print what they changed, and never touch git.
8
8
  //
9
- // The hook command carries --hook: Claude Code and Codex both drop plain stdout from a PostToolUse
10
- // hook, and read only hookSpecificOutput.additionalContext. The command follows lib/spec.mjs, and
11
- // unstick knows the line and the hook under any spec.
9
+ // Two hooks: after an edit, the changed prompt and tool files and any news; on the person's next
10
+ // prompt, the news alone (a run that ended while the agent was not listening), with no call to the
11
+ // server. Both carry --hook: Claude Code and Codex drop plain stdout from a hook and read only
12
+ // hookSpecificOutput.additionalContext. The commands follow lib/spec.mjs. A hook is ours when npx
13
+ // runs a package named cortad, at any version, or the very spec this command runs under: another
14
+ // tool started from a folder (`npx /opt/acme/notes news --hook`) is never taken for ours.
12
15
  export const lineFor = (name) => `After changing prompts, tools, models or retrieval, check with Cortad before committing: npx ${name} status`;
13
16
  export const hookFor = (name) => `npx ${name} status --changed --hook`;
14
- const IS_LINE = /^After changing prompts, tools, models or retrieval, check with Cortad before committing: npx \S+ status$/;
15
- const IS_HOOK = /^npx \S+ status --changed --hook$/;
17
+ export const newsHookFor = (name) => `npx ${name} news --hook`;
18
+ const IS_LINE = new RegExp(`^After changing prompts, tools, models or retrieval, check with Cortad before committing: npx ${SPEC} status$`);
19
+ const IS_HOOK = /^npx cortad(?:@[A-Za-z0-9._-]+)? (?:status --changed|news) --hook$/;
20
+ const HOOK_EVENTS = ["PostToolUse", "UserPromptSubmit"];
16
21
  const hasLine = (text) => text.split("\n").some((l) => IS_LINE.test(l.trim()));
17
- const ours = (entry) => entry?.hooks?.some((h) => IS_HOOK.test(h.command ?? ""));
22
+ const mineFor = (spec) => {
23
+ const own = new Set([hookFor(npxName(spec)), newsHookFor(npxName(spec))]);
24
+ return (command) => IS_HOOK.test(command ?? "") || own.has(command);
25
+ };
26
+ // A settings file whose hooks are not the lists a client writes is left alone, said as such.
27
+ const SHAPE = Symbol("shape");
28
+ const listsOf = (config) => {
29
+ const hooks = config.hooks ?? {};
30
+ if (typeof hooks !== "object" || Array.isArray(hooks)) return null;
31
+ const lists = HOOK_EVENTS.map((event) => hooks[event] ?? []);
32
+ return lists.every((l) => Array.isArray(l) && l.every((e) => e && typeof e === "object" && (e.hooks === undefined || Array.isArray(e.hooks)))) ? lists : null;
33
+ };
18
34
  const LINE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"];
19
35
  const HOOK_FILES = [".claude/settings.json", ".codex/hooks.json"];
20
36
  const RULE_FILE = ".cursor/rules/cortad.mdc";
21
37
 
22
- export const hookOutput = (text) => JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } });
38
+ export const hookOutput = (text, event = "PostToolUse") => JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } });
23
39
 
24
40
  export function stick(root, { spec = cliSpec() } = {}) {
25
41
  const line = lineFor(npxName(spec));
26
42
  const hook = hookFor(npxName(spec));
43
+ const news = newsHookFor(npxName(spec));
44
+ const mine = mineFor(spec);
45
+ const ours = (entry) => (entry.hooks ?? []).some((h) => mine(h.command));
27
46
  const rule = `---\ndescription: Checking AI behavior with Cortad\nalwaysApply: true\n---\n${line}\n`;
28
47
  const said = LINE_FILES.map((rel, i) => write(root, rel, (text) => {
29
48
  if (hasLine(text)) return null;
30
49
  return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${line}\n`;
31
50
  }, i === 0 ? `added "${line}"` : "added the same line"));
32
51
  said.push(write(root, RULE_FILE, (text) => (hasLine(text) ? null : rule), "written, with the same line, applied always"));
33
- said.push(...HOOK_FILES.map((rel, i) => write(root, rel, (text) => {
34
- const config = parse(text);
35
- if (config === undefined) return undefined;
36
- const list = config.hooks?.PostToolUse ?? [];
37
- if (list.some(ours)) return null;
38
- config.hooks = { ...config.hooks, PostToolUse: [...list, { matcher: "Edit|Write", hooks: [{ type: "command", command: hook }] }] };
39
- return `${JSON.stringify(config, null, 2)}\n`;
40
- }, i === 0 ? `added a PostToolUse hook on Edit|Write that runs ${hook}` : "added the same hook")));
52
+ said.push(...HOOK_FILES.map((rel) => {
53
+ let added = [];
54
+ return write(root, rel, (text) => {
55
+ const config = parse(text);
56
+ if (config === undefined) return undefined;
57
+ const lists = listsOf(config);
58
+ if (!lists) return SHAPE;
59
+ const [edits, prompts] = lists;
60
+ added = [
61
+ ...(edits.some(ours) ? [] : [`a PostToolUse hook on Edit|Write that runs ${hook}`]),
62
+ ...(prompts.some(ours) ? [] : [`a UserPromptSubmit hook that runs ${news}`]),
63
+ ];
64
+ if (!added.length) return null;
65
+ config.hooks = {
66
+ ...config.hooks,
67
+ PostToolUse: edits.some(ours) ? edits : [...edits, { matcher: "Edit|Write", hooks: [{ type: "command", command: hook }] }],
68
+ UserPromptSubmit: prompts.some(ours) ? prompts : [...prompts, { hooks: [{ type: "command", command: news }] }],
69
+ };
70
+ return `${JSON.stringify(config, null, 2)}\n`;
71
+ }, () => `added ${added.join(", and ")}`);
72
+ }));
41
73
  return said;
42
74
  }
43
75
 
44
- export function unstick(root) {
76
+ export function unstick(root, { spec = cliSpec() } = {}) {
77
+ const mine = mineFor(spec);
78
+ const ours = (entry) => (entry.hooks ?? []).some((h) => mine(h.command));
45
79
  const said = LINE_FILES.map((rel) => write(root, rel, (text) => {
46
80
  if (!hasLine(text)) return null;
47
81
  return text.split("\n").filter((l) => !IS_LINE.test(l.trim())).join("\n").replace(/\n+$/, "\n");
@@ -50,14 +84,19 @@ export function unstick(root) {
50
84
  said.push(...HOOK_FILES.map((rel) => write(root, rel, (text) => {
51
85
  const config = parse(text);
52
86
  if (config === undefined) return undefined;
53
- const list = config.hooks?.PostToolUse;
54
- if (!list?.some(ours)) return null;
55
- const kept = list.map((e) => ({ ...e, hooks: (e.hooks ?? []).filter((h) => !IS_HOOK.test(h.command ?? "")) })).filter((e) => e.hooks.length);
56
- if (kept.length) config.hooks.PostToolUse = kept;
57
- else delete config.hooks.PostToolUse;
87
+ const lists = listsOf(config);
88
+ if (!lists) return SHAPE;
89
+ if (!lists.some((list) => list.some(ours))) return null;
90
+ for (const event of HOOK_EVENTS) {
91
+ const list = config.hooks?.[event];
92
+ if (!list?.some(ours)) continue;
93
+ const kept = list.map((e) => (ours(e) ? { ...e, hooks: e.hooks.filter((h) => !mine(h.command)) } : e)).filter((e) => !Array.isArray(e.hooks) || e.hooks.length);
94
+ if (kept.length) config.hooks[event] = kept;
95
+ else delete config.hooks[event];
96
+ }
58
97
  if (!Object.keys(config.hooks).length) delete config.hooks;
59
98
  return Object.keys(config).length ? `${JSON.stringify(config, null, 2)}\n` : "";
60
- }, "removed the hook")));
99
+ }, "removed the hooks")));
61
100
  return said;
62
101
  }
63
102
 
@@ -70,15 +109,16 @@ function write(root, rel, change, done) {
70
109
  const text = existsSync(file) ? readFileSync(file, "utf8") : "";
71
110
  const next = change(text);
72
111
  if (next === undefined) return `${rel}: left alone, it is not valid JSON`;
112
+ if (next === SHAPE) return `${rel}: left alone, its hooks are not the lists a client writes`;
73
113
  if (next === null) return `${rel}: nothing to change`;
74
114
  if (next.trim() === "") {
75
115
  rmSync(file);
76
116
  for (let dir = dirname(file); dir.startsWith(`${resolve(root)}${sep}`) && !readdirSync(dir).length; dir = dirname(dir)) rmdirSync(dir);
77
- return `${rel}: removed, it held only what stick wrote`;
117
+ return `${rel}: removed, it had only what stick wrote`;
78
118
  }
79
119
  mkdirSync(dirname(file), { recursive: true });
80
120
  writeFileSync(file, next);
81
- return `${rel}: ${done}`;
121
+ return `${rel}: ${typeof done === "function" ? done() : done}`;
82
122
  }
83
123
 
84
124
  function inside(root, file) {