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.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- 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
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
15
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
54
|
-
if (!
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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) {
|