Tool policies, workspace edit formatting, Nx diagnostics, captures, service status, and optional event records and finding delivery

Rasm is a polyglot monorepo with macOS-first development and portable code and tooling for Linux and Windows.
Rasm/
├── apps/ # One directory per app or group of related apps
├── libs/ # Packages, one directory per language
│ ├── dotnet/
│ ├── python/
│ └── typescript/
├── tests/ # Shared test support per language and suites outside libs/
├── eng/
│ ├── dotnet/
│ ├── python/
│ └── typescript/
├── infra/ # Pulumi program declaring repository resources
├── tools/
│ ├── ast-grep/ # Outlines, rules, and utilities per language
│ ├── bridge/ # Streamable HTTP bridge every stdio MCP server's launchd agent runs through
│ └── nx/ # Nx plugin inferring a project from each project file
├── plugins/ # Agent harness marketplace, one directory per plugin
├── mise.toml # Tool binaries and process environment
├── global.json # .NET SDK versions
├── nx.json # Task graph
├── package.json # Catalog rows except tool plugins, root Nx targets
├── pnpm-workspace.yaml # TypeScript workspace globs and dependency catalog
├── pyproject.toml # Python wheel project, dependency groups, and tool tables
├── Directory.Packages.props # .NET central package versions
├── Directory.Build.props # .NET build defaults and project classification by tree position
├── Directory.Build.targets # .NET items, host package references, and build targets
├── NuGet.config # NuGet source and package folder
├── Workspace.slnx # .NET solution
├── Xcode.xcconfig # Build settings every Xcode project inherits at project level
├── tsconfig.base.json # Compiler options every TypeScript project extends
├── tsconfig.json # Root TypeScript project over files outside every package
├── vitest.config.ts # Test and coverage options every project config imports
├── vite.config.ts # Bundling options every UXP build target runs from its project directory
├── biome.json # TypeScript and JSON formatting and lint
├── pmd.xml # Java lint rules
├── sgconfig.yml # ast-grep rule directories and language parsing
├── .editorconfig # Editor settings and .NET analyzer severity
├── .swift-format # Swift lint and format rules
├── .swiftlint.yml # Swift lint rules swift-format lacks
├── .lldbinit # LLDB MCP server start every Xcode scheme's Run loads
├── .yamllint.yaml, .yamlfmt # YAML lint and format
├── .github/ # Continuous integration and repository workflows
├── .claude/ # Agent harness knowledge and settings
├── .mcp.json # Agent harness MCP servers
├── .codex/ # Codex harness knowledge and settings
├── CLAUDE.md # Agent standards, AGENTS.md is its symlink
└── README.md
flowchart LR
subgraph toolchain ["Toolchain"]
direction TB
mise_tools["mise.toml [tools], global.json"] --> binaries["Tool binaries"]
mise_env["mise.toml [env]"] --> processes["Every process"]
xcode["xcode-select"] --> apple_tools["Xcode toolchain and macOS SDK"]
brew["Homebrew formula ghidra"] --> ghidra_tool["Ghidra install"]
end
subgraph dependencies ["Dependencies"]
direction TB
catalog_ts["pnpm-workspace.yaml catalog"] --> lock_ts["pnpm-lock.yaml"]
catalog_py["pyproject.toml dependencies and groups"] --> lock_py["uv.lock, .venv/bin on PATH"]
catalog_net["Directory.Packages.props"] --> restore["rasm:restore"]
catalog_net --> eng_net["eng/dotnet"] --> upgrade["rasm:upgrade"]
catalog_swift[".xcodeproj package requirements"] --> lock_swift["Package.resolved"]
end
subgraph taskgraph ["Task graph"]
direction TB
plugins["nx.json plugins"] --> projects["Project per project file: language and host tags, targets"]
target_defaults["nx.json targetDefaults by language and host tag"] --> bodies["Target body per language and host"]
root_nx["package.json nx"] --> root_targets["Root targets rasm:*"]
end
subgraph commands ["Commands"]
direction TB
lint["nx run rasm:lint"] --> checkers["One cached target per portable checker"]
format_tree["nx run rasm:format"] --> writers["Every portable writer, then dotnet format"]
check_all["nx run-many -t check"] --> project_check["Build, typecheck, or test per project"]
check_affected["nx affected -t check"] --> project_check
ci["ci.yml"] --> setup["setup action"] --> ci_steps["rasm:check, affected check per host runner"]
end
toolchain --> dependencies --> taskgraph --> commands
nx run rasm:check runs every lint:<checker> and typecheck:<checker> root target, nx run rasm:lint:<checker> one checkernx run <project>:<target> runs one target of one projectnx run <project>:build -- <switch> forwards MSBuild switches to a .NET build beside the target's -bl--skip-nx-cache runs a build in place of an Nx cache replaynx run <project>:install installs built products, packing Rhino and Blender projects firstnx run <project>:pack builds a Rhino Yak package or Blender extension.zip under .artifacts/<host>/<project>/nx run rasm:upgrade moves catalogs, Swift package locks, and tool binaries to newest buildsnx run rasm:clean removes declared outputs and tool residue, prunes unused installations and caches, and recovers stale macOS processesnx run rasm:rewrite -- --filter='^<id>$' <path> applies one rule's fix across a pathnx run rasm:outline -- <path> lists a path's declarations, --items selects local, exported, imported, or all items, --view the depth@nx/dotnet and @nx/vitest infer theirsbuild, format, and lint run one <target>:<file> target per entry filenx run rasm:format runs every app's format and nx run rasm:lint:dotnet-format every app's lintvite.config.ts infers build, run from that project, and its serve configuration runs the Vite development serverruntime, outputs name the files it writes.cache/ and .artifacts/, each tool relocated through one setting every run reads, or its skill states why not| [INDEX] | [CONCERN] | [OWNER] |
|---|---|---|
| [01] | Tool binary | mise.toml [tools] at latest, prereleases included |
| [02] | Process variable | mise.toml [env] |
| [03] | SDK version | global.json for .NET, xcode-select for Swift |
| [04] | Package version | pnpm-workspace.yaml catalog, pyproject.toml [project] or group, Directory.Packages.props row |
| [05] | .NET tool package | dotnet dnx <id> on the command |
| [06] | Task graph | nx.json, root package.json nx |
| [07] | Checker configuration | Tool's own file, pyproject.toml [tool.*] for every Python tool |
| [08] | Secret | Doppler, mise.toml [env] exec row for every process, doppler run around one command |
| [09] | Resource or repository setting | Typed row of the program under infra/, applied by nx run rasm:infra:up |
| [10] | Tool with no consumer | Machine setup |
| [11] | Ghidra install | Homebrew formula ghidra, path named in mise.toml [env] |
| [12] | Xcode build setting | Xcode.xcconfig, per-product rows in the .xcodeproj target |
| [13] | Swift package version | .xcodeproj package requirement |
| [14] | Agent harness plugin | plugins/<name> |
| [15] | Local MCP service | mise.toml launchd agent row, applied by mise bootstrap macos launchd-agents apply |
| [16] | MCP tool a skill replaces | --hide <tool> on server's mise.toml launchd agent row |
| [17] | Python runtime version | Root Ruff per-file-target-version for files, nested requires-python for host trees |
latest resolves a development build.gitattributes filters, and CLAUDE.md [CLI_TOOLING] rowsdotnet build and dotnet format style --verify-no-changes at zero findingsruff, ty, and mypy at zero findingsbiome check at zero findings, tsc --build under strict optionsswift-format lint --strict and swiftlint lint at zero findingsgoogle-java-format --aosp and pmd check at zero findingsAvoidAccessibilityAlteration skips use-ghidra Headers.run, Ghidra's PreProcessor takes a DefineTable through package-private field defs aloneyamllint, yamlfmt -lint, actionlint with shellcheck over workflow run steps, and ast-grep rule familiesdotnet format, ruff format, Biome, yamlfmt, google-java-format, swiftlint lint --fix then swift-format per Xcode project.artifacts/dotnet/binlog/<purpose>-{}.binlog for the binlog MCP to read a failed or slow runeng/<language>/ owns engineering workflows over repository projects and artifactstools/ provides capabilities for development tools and applicationsapps/<product>/, with a <host>/ folder per host applicationlibs/<language>/, with host-bound packages under a <host>/ folder<host>/ path folder, Blender hosts from blender_manifest.tomllibs/ packages point down an acyclic graph, each .NET and TypeScript package consumable alone through declared dependenciesrasm wheelfrom rasm.<module> import <member> for private Blender extension relocationrhino folder compile against RhinoCommon, RhinoHost token grasshopper adds Grasshopper2project.json.csproj, .cs files opening with #!, package.json with tsconfig.json, py.typed, blender_manifest.toml, and .xcodeprojWorkspace.slnx lists every project .csproj.xcodeproj basenames name the Nx project, its scheme, and its productsrc/ directory and no folder with one file, folders group by domain per languagev1 folderhooks/register.ts 403 lines1import type { ClassicHookInputs, EngineInterface, Frozen, Next, PluginOptions, ProcessRunInit, Register, ToolCallInput, ToolCallResult } from 'claude-code';
2import { atom, memberOf, read, update } from 'claude-code';
3import { bind, both, decoded, type Fault, fault, fromUndefined, map, none, type Option, ok, type Result, rendered, some } from '../composition.ts';
4import { pointer, touched } from '../context/plan.ts';
5import { type Boundary, delivered, outcome, request, type Spawn } from '../observation/delivery.ts';
6import { CALL, CLASSIC, type Columns, type Event, type Payload, row, TURN, USAGE } from '../observation/row.ts';
7import { BOUNDARY, bound, DELTA, INSERT, JUDGE, OPEN, REPORT, sqlite } from '../observation/sql.ts';
8import { SCAN } from '../policies/command.ts';
9import type { Invocation } from '../policies/invocation.ts';
10import { commandDecision, type Decision, type Host, pathRefusal, worktreeRefusal } from '../policies/policies.ts';
11import { caption, capturePath, recorded } from '../ui/capture.ts';
12import { down, kickstart, LAUNCHD_AGENTS, LISTENERS, remaining, services, UID } from '../ui/health.ts';
13import { band, bandRows, captureRow, resultRow } from '../ui/render.tsx';
14import type { Capture, Notice, Service } from './state.d.ts';
15
16// --- [TYPES] ---------------------------------------------------------------------------
17
18interface Stamped {
19 readonly root: string;
20 readonly ts: number;
21}
22interface Bindings {
23 readonly main: string;
24 readonly worktree: string;
25 readonly branch: string;
26 readonly key: string;
27 readonly session: string;
28 readonly at: number;
29}
30
31// --- [CONSTANTS] -----------------------------------------------------------------------
32
33const _MAIN = 'main';
34const _LOG = 'log';
35
36// --- [STATE] ---------------------------------------------------------------------------
37
38const _DATABASE = atom({ plugin: 'function-hooks', key: 'database' } as const, none);
39const _NOTICE = atom({ plugin: 'function-hooks', key: 'notice' } as const, none);
40const _DOWN = atom({ plugin: 'function-hooks', key: 'down' } as const, []);
41const _EDITS = atom({ plugin: 'function-hooks', key: 'edits' } as const, { format: [], diagnostics: [] });
42const _CAPTURE = atom({ plugin: 'function-hooks', key: 'capture' } as const, none);
43const _PLAN = atom({ plugin: 'function-hooks', key: 'plan' } as const, { path: none, taskFile: none });
44const _SAID = atom({ plugin: 'function-hooks', key: 'said' } as const, []);
45
46// --- [OPERATIONS] ----------------------------------------------------------------------
47
48// --- [HOST]
49
50const _once = async ($: EngineInterface, loop: string, texts: readonly string[]): Promise<readonly string[]> => {
51 if (texts.length === 0) {
52 return [];
53 }
54 let fresh: readonly string[] = [];
55 await update($, memberOf(_SAID, { requestId: loop }), (held) => {
56 fresh = [...new Set(texts)].filter((text) => !held.includes(text));
57 return [...held, ...fresh];
58 });
59 return fresh;
60};
61
62const _result = <T>(pending: Promise<T>, kind: 'unstarted' | 'unread' | 'unwritten', subject: string): Promise<Result<T>> => pending.then(ok, (cause: unknown) => fault<T>({ kind, subject, cause }));
63
64const _faulted = async ($: EngineInterface, line: string, faults: readonly Fault[]): Promise<void> => {
65 (await _once($, _LOG, [`${line}, ${rendered(faults)}`])).forEach((text) => {
66 $.ui.log(text);
67 });
68};
69
70const _run = async ($: EngineInterface, argv: Invocation, init: ProcessRunInit, exits: readonly number[]): Promise<Result<string>> =>
71 bind(await _result($.process.run(argv, init), 'unstarted', argv[0]), ({ exitCode, stdout, stderr }) => (exits.includes(exitCode) ? ok(stdout) : fault<string>({ kind: 'exited', subject: argv[0], code: exitCode, stderr: stderr.trim() })));
72
73const _host = ($: EngineInterface): Host => ({
74 scan: (text) => _run($, SCAN, { stdin: text }, [0]),
75 repo: () => $.session.repo().then((found) => (found === null ? none : some(found.root))),
76 exists: (path) => $.fs.exists(path),
77 real: (path) =>
78 $.fs.stat(path, { resolve: true }).then(
79 ({ realPath }) => fromUndefined(realPath),
80 () => none,
81 ),
82 home: () => $.env.get('HOME').then(fromUndefined),
83});
84
85const _toplevel = async ($: EngineInterface): Promise<Result<string>> => map(await _run($, ['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.root() }, [0]), (printed) => printed.trim());
86
87// --- [NOTICE]
88
89const _cleared = async ($: EngineInterface, at: Option<number>): Promise<void> => {
90 const clears = (held: Option<Notice>): boolean => held.kind === 'some' && (at.kind === 'none' || at.value === held.value.at);
91 await (clears(await read($, _NOTICE)) ? update($, _NOTICE, (held) => (clears(held) ? none : held)) : undefined);
92};
93
94const _noticed = async ($: EngineInterface, text: string): Promise<void> => {
95 const shownMs = 8000;
96 const at = await $.clock.now();
97 await update($, _NOTICE, () => some({ text, at }));
98 $.clock.after(shownMs, () => _cleared($, some(at)));
99};
100
101// --- [SERVICES]
102
103const _health = async ($: EngineInterface, known: readonly Service[]): Promise<void> => {
104 const [listened, held] = await Promise.all([_run($, LISTENERS, {}, [0, 1]), read($, _DOWN)]);
105 const found = listened.kind === 'ok' ? down(known, listened.value) : held;
106 await (found.length === held.length && found.every(({ name }) => held.some((service) => service.name === name)) ? undefined : update($, _DOWN, () => found));
107};
108
109const _watched = async ($: EngineInterface): Promise<void> => {
110 const healthMs = 60_000;
111 const repo = await $.session.repo();
112 const known = repo === null ? ok<readonly Service[]>([]) : services(await _run($, LAUNCHD_AGENTS, { cwd: repo.root }, [0]));
113 if (known.kind === 'ok' && known.value.length > 0) {
114 $.clock.every(healthMs, () => _health($, known.value));
115 await _health($, known.value);
116 }
117};
118
119const _restart = async ($: EngineInterface): Promise<void> => {
120 const [held, uid] = await Promise.all([read($, _DOWN), _run($, UID, {}, [0])]);
121 if (uid.kind === 'fault') {
122 await _faulted($, 'services not restarted', uid.faults);
123 return;
124 }
125 const ran = await Promise.all(held.map(async ({ name }) => ({ name, result: await _run($, kickstart(name, uid.value), {}, [0]) })));
126 const restarted = new Set(ran.flatMap(({ name, result }) => (result.kind === 'ok' ? [name] : [])));
127 const failed = ran.flatMap(({ result }) => (result.kind === 'fault' ? result.faults : []));
128 await (failed.length === 0 ? undefined : _faulted($, 'services not restarted', failed));
129 await (restarted.size === 0 ? undefined : Promise.all([_noticed($, `${[...restarted].join(' and ')} restarted · reconnect with /mcp`), update($, _DOWN, (current) => remaining(current, restarted))]));
130};
131
132// --- [TOOL_CALL]
133
134const _decision = ($: EngineInterface, e: ToolCallInput, walkPolicy: boolean): Promise<Decision> => {
135 if ((e.tool === 'Bash' || e.tool === 'Monitor') && e.command !== undefined) {
136 return commandDecision(_host($), e.tool, e.command, e.tool_use_id, walkPolicy);
137 }
138 if (e.tool === 'EnterWorktree' || (e.tool === 'Agent' && e.isolation === 'worktree')) {
139 return Promise.resolve({ kind: 'deny', reason: worktreeRefusal(e.tool) });
140 }
141 const refusal = e.tool === 'Write' ? pathRefusal([e.file_path]) : none;
142 return Promise.resolve(refusal.kind === 'some' ? { kind: 'deny', reason: refusal.value } : { kind: 'allow', rewrite: none });
143};
144
145const _called = async (e: Frozen<ToolCallInput>, next: Next<'tool.call'>): Promise<{ readonly answer: ToolCallResult; readonly paths: readonly string[] }> => {
146 if (e.tool === 'Edit' || e.tool === 'Write') {
147 const answer = await next(e);
148 return { answer, paths: answer.deny === undefined && answer.isError !== true && answer.result.staged !== true ? [answer.result.filePath] : [] };
149 }
150 if (e.tool === 'NotebookEdit') {
151 const answer = await next(e);
152 return { answer, paths: answer.deny === undefined && answer.isError !== true && answer.result.error === undefined ? [answer.result.notebook_path] : [] };
153 }
154 return { answer: await next(e), paths: [] };
155};
156
157const _queued = async ($: EngineInterface, loop: string, paths: readonly string[]): Promise<void> => {
158 if (paths.length === 0) {
159 return;
160 }
161 const merge = (held: readonly string[]): readonly string[] => [...new Set([...held, ...paths])];
162 await update($, memberOf(_EDITS, { requestId: loop }), (held) => ({ format: merge(held.format), diagnostics: merge(held.diagnostics) }));
163};
164
165const _drained = async ($: EngineInterface, loop: string, operation: 'format' | 'diagnostics'): Promise<readonly string[]> => {
166 let paths: readonly string[] = [];
167 await update($, memberOf(_EDITS, { requestId: loop }), (held) => {
168 paths = held[operation];
169 return { ...held, [operation]: [] };
170 });
171 return paths;
172};
173
174const _node = async ($: EngineInterface, operation: 'format' | 'diagnostics', root: string, paths: readonly string[]): Promise<Result<string>> => {
175 const stream = $.process.spawn({ argv: ['node', `${$.plugin.root}/repository/${operation}-cli.ts`], cwd: root, input: JSON.stringify({ root, paths }) });
176 let stdout = '';
177 let stderr = '';
178 for await (const chunk of stream) {
179 if (chunk.stream === 'stdout') {
180 stdout += chunk.text;
181 } else {
182 stderr += chunk.text;
183 }
184 }
185 const { code, signal } = await stream.result;
186 if (code === null) {
187 return fault({ kind: 'unstarted', subject: operation, cause: signal });
188 }
189 return code === 0 ? ok(stdout) : fault({ kind: 'exited', subject: operation, code, stderr });
190};
191
192const _processed = async ($: EngineInterface, loop: string, operation: 'format' | 'diagnostics'): Promise<readonly string[]> => {
193 const paths = await _drained($, loop, operation);
194 if (paths.length === 0) {
195 return [];
196 }
197 const ran = await bind(await _toplevel($), (root) => _result(_node($, operation, root, paths), 'unstarted', operation));
198 const output = bind(ran, (printed) => decoded<readonly string[]>(operation, printed));
199 if (output.kind === 'fault') {
200 await update($, memberOf(_EDITS, { requestId: loop }), (held) => ({ ...held, [operation]: [...new Set([...paths, ...held[operation]])] }));
201 return [rendered(output.faults)];
202 }
203 if (operation === 'format') {
204 await update($, memberOf(_EDITS, { requestId: loop }), (held) => ({ ...held, diagnostics: [...new Set([...held.diagnostics, ...paths])] }));
205 }
206 return output.value;
207};
208
209const _captured = async ($: EngineInterface, e: ToolCallInput, text: string): Promise<void> => {
210 const found = capturePath(e.tool, text);
211 if (found.kind === 'none') {
212 return;
213 }
214 const capture = await bind(await _toplevel($), async (root) => {
215 const path = `${root}/${found.value}`;
216 const [printed, stat] = await Promise.all([_run($, ['exiftool', '-j', '-Capture', path], {}, [0]), _result($.fs.stat(path), 'unread', path)]);
217 return map(both(recorded(printed), stat), ([record, { mtimeMs }]): Capture => ({ path, record, generation: mtimeMs }));
218 });
219 await (capture.kind === 'ok' ? update($, memberOf(_CAPTURE, { requestId: e.tool_use_id }), () => some(capture.value)) : undefined);
220};
221
222const _planned = async ($: EngineInterface, e: ToolCallInput): Promise<void> => {
223 if (e.agentId !== undefined || (e.tool !== 'Read' && e.tool !== 'Write' && e.tool !== 'Edit')) {
224 return;
225 }
226 const [home, plan] = await Promise.all([$.env.get('HOME'), read($, _PLAN)]);
227 const moved = home === undefined ? none : touched(plan, e.file_path, e.tool === 'Read' ? none : some(e.tool === 'Write' ? e.content : e.new_string), home);
228 await (moved.kind === 'some' ? update($, _PLAN, () => moved.value) : undefined);
229};
230
231// --- [RECORDING]
232
233const _opened = async ($: EngineInterface): Promise<Option<string>> => {
234 const repo = await $.session.repo();
235 if (repo === null) {
236 return none;
237 }
238 const { root } = repo;
239 const delta = `${root}/${DELTA}`;
240 const applied = await bind(await _result($.fs.write(delta, ''), 'unwritten', delta), () => _run($, sqlite(root), { stdin: OPEN, cwd: root }, [0]));
241 if (applied.kind === 'fault') {
242 await _faulted($, 'rows not recorded', applied.faults);
243 return none;
244 }
245 return some(root);
246};
247
248const _database = async ($: EngineInterface, held: Option<Option<string>>): Promise<Option<string>> => {
249 const opened = held.kind === 'some' ? held.value : await _opened($);
250 await (held.kind === 'none' ? update($, _DATABASE, () => some(opened)) : undefined);
251 return opened;
252};
253
254const _record = async ($: EngineInterface, event: Event, value: Payload, columns: Columns): Promise<Option<Stamped>> => {
255 const [root, ts, session, payload] = await Promise.all([read($, _DATABASE).then((held) => _database($, held)), $.clock.now(), $.session.id(), USAGE.includes(event) ? $.session.usage().then((usage): Payload => ({ ...value, usage })) : value]);
256 if (root.kind === 'none') {
257 return none;
258 }
259 const inserted = await _run($, sqlite(root.value), { stdin: bound(row(event, payload, columns, session, ts), INSERT), cwd: root.value }, [0]);
260 await (inserted.kind === 'ok' ? undefined : _faulted($, 'observation row not written', inserted.faults));
261 return some({ root: root.value, ts });
262};
263
264const _denied = ($: EngineInterface, observation: Option<PluginOptions>, e: ToolCallInput, reason: string, trace: unknown): Promise<Option<Stamped>> => (observation.kind === 'some' ? _record($, 'tool.call', { ...e, deny: reason, trace }, CALL) : Promise.resolve(none));
265
266const _spawn = async ($: EngineInterface, spawn: Spawn, bindings: Bindings): Promise<void> => {
267 const answer = await _result($.agent.spawn(request(spawn, bindings.key, bindings.worktree)), 'unstarted', spawn.agent);
268 if (answer.kind === 'fault') {
269 await _faulted($, 'agent not spawned', answer.faults);
270 return;
271 }
272 if (answer.value.deny !== undefined) {
273 $.ui.log(outcome(spawn, answer.value));
274 return;
275 }
276 const { agentId } = answer.value;
277 const [written] = await Promise.all([agentId === undefined ? undefined : _run($, sqlite(bindings.main), { stdin: bound({ ...spawn, ...bindings, id: agentId }, spawn.kind === 'range' ? JUDGE : REPORT), cwd: bindings.worktree }, [0]), _noticed($, outcome(spawn, answer.value))]);
278 await (written?.kind === 'fault' ? _faulted($, 'observation row not written', written.faults) : undefined);
279};
280
281const _boundary = async ($: EngineInterface, options: PluginOptions, session: string, { root: main, ts }: Stamped): Promise<readonly string[]> => {
282 const [toplevel, branch] = await Promise.all([_toplevel($), $.session.root().then((cwd) => _run($, ['git', 'branch', '--show-current'], { cwd }, [0]))]);
283 const decided = await bind(both(toplevel, branch), async ([worktree, name]) => {
284 const lineage = { main, worktree, branch: name.trim() };
285 return map(decoded<Boundary>('sqlite3', await _run($, sqlite(main), { stdin: bound({ ...options, ...lineage, to: ts, session }, BOUNDARY), cwd: worktree }, [0])), (boundary) => ({ ...boundary, bindings: { ...lineage, key: boundary.key, session, at: ts } }));
286 });
287 if (decided.kind === 'fault') {
288 await _faulted($, 'boundary skipped', decided.faults);
289 return [];
290 }
291 const { bindings, spawns, findings } = decided.value;
292 await Promise.all(spawns.map((spawn) => _spawn($, spawn, bindings)));
293 return delivered(findings, bindings.branch);
294};
295
296// --- [WRITERS]
297
298const _stopped = async <E extends Frozen<ClassicHookInputs['Stop' | 'SubagentStop']>, R extends { readonly additionalContext?: readonly string[] }>($: EngineInterface, e: E, next: (input: E) => Promise<R>, observation: Option<PluginOptions>): Promise<R> => {
299 const loop = e.hook_event_name === 'SubagentStop' ? e.agent_id : _MAIN;
300 const stamped = observation.kind === 'some' ? await _record($, e.hook_event_name, e, CLASSIC) : none;
301 const result = await next(e);
302 const boundary = observation.kind === 'some' && stamped.kind === 'some' && e.hook_event_name === 'Stop' ? _boundary($, observation.value, e.session_id, stamped.value) : [];
303 const written = await _processed($, loop, 'format');
304 const checked = await _processed($, loop, 'diagnostics');
305 const context = e.stop_hook_active ? [] : await _once($, loop, [...(await boundary), ...written, ...checked]);
306 return context.length === 0 ? result : { ...result, additionalContext: [...(result.additionalContext ?? []), ...context] };
307};
308
309// --- [COMPOSITION] ---------------------------------------------------------------------
310
311const register: Register = (on, options) => {
312 const walkPolicy = options.walkPolicy === true;
313 const observation = options.observation === true ? some(options) : none;
314
315 on('session.start', async ($, e, next) => {
316 await Promise.all([_watched($), observation.kind === 'some' ? _database($, none) : undefined]);
317 return next(e);
318 });
319
320 on('tool.call', async ($, e, next) => {
321 const decision = await _decision($, e, walkPolicy);
322 if (decision.kind === 'deny') {
323 await _denied($, observation, e, decision.reason, next.trace);
324 return { deny: decision.reason };
325 }
326 const { rewrite } = decision;
327 const loop = e.agentId ?? _MAIN;
328 const [{ answer, paths }] = await Promise.all([_called((e.tool === 'Bash' || e.tool === 'Monitor') && rewrite.kind === 'some' ? { ...e, command: rewrite.value.command } : e, next), rewrite.kind === 'some' ? _noticed($, rewrite.value.notice) : undefined]);
329 if (answer.deny !== undefined) {
330 await _denied($, observation, e, answer.deny, next.trace);
331 return answer;
332 }
333 const failed = answer.isError === true;
334 await Promise.all([_queued($, loop, paths), failed || answer.text === undefined ? undefined : _captured($, e, answer.text), failed ? undefined : _planned($, e)]);
335 const context = await _once($, loop, [...(rewrite.kind === 'some' ? [rewrite.value.context] : []), ...(await _processed($, loop, 'diagnostics'))]);
336 return context.length === 0 ? answer : ({ ...answer, context: [...(answer.context ?? []), ...context] } satisfies ToolCallResult);
337 }).catch((_$, e, next) => (next.called ? next(e) : { deny: 'function-hooks policy did not run' }));
338
339 on('turn.complete', async ($, e, next) => {
340 const loop = e.agentId ?? _MAIN;
341 const said = memberOf(_SAID, { requestId: loop });
342 const logged = memberOf(_SAID, { requestId: _LOG });
343 const [notes, faults] = await Promise.all([read($, said), loop === _MAIN ? read($, logged) : [], observation.kind === 'some' ? _record($, 'turn.complete', e, TURN) : undefined]);
344 const [result] = await Promise.all([next(e), notes.length === 0 ? undefined : update($, said, () => []), faults.length === 0 ? undefined : update($, logged, () => [])]);
345 return result;
346 });
347
348 on('classic.Stop', ($, e, next) => _stopped($, e, next, observation));
349
350 on('classic.SubagentStop', ($, e, next) => _stopped($, e, next, observation));
351
352 on('prompt.submit', async ($, e, next) => {
353 await _cleared($, none);
354 return next(e);
355 });
356
357 on('prompt.compose', async ($, e, next) => {
358 const [composed, plan] = await Promise.all([next(e), read($, _PLAN)]);
359 const shown = pointer(plan);
360 return shown.kind === 'none' ? composed : { ...composed, sections: [...composed.sections, { id: 'function-hooks:plan', text: shown.value, scope: 'session' }] };
361 });
362
363 on('session.compact', async ($, e, next) => {
364 const shown = pointer(await read($, _PLAN));
365 return next(shown.kind === 'none' || e.agentId !== undefined ? e : { ...e, instructions: [...(e.instructions === undefined ? [] : [e.instructions]), shown.value].join('\n') });
366 });
367
368 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
369 const [below, notice, held] = await Promise.all([next(e), read($, _NOTICE), read($, _DOWN)]);
370 const shown = e.props.hasSurvey ? [] : bandRows(notice, held, () => _restart($));
371 return shown.length === 0 ? below : band($.ui.resolve(e), shown, below);
372 });
373
374 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
375 const [below, capture] = await Promise.all([next(e), read($, memberOf(_CAPTURE, e))]);
376 if (capture.kind === 'some') {
377 return e.surface === 'terminal' && e.viewport !== undefined ? captureRow($.ui.resolve(e), below, capture.value, e.viewport.columns) : resultRow($.ui.resolve(e), below, caption(capture.value.path, capture.value.record));
378 }
379 return below;
380 });
381
382 if (observation.kind === 'some') {
383 on('classic.*', { hook_event_name: ['SessionStart', 'PermissionDenied', 'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'SubagentStart', 'UserPromptSubmit', 'StopFailure', 'PreCompact', 'PostCompact', 'SessionEnd', 'WorktreeCreate', 'WorktreeRemove'] }, async ($, e, next) => {
384 await (next.is('!classic.PreToolUse', e) ? _record($, e.hook_event_name, e, CLASSIC) : undefined);
385 return next(e);
386 });
387
388 on('turn.start', async ($, e, next) => {
389 await _record($, 'turn.start', e, TURN);
390 return next(e);
391 });
392
393 on('turn.step', async function* ($, e, next) {
394 await _record($, 'turn.step', e, TURN);
395 return yield* next(e);
396 });
397 }
398};
399
400// --- [EXPORTS] -------------------------------------------------------------------------
401
402export { register };
403composition.ts 71 lines1import type { Option } from './hooks/state.d.ts';
2
3// --- [TYPES] ---------------------------------------------------------------------------
4
5type Fault =
6 | { readonly kind: 'exited'; readonly subject: string; readonly code: number; readonly stderr: string }
7 | { readonly kind: 'refused'; readonly subject: string; readonly text: string }
8 | { readonly kind: 'unstarted' | 'unread' | 'unwritten' | 'undecoded' | 'invalid'; readonly subject: string; readonly cause: unknown };
9type Result<T> = { readonly kind: 'ok'; readonly value: T } | { readonly kind: 'fault'; readonly faults: readonly [Fault, ...Fault[]] };
10
11// --- [CONSTANTS] -----------------------------------------------------------------------
12
13const _LATER_LINES = /\n.*/su;
14
15// --- [OPERATIONS] ----------------------------------------------------------------------
16
17// --- [CONSTRUCTORS]
18
19const none: Option<never> = { kind: 'none' };
20const some = <A>(value: A): Option<A> => ({ kind: 'some', value });
21const fromUndefined = <A>(value: A | undefined): Option<A> => (value === undefined ? none : some(value));
22const ok = <T>(value: T): Result<T> => ({ kind: 'ok', value });
23const fault = <T>(value: Fault): Result<T> => ({ kind: 'fault', faults: [value] });
24
25// --- [COMBINATORS]
26
27const map = <A, B>(result: Result<A>, f: (value: A) => B): Result<B> => (result.kind === 'ok' ? ok(f(result.value)) : result);
28const bind = <A, R extends Result<unknown> | Promise<Result<unknown>>>(result: Result<A>, f: (value: A) => R): R | Result<never> => (result.kind === 'ok' ? f(result.value) : result);
29const both = <A, B>(left: Result<A>, right: Result<B>): Result<readonly [A, B]> => (left.kind === 'ok' ? map(right, (value) => [left.value, value] as const) : { kind: 'fault', faults: [...left.faults, ...(right.kind === 'fault' ? right.faults : [])] });
30const all = <T>(results: readonly Result<T>[]): Result<readonly T[]> => results.reduce<Result<readonly T[]>>((done, next) => map(both(done, next), ([values, value]) => [...values, value]), ok([]));
31
32// --- [CONVERSIONS]
33
34const decoded = <T>(subject: string, printed: Result<string>): Result<T> =>
35 bind(printed, (text): Result<T> => {
36 try {
37 return ok(JSON.parse(text));
38 } catch (cause) {
39 return fault({ kind: 'undecoded', subject, cause });
40 }
41 });
42
43// --- [TEXT]
44
45const counted = (count: number, noun: string, plural: string): string => `${count} ${count === 1 ? noun : plural}`;
46
47const _described = (value: Fault): readonly [outcome: string, detail: string] => {
48 switch (value.kind) {
49 case 'exited':
50 return [`exited ${value.code}`, value.stderr];
51 case 'refused':
52 return ['returned an error', value.text];
53 default:
54 return [{ unstarted: 'did not run', unread: 'not read', unwritten: 'not written', undecoded: 'output does not decode as JSON', invalid: 'does not hold its declared form' }[value.kind], String(value.cause)];
55 }
56};
57
58const rendered = (faults: readonly Fault[]): string =>
59 faults
60 .map((value) => {
61 const [outcome, detail] = _described(value);
62 return [`${value.subject} ${outcome}`, detail.replace(_LATER_LINES, '')].filter((part) => part.length > 0).join(', ');
63 })
64 .join('. ');
65
66// --- [EXPORTS] -------------------------------------------------------------------------
67
68export type { Option } from './hooks/state.d.ts';
69export type { Fault, Result };
70export { all, bind, both, counted, decoded, fault, fromUndefined, map, none, ok, rendered, some };
71context/plan.ts 26 lines1import { none, type Option, some } from '../composition.ts';
2import type { Plan } from '../hooks/state.d.ts';
3
4// --- [CONSTANTS] -----------------------------------------------------------------------
5
6const _TASK = /^\d+\. /mu;
7
8// --- [OPERATIONS] ----------------------------------------------------------------------
9
10const touched = (plan: Plan, path: string, written: Option<string>, home: string): Option<Plan> => {
11 const inside = (folder: string): boolean => path.endsWith('.md') && path.startsWith(`${folder}/`);
12 if (inside(`${home}/.claude/plans`)) {
13 return some({ ...plan, path: some(path) });
14 }
15 return written.kind === 'some' && ['/tmp', '/private/tmp'].some(inside) && _TASK.test(written.value) ? some({ ...plan, taskFile: some(path) }) : none;
16};
17
18const pointer = ({ path, taskFile }: Plan): Option<string> => {
19 const named = [...(path.kind === 'some' ? [`plan ${path.value}`] : []), ...(taskFile.kind === 'some' ? [`task file ${taskFile.value}`] : [])];
20 return named.length === 0 ? none : some(`Active ${named.join(', ')}. Delete each closed task from ${named.length === 1 ? 'it' : 'both'}`);
21};
22
23// --- [EXPORTS] -------------------------------------------------------------------------
24
25export { pointer, touched };
26observation/delivery.ts 33 lines1import type { AgentSpawnArgs, AgentSpawnResult } from 'claude-code';
2import { counted } from '../composition.ts';
3
4// --- [TYPES] ---------------------------------------------------------------------------
5
6type Spawn = { readonly kind: 'range'; readonly agent: string; readonly from: number; readonly to: number } | { readonly kind: 'category'; readonly agent: string; readonly category: string };
7
8interface Boundary {
9 readonly key: string;
10 readonly spawns: readonly Spawn[];
11 readonly findings: readonly string[];
12}
13
14// --- [OPERATIONS] ----------------------------------------------------------------------
15
16const request = (spawn: Spawn, key: string, cwd: string): AgentSpawnArgs => ({
17 subagentType: spawn.agent,
18 cwd,
19 ...(spawn.kind === 'range' ? { prompt: `range ${key} ${spawn.from} ${spawn.to}`, description: 'judge edits' } : { prompt: `category ${spawn.category} lineage ${key}`, description: 'build category rule' }),
20});
21
22const outcome = (spawn: Spawn, result: AgentSpawnResult): string => {
23 const subject = spawn.kind === 'range' ? `${spawn.from}..${spawn.to}` : spawn.category;
24 return result.deny === undefined ? `spawned ${spawn.agent}${result.agentId === undefined ? '' : ` ${result.agentId}`} over ${subject}` : `${spawn.agent} spawn refused over ${subject}, ${result.deny}`;
25};
26
27const delivered = (findings: readonly string[], branch: string): readonly string[] => (findings.length === 0 ? [] : [`${counted(findings.length, 'finding', 'findings')} on ${branch}, ids ${findings.join(', ')}. Use observation skill for delivered findings`]);
28
29// --- [EXPORTS] -------------------------------------------------------------------------
30
31export type { Boundary, Spawn };
32export { delivered, outcome, request };
33observation/row.ts 54 lines1import type { ClassicHookEvent, EventName } from 'claude-code';
2import type { Column } from './sql.ts';
3
4// --- [TYPES] ---------------------------------------------------------------------------
5
6type Event = ClassicHookEvent | 'tool.call' | Extract<EventName, `turn.${string}`>;
7type Payload = Readonly<Record<string, unknown>>;
8type Id = Exclude<Column, 'event' | 'ts' | 'session_id' | 'payload'>;
9type Row = Readonly<Partial<Record<Id, string>>> & { readonly event: Event; readonly ts: number; readonly session_id: string; readonly payload: string };
10
11interface Drops {
12 readonly [key: string]: Drops | true;
13}
14interface Columns {
15 readonly ids: Readonly<Partial<Record<Id, string>>>;
16 readonly drops: Drops;
17 readonly tools: Readonly<Partial<Record<string, Drops>>>;
18}
19
20// --- [CONSTANTS] -----------------------------------------------------------------------
21
22const CLASSIC: Columns = {
23 ids: { prompt_id: 'prompt_id', agent_id: 'agent_id', tool: 'tool_name', tool_use_id: 'tool_use_id' },
24 drops: { session_id: true, hook_event_name: true, tool_calls: { tool_response: true } },
25 tools: {
26 Read: { tool_response: { pages: true, file: { content: true, base64: true, cells: true } } },
27 Write: { tool_response: { content: true } },
28 Edit: { tool_response: { originalFile: true } },
29 },
30};
31const CALL: Columns = { ids: { agent_id: 'agentId', tool: 'tool', tool_use_id: 'tool_use_id' }, drops: { trace: { received: true, returned: true } }, tools: {} };
32const TURN: Columns = { ids: { agent_id: 'agentId' }, drops: {}, tools: {} };
33const USAGE: readonly Event[] = ['Stop', 'SessionEnd'];
34
35// --- [OPERATIONS] ----------------------------------------------------------------------
36
37const _dropped = (value: unknown, drops: Drops): unknown => {
38 if (Array.isArray(value)) {
39 return value.map((item) => _dropped(item, drops));
40 }
41 return typeof value === 'object' && value !== null ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => (drops[key] === true ? [] : [[key, drops[key] === undefined ? item : _dropped(item, drops[key])]]))) : value;
42};
43
44const row = (event: Event, value: Payload, columns: Columns, session: string, ts: number): Row => {
45 const ids: Readonly<Partial<Record<Id, string>>> = Object.fromEntries(Object.entries(columns.ids).flatMap(([name, key]) => (typeof value[key] === 'string' ? [[name, value[key]]] : [])));
46 const columned = Object.fromEntries(Object.values(columns.ids).map((key) => [key, true] as const));
47 return { ...ids, event, ts, session_id: session, payload: JSON.stringify(_dropped(value, { ...columns.drops, ...(ids.tool === undefined ? {} : columns.tools[ids.tool]), ...columned })) };
48};
49
50// --- [EXPORTS] -------------------------------------------------------------------------
51
52export type { Columns, Event, Payload };
53export { CALL, CLASSIC, row, TURN, USAGE };
54observation/sql.ts 146 lines1import type { Invocation } from '../policies/invocation.ts';
2
3// --- [TYPES] ---------------------------------------------------------------------------
4
5type Column = (typeof _OBSERVATION)[number][0];
6
7// --- [CONSTANTS] -----------------------------------------------------------------------
8
9const _FOLDER = '.cache/observation';
10const DELTA = `${_FOLDER}/delta.sql`;
11
12// --- [OPERATIONS] ----------------------------------------------------------------------
13
14const sqlite = (root: string): Invocation => ['sqlite3', '-bail', '-cmd', '.timeout 10000', `${root}/${_FOLDER}/observation.db`];
15const _normalized = (text: string): string => `replace(replace(replace(replace(replace(replace(${text}, char(9), ' '), char(13), ' '), char(10), ' '), ' ', char(64976, 64977)), char(64977, 64976), ''), char(64976, 64977), ' ')`;
16const _lineage = (main: string, worktree: string, branch: string): string => `iif(${worktree} = ${main}, '.', substr(${worktree}, length(rtrim(${worktree}, replace(${worktree}, '/', ''))) + 1)) || '/' || ${branch}`;
17const bound = (values: object, statement: string): string => `.parameter init\ninsert into temp.sqlite_parameters(key, value) select ':' || key, value from json_each('${JSON.stringify(values).replaceAll("'", "''")}');\n${statement}`;
18
19// --- [SCHEMA] --------------------------------------------------------------------------
20
21const _OBSERVATION = [
22 ['event', 'text not null'],
23 ['ts', 'integer not null'],
24 ['session_id', 'text not null'],
25 ['prompt_id', 'text'],
26 ['agent_id', 'text'],
27 ['tool', 'text'],
28 ['tool_use_id', 'text'],
29 ['payload', 'text not null'],
30] as const;
31const _TABLES: readonly (readonly [name: string, body: string, rows?: readonly object[]])[] = [
32 ['observation', `(${_OBSERVATION.map(([name, type]) => `${name} ${type}`).join(', ')})`],
33 [
34 'transition_state',
35 '(state text primary key, live integer not null default 0 check (live in (0, 1))) strict',
36 [
37 { state: 'proposed', live: 1 },
38 { state: 'confirmed', live: 1 },
39 { state: 'wrong', live: 0 },
40 { state: 'checker_owned', live: 1 },
41 { state: 'checker_silent', live: 1 },
42 { state: 'fixed', live: 0 },
43 { state: 'vanished', live: 0 },
44 { state: 'moved', live: 0 },
45 { state: 'waived', live: 1 },
46 ],
47 ],
48 ['transition_actor', '(actor text primary key) strict', [{ actor: 'user' }, { actor: 'agent' }, { actor: 'check' }]],
49 ['checker', '(tool text primary key) strict', [{ tool: 'ast-grep' }, { tool: 'ruff' }, { tool: 'biome' }, { tool: 'roslyn' }]],
50 ['delivery_channel', '(channel text primary key) strict', [{ channel: 'additionalContext' }, { channel: 'report' }]],
51 ['range_kind', '(kind text primary key) strict', [{ kind: 'edit' }]],
52 ['bar_verdict', '(verdict text primary key, earns integer not null check (earns in (0, 1))) strict'],
53 [
54 'finding',
55 `(checker text references checker(tool), category text not null, path text not null, text text not null, ntext text generated always as (${_normalized('text')}) stored, text_hash text generated always as (lower(hex(sha3(ntext, 256)))) stored, occurrence integer not null, finding_id text generated always as (lower(hex(sha3(coalesce(checker || ':', '') || category || char(0) || path || char(0) || text_hash || char(0) || occurrence, 256)))) stored unique, start_line integer not null, start_column integer not null, end_line integer not null, end_column integer not null, byte_start integer, byte_end integer, subject_hash text not null, severity text, message text not null, replacement text, session_id text, prompt_id text, agent_id text, tool_use_id text, observed_at integer not null) strict`,
56 ],
57 [
58 'finding_transition',
59 "(finding_id text not null references finding(finding_id), state text not null references transition_state(state), subject_hash text not null, path text, start_line integer, start_column integer, end_line integer, end_column integer, byte_start integer, byte_end integer, occurrence integer, at integer not null, actor text not null references transition_actor(actor), actor_id text check ((actor = 'user') = (actor_id is null)), evidence text check (evidence is not null or state not in ('wrong', 'waived', 'checker_owned', 'checker_silent')), verdict text references bar_verdict(verdict)) strict",
60 ],
61 ['finding_delivery', '(finding_id text not null references finding(finding_id), lineage_key text not null, session_id text not null, agent_id text, channel text not null references delivery_channel(channel), delivered_at integer not null) strict'],
62 [
63 'judged_range',
64 `(kind text not null references range_kind(kind), main_worktree text not null, worktree text not null, branch text not null, lineage_key text generated always as (${_lineage('main_worktree', 'worktree', 'branch')}) stored, from_ts integer not null, to_ts integer not null, agent_id text not null, at integer not null) strict`,
65 ],
66];
67const _INDEXES: readonly string[] = [
68 'create index if not exists observation_session_ts on observation(session_id, ts);',
69 'create index if not exists observation_agent on observation(agent_id);',
70 'create index if not exists observation_prompt on observation(prompt_id);',
71 "create index if not exists observation_turn on observation(payload ->> '$.turnId');",
72 'create index if not exists observation_event on observation(event, tool, ts);',
73 'create index if not exists observation_tool_use on observation(tool_use_id);',
74 'create index if not exists finding_category on finding(category);',
75 'create index if not exists finding_path on finding(path);',
76 'create index if not exists finding_transition_at on finding_transition(finding_id, at);',
77 'create index if not exists finding_delivery_at on finding_delivery(finding_id, delivered_at);',
78 'create index if not exists judged_range_to on judged_range(kind, lineage_key, to_ts);',
79];
80const _COST =
81 "cost as (select session_id, ts, event, payload ->> '$.usage.cost.usd' as usd, max(ts) filter (where event = 'SessionStart') over (partition by session_id order by ts) as process_ts from (select session_id, ts, 'SessionStart' as event, null as payload from processes union all select session_id, ts, event, payload from observation where event in ('Stop', 'SessionEnd')))";
82const _VIEWS: readonly string[] = [
83 "create view edited_files as select session_id, prompt_id, agent_id, ts, tool, tool_use_id, coalesce(payload ->> '$.tool_input.file_path', payload ->> '$.tool_input.notebook_path') as file_path, payload ->> '$.cwd' as cwd from observation where event = 'PostToolUse' and tool in ('Edit', 'Write', 'NotebookEdit');",
84 "create view lineage as select g.session_id, g.prompt_id, g.agent_id, a.parent_id, g.agent_type, a.description, a.resolved_model, a.is_async, a.status, a.total_tokens, a.total_duration_ms, g.started_ts, g.stopped_ts from (select agent_id, min(session_id) as session_id, min(prompt_id) as prompt_id, min(payload ->> '$.agent_type') as agent_type, min(ts) filter (where event = 'SubagentStart') as started_ts, max(ts) filter (where event in ('SubagentStop', 'turn.complete')) as stopped_ts from observation where event in ('SubagentStart', 'SubagentStop', 'turn.complete') and agent_id is not null group by agent_id) g left join (select payload ->> '$.tool_response.agentId' as agent_id, min(agent_id) as parent_id, min(payload ->> '$.tool_input.description') as description, min(payload ->> '$.tool_response.resolvedModel') as resolved_model, min(payload ->> '$.tool_response.isAsync') as is_async, min(payload ->> '$.tool_response.status') as status, min(payload ->> '$.tool_response.totalTokens') as total_tokens, min(payload ->> '$.tool_response.totalDurationMs') as total_duration_ms from observation where event = 'PostToolUse' and tool = 'Agent' group by 1) a on a.agent_id = g.agent_id;",
85 "create view processes as select session_id, ts, payload ->> '$.source' as source from observation where event = 'SessionStart' and payload ->> '$.source' <> 'compact';",
86 `create view turn_cost as with ${_COST}, priced as (select session_id, ts, usd, process_ts, lag(usd) over (partition by session_id, process_ts order by ts) as previous_usd, lead(ts) over (partition by session_id order by ts) as next_ts from cost where event = 'Stop') select p.session_id, p.prompt_id, p.turn_id, p.started_ts, c.payload ->> '$.usage.model' as model, c.payload ->> '$.usage.input_tokens' as input_tokens, c.payload ->> '$.usage.output_tokens' as output_tokens, c.payload ->> '$.usage.cache_read_input_tokens' as cache_read_input_tokens, c.payload ->> '$.usage.cache_creation_input_tokens' as cache_creation_input_tokens, (select count(*) from observation s where s.event = 'turn.step' and s.payload ->> '$.turnId' = p.turn_id) as steps, c.payload ->> '$.durationMs' as duration_ms, c.payload ->> '$.reason' as reason, (select x.payload ->> '$.last_assistant_message' from observation x where x.event = 'StopFailure' and x.session_id = p.session_id and x.agent_id is null and x.ts between p.started_ts and c.ts order by x.ts desc limit 1) as cause, iif(k.process_ts is not null, k.usd - coalesce(k.previous_usd, 0), null) as usd from (select t.session_id, t.payload ->> '$.turnId' as turn_id, t.ts as started_ts, (select u.prompt_id from observation u where u.session_id = t.session_id and u.event = 'UserPromptSubmit' and u.ts <= t.ts order by u.ts desc limit 1) as prompt_id from observation t where t.event = 'turn.start') p left join observation c on c.event = 'turn.complete' and c.payload ->> '$.turnId' = p.turn_id left join priced k on k.session_id = p.session_id and k.ts between p.started_ts and c.ts and (k.next_ts is null or k.next_ts > c.ts);`,
87 "create view agent_cost as select l.session_id, l.prompt_id, l.agent_id, l.agent_type, l.stopped_ts - l.started_ts as span_ms, count(o.rowid) as tool_uses, sum(o.payload ->> '$.duration_ms') as tool_duration_ms, coalesce(l.total_tokens, t.tokens) as total_tokens, coalesce(l.total_duration_ms, t.duration_ms) as total_duration_ms, (select x.payload ->> '$.last_assistant_message' from observation x where x.event = 'StopFailure' and x.agent_id = l.agent_id order by x.ts desc limit 1) as cause from lineage l left join observation o on o.event = 'PostToolUse' and o.agent_id = l.agent_id left join (select c.agent_id, sum(c.payload ->> '$.usage.input_tokens' + c.payload ->> '$.usage.output_tokens' + c.payload ->> '$.usage.cache_read_input_tokens' + c.payload ->> '$.usage.cache_creation_input_tokens') as tokens, sum(c.payload ->> '$.durationMs') as duration_ms from observation c where c.event = 'turn.complete' and c.agent_id is not null group by c.agent_id) t on t.agent_id = l.agent_id group by l.agent_id;",
88 'create view edit_churn as select session_id, prompt_id, file_path, count(*) as edits, count(distinct agent_id) + max(agent_id is null) as agents, min(ts) as first_ts, max(ts) as last_ts from edited_files group by session_id, prompt_id, file_path having count(*) > 1;',
89 "create view denials as select ts, session_id, prompt_id, agent_id, tool, tool_use_id, 'policy' as kind, payload ->> '$.deny' as reason, payload ->> '$.command' as command, payload ->> '$.file_path' as file_path from observation where event = 'tool.call' and payload ->> '$.deny' is not null union all select ts, session_id, prompt_id, agent_id, tool, tool_use_id, 'permission', payload ->> '$.reason', payload ->> '$.tool_input.command', payload ->> '$.tool_input.file_path' from observation where event = 'PermissionDenied' union all select ts, session_id, prompt_id, agent_id, tool, tool_use_id, 'failure', payload ->> '$.error', payload ->> '$.tool_input.command', payload ->> '$.tool_input.file_path' from observation where event = 'PostToolUseFailure' union all select b.ts, b.session_id, b.prompt_id, b.agent_id, c.value ->> '$.tool_name', c.value ->> '$.tool_use_id', 'host', null, c.value ->> '$.tool_input.command', c.value ->> '$.tool_input.file_path' from observation b, json_each(b.payload, '$.tool_calls') c where b.event = 'PostToolBatch' and not exists (select 1 from observation r where r.tool_use_id = c.value ->> '$.tool_use_id' and r.event in ('PostToolUse', 'PostToolUseFailure', 'PermissionDenied', 'tool.call'));",
90 `create view session_audit as with ${_COST}, spent as (select session_id, sum(usd) as usd from (select session_id, max(usd) as usd from cost where event <> 'SessionStart' and process_ts is not null group by session_id, process_ts) group by session_id), base as (select session_id, min(ts) as first_ts, max(ts) as last_ts, count(*) filter (where event = 'UserPromptSubmit') as prompts, count(*) filter (where event = 'SubagentStart') as agents, count(*) filter (where event = 'SubagentStop' and payload ->> '$.agent_type' = '') as forks, count(*) filter (where event = 'PostCompact') as compactions, sum(length(cast(payload ->> '$.compact_summary' as blob))) filter (where event = 'PostCompact') as compact_summary_bytes, min(ts) = min(ts) filter (where event = 'SessionStart') as recorded from observation group by session_id) select b.session_id, b.first_ts, b.last_ts, coalesce(n.processes, 0) as processes, b.prompts, b.agents, b.forks, b.compactions, b.compact_summary_bytes, (select e.payload ->> '$.reason' from observation e where e.session_id = b.session_id and e.event = 'SessionEnd' order by e.ts desc limit 1) as end_reason, (select json_array_length(p.payload, '$.background_tasks') from observation p where p.session_id = b.session_id and p.event = 'Stop' order by p.ts desc limit 1) as background_tasks_at_end, iif(b.recorded, x.usd, null) as usd from base b left join (select session_id, count(*) as processes from processes group by session_id) n on n.session_id = b.session_id left join spent x on x.session_id = b.session_id;`,
91 "create view commits as select ts, session_id, prompt_id, agent_id, tool_use_id, payload ->> '$.tool_response.gitOperation.commit.sha' as sha, payload ->> '$.tool_response.gitOperation.commit.kind' as kind, payload ->> '$.tool_response.gitOperation.commit.branch' as branch, payload ->> '$.cwd' as cwd from observation where event = 'PostToolUse' and tool = 'Bash' and payload ->> '$.tool_response.gitOperation.commit' is not null;",
92 "create view agent_digest as select o.session_id, o.agent_id, l.agent_type, count(*) as tool_uses, min(o.ts) as first_ts, max(o.ts) as last_ts, count(*) filter (where o.tool = 'Read') as reads, count(*) filter (where o.tool = 'Edit') as edits, count(*) filter (where o.tool = 'Write') as writes, count(*) filter (where o.tool = 'Bash') as bash_calls, count(*) filter (where o.tool = 'Agent') as agent_calls, coalesce(d.denials, 0) as denials, coalesce(f.files, 0) as files from observation o left join lineage l on l.agent_id = o.agent_id left join (select session_id, agent_id, count(*) as denials from denials group by session_id, agent_id) d on d.session_id = o.session_id and d.agent_id is o.agent_id left join (select session_id, agent_id, count(distinct file_path) as files from edited_files group by session_id, agent_id) f on f.session_id = o.session_id and f.agent_id is o.agent_id where o.event = 'PostToolUse' group by o.session_id, o.agent_id;",
93 "create view repeated_calls as select session_id, agent_id, tool, payload ->> '$.tool_input' as tool_input, count(*) as calls, min(ts) as first_ts, max(ts) as last_ts from observation where event = 'PostToolUse' group by session_id, agent_id, tool, payload ->> '$.tool_input' having count(*) > 1;",
94 "create view edits_outside_cwd as select ts, session_id, prompt_id, agent_id, tool, tool_use_id, file_path, cwd from edited_files where instr(file_path, cwd || '/') <> 1;",
95 "create view running_agents as with parented as materialized (select payload ->> '$.tool_response.agentId' as agent_id from observation where event = 'PostToolUse' and tool = 'Agent') select s.session_id, s.prompt_id, s.agent_id, s.payload ->> '$.agent_type' as agent_type, s.payload ->> '$.cwd' as cwd, s.ts as started_ts from observation s where s.event = 'SubagentStart' and (select max(m.ts) from observation m where m.session_id = s.session_id) > unixepoch() * 1000 - 172800000 and not exists (select 1 from observation x where x.session_id = s.session_id and x.ts > s.ts and (x.event = 'SessionEnd' or (x.event = 'SessionStart' and x.payload ->> '$.source' <> 'compact') or (x.event = 'turn.complete' and x.agent_id = s.agent_id) or (x.event in ('Stop', 'SubagentStop') and s.agent_id in (select agent_id from parented) and not exists (select 1 from json_each(x.payload, '$.background_tasks') t where t.value ->> '$.id' = s.agent_id))));",
96 "create view finding_state as select f.finding_id, f.category, coalesce(t.path, f.path) as path, f.text, f.ntext, f.text_hash, f.occurrence, coalesce(t.start_line, f.start_line) as start_line, coalesce(t.start_column, f.start_column) as start_column, coalesce(t.end_line, f.end_line) as end_line, coalesce(t.end_column, f.end_column) as end_column, coalesce(t.byte_start, f.byte_start) as byte_start, coalesce(t.byte_end, f.byte_end) as byte_end, f.severity, f.message, f.replacement, f.checker, f.session_id, f.prompt_id, f.agent_id, f.tool_use_id, f.observed_at, j.state, t.subject_hash, t.at, t.actor, t.actor_id, j.evidence, j.verdict, (select max(c.at) from finding_transition c where c.finding_id = f.finding_id and c.state in ('fixed', 'vanished')) as last_closed_at from finding f join finding_transition t on t.rowid = (select u.rowid from finding_transition u where u.finding_id = f.finding_id order by u.at desc, u.rowid desc limit 1) join finding_transition j on j.rowid = (select u.rowid from finding_transition u where u.finding_id = f.finding_id and u.state <> 'moved' order by u.at desc, u.rowid desc limit 1);",
97 "create view confirmed_findings as select finding_id, category, path, text, ntext, occurrence, start_line, start_column, end_line, end_column, message, replacement, session_id, prompt_id, agent_id, subject_hash, at as confirmed_at, evidence, verdict, last_closed_at from finding_state s where state = 'confirmed' and checker is null and not exists (select 1 from finding_transition w where w.finding_id = s.finding_id and w.state = 'wrong' and w.subject_hash = s.subject_hash);",
98 'create view open_findings as select c.*, (select json_group_array(distinct d.lineage_key) from finding_delivery d where d.finding_id = c.finding_id and (c.last_closed_at is null or d.delivered_at > c.last_closed_at)) as delivered_on from confirmed_findings c;',
99 "create view recurring_categories as select c.category, count(*) as sites, json_group_array(c.path || ':' || c.start_line) as sites_at, min(c.confirmed_at) as first_at, max(c.confirmed_at) as last_at, (select json_group_array(distinct d.lineage_key) from finding_delivery d join confirmed_findings s on s.category = c.category and s.finding_id = d.finding_id where d.channel = 'report' and (s.last_closed_at is null or d.delivered_at > s.last_closed_at)) as reported_on from confirmed_findings c left join bar_verdict v on v.verdict = c.verdict group by c.category having count(*) >= 2 and count(*) filter (where v.earns = 0) = 0;",
100 "create view judged_edits as select e.session_id, e.prompt_id, e.agent_id, e.ts, e.tool, e.tool_use_id, e.file_path, e.cwd, j.lineage_key from edited_files e join judged_range j on j.kind = 'edit' and e.ts between j.from_ts and j.to_ts and instr(e.cwd || '/', j.worktree || '/') = 1 where instr(e.file_path, e.cwd || '/') = 1 and not exists (select 1 from judged_range k where k.kind = 'edit' and length(k.worktree) > length(j.worktree) and instr(e.cwd || '/', k.worktree || '/') = 1);",
101 "create view unjudged_edits as select e.session_id, e.prompt_id, e.agent_id, e.ts, e.tool, e.tool_use_id, e.file_path, e.cwd from edited_files e left join judged_edits x on x.tool_use_id = e.tool_use_id where instr(e.file_path, e.cwd || '/') = 1 and x.tool_use_id is null;",
102 "create view category_fires as select f.checker, f.category, count(distinct f.finding_id) as sites, count(t.rowid) as sightings, count(distinct f.prompt_id) as prompts_fired, min(t.at) as first_at, max(t.at) as last_at from finding f left join finding_transition t on t.finding_id = f.finding_id and t.actor = 'check' where f.checker is not null group by f.checker, f.category;",
103 "create view missed_sites as select finding_id, category, path, start_line, start_column, evidence, at from finding_state where state = 'checker_silent';",
104];
105const OPEN = bound(
106 { rows: Object.fromEntries(_TABLES.flatMap(([name, _body, rows]) => (rows === undefined ? [] : [[name, rows]]))) },
107 [
108 'pragma journal_mode=wal;',
109 ..._TABLES.map(([name, body]) => `create temp table ${name}${body};`),
110 ..._INDEXES,
111 'begin immediate;',
112 `.output ${DELTA}`,
113 "select 'drop ' || m.type || ' ' || m.name || ';' from sqlite_master m left join sqlite_temp_master w on w.type = m.type and lower(w.name) = lower(m.name) where m.type = 'view' or (m.type = 'index' and m.sql <> w.sql);",
114 "select 'create table ' || w.name || '__delta' || substr(w.sql, instr(w.sql, '(')) || ';' || char(10) || 'insert into ' || w.name || '__delta(' || coalesce(c.cols, '') || ') select ' || coalesce(c.cols, '') || ' from ' || w.name || ';' || char(10) || 'drop table ' || w.name || ';' || char(10) || 'alter table ' || w.name || '__delta rename to ' || w.name || ';' from sqlite_temp_master w join sqlite_master m on m.type = 'table' and lower(m.name) = lower(w.name) and substr(m.sql, instr(m.sql, '(')) <> substr(w.sql, instr(w.sql, '(')) left join (select t.name as tbl, group_concat(p.name, ', ' order by p.cid) as cols from sqlite_temp_master t, pragma_table_info(t.name, 'temp') p join pragma_table_info(t.name, 'main') q on q.name = p.name group by t.name) c on c.tbl = w.name;",
115 '.output',
116 ..._TABLES.map(([name]) => `drop table temp.${name};`),
117 `.read ${DELTA}`,
118 ..._TABLES.map(([name, body]) => `create table if not exists ${name}${body};`),
119 ..._INDEXES,
120 `.output ${DELTA}`,
121 `select 'delete from ' || l.key || ' where ' || k.name || ' not in (select value ->> ' || quote('$.' || k.name) || ' from json_each(' || quote(l.value) || '))' || coalesce((select group_concat(' and not exists (select 1 from ' || m.name || ' r where r.' || f."from" || ' = ' || l.key || '.' || k.name || ')', '') from sqlite_master m, pragma_foreign_key_list(m.name) f where m.type = 'table' and f."table" = l.key), '') || ';' || char(10) || 'insert into ' || l.key || '(' || (select group_concat(c.name, ', ') from pragma_table_info(l.key) c) || ') select ' || (select group_concat('value ->> ' || quote('$.' || c.name), ', ') from pragma_table_info(l.key) c) || ' from json_each(' || quote(l.value) || ') where true on conflict do ' || coalesce('update set ' || (select group_concat(c.name || ' = excluded.' || c.name, ', ') from pragma_table_info(l.key) c where c.pk = 0), 'nothing') || ';' from json_each(:rows) l, pragma_table_info(l.key) k where k.pk = 1;`,
122 '.output',
123 `.read ${DELTA}`,
124 ..._VIEWS,
125 'commit;',
126 ].join('\n'),
127);
128
129// --- [STATEMENTS] ----------------------------------------------------------------------
130
131const INSERT = `insert into observation(${_OBSERVATION.map(([name]) => name).join(', ')}) values (${_OBSERVATION.map(([name]) => `:${name}`).join(', ')});`;
132const BOUNDARY = `insert into temp.sqlite_parameters(key, value) values (':key', ${_lineage(':main', ':worktree', ':branch')});
133create temp table decision as with r(f) as (select coalesce(max(to_ts), 0) from judged_range where kind = 'edit' and lineage_key = :key), e as materialized (select v.session_id, v.file_path, v.agent_id from unjudged_edits v, r where v.ts > r.f and v.ts <= :to and instr(v.cwd || '/', :worktree || '/') = 1), a as materialized (select count(*) filter (where agent_id in (select agent_id from e)) = 0 as quiet, count(*) filter (where agent_type = :editAgent and instr(cwd || '/', :worktree || '/') = 1) = 0 as rangeIdle, count(*) filter (where agent_type = :categoryAgent and instr(cwd || '/', :worktree || '/') = 1) = 0 as categoryIdle from running_agents), c(category) as (select category from recurring_categories where sites >= :categoryThreshold and not exists (select 1 from json_each(reported_on) where value = :key) order by sites desc, category limit 1), spawn(value) as (select json_object('kind', 'range', 'agent', :editAgent, 'from', r.f, 'to', :to) from r, a where :editThreshold > 0 and a.quiet and a.rangeIdle and exists (select 1 from e where session_id = :session) and (select count(distinct file_path) from e) >= :editThreshold union all select json_object('kind', 'category', 'agent', :categoryAgent, 'category', c.category) from c, a where :categoryThreshold > 0 and a.quiet and a.categoryIdle) select a.quiet and a.rangeIdle as idle, (select json_group_array(json(value)) from spawn) as spawns from a;
134begin immediate;
135create temp table told as select finding_id from open_findings where (select idle from decision) and instr(${_normalized('cast(readfile(path) as text)')}, ntext) > 0 and not exists (select 1 from json_each(delivered_on) where value = :key);
136insert into finding_delivery(finding_id, lineage_key, session_id, channel, delivered_at) select finding_id, :key, :session, 'additionalContext', :to from told;
137select json_object('key', :key, 'spawns', json(spawns), 'findings', (select json_group_array(finding_id) from told)) from decision;
138commit;`;
139const JUDGE = "insert into judged_range(kind, main_worktree, worktree, branch, from_ts, to_ts, agent_id, at) values ('edit', :main, :worktree, :branch, :from, :to, :id, :at);";
140const REPORT = "insert into finding_delivery(finding_id, lineage_key, session_id, agent_id, channel, delivered_at) select finding_id, :key, :session, :id, 'report', :at from confirmed_findings where category = :category;";
141
142// --- [EXPORTS] -------------------------------------------------------------------------
143
144export type { Column };
145export { BOUNDARY, bound, DELTA, INSERT, JUDGE, OPEN, REPORT, sqlite };
146policies/command.ts 144 lines1import { all, bind, decoded, map, type Result } from '../composition.ts';
2import { declared, type Invocation, invocations, operands } from './invocation.ts';
3
4// --- [TYPES] ---------------------------------------------------------------------------
5
6type Scanner = (text: string) => Promise<Result<string>>;
7type Marker = 'looped' | 'polled' | 'fed';
8type Context = Readonly<Record<Marker | 'nested', boolean>>;
9
10interface Span {
11 readonly start: number;
12 readonly end: number;
13}
14interface Command extends Context {
15 readonly words: readonly string[];
16 readonly spans: readonly Span[];
17 readonly invocations: readonly Invocation[];
18 readonly writes: readonly string[];
19 readonly reads: readonly string[];
20}
21interface Script {
22 readonly commands: readonly Command[];
23 readonly clocks: readonly string[];
24}
25interface Capture {
26 readonly text: string;
27 readonly range: { readonly byteOffset: Span };
28}
29interface Owned {
30 readonly single: { readonly BODY: Capture; readonly DEST: Capture };
31}
32
33type Hit = Capture & ({ readonly ruleId: 'command'; readonly metaVariables: { readonly single: { readonly CMD: Capture }; readonly multi: { readonly ARGS?: readonly Capture[] } } } | { readonly ruleId: 'operand' | 'write' | 'read'; readonly metaVariables: Owned } | { readonly ruleId: Marker | 'clock' });
34
35// --- [CONSTANTS] -----------------------------------------------------------------------
36
37const _WORD = /^(?!\d*[<>]|&>)./su;
38const _QUOTED = /(?:\$?(?<quote>["'])|\\)(?<body>(?<=')[^']*|(?<=")(?:[^"\\]|\\.)*|(?<=\\)[\s\S])\k<quote>/gu;
39const _ESCAPED = /\\(?<char>["\\$`\n])/gu;
40const _OWNER = `utils:
41 owner: {any: [{inside: {kind: command, pattern: $BODY}}, {inside: {kind: heredoc_redirect, inside: {kind: redirected_statement, matches: statement}}}, {inside: {kind: redirected_statement, matches: statement}}]}
42 statement: {has: {field: body, matches: last}}
43 last: {any: [{kind: command, pattern: $BODY}, {kind: 'list, pipeline, negated_command', has: {matches: last, nthChild: {position: 1, reverse: true}}}]}`;
44
45// --- [OPERATIONS] ----------------------------------------------------------------------
46
47// --- [RULES]
48
49const _marker = (id: Marker, relation: string): string => `id: ${id}
50language: bash
51utils:
52 read: {kind: command, has: {field: name, regex: '^read$'}}
53 input: {any: [{kind: heredoc_redirect}, {kind: herestring_redirect}, {kind: file_redirect, regex: '^0?<'}]}
54 stage: {any: [{inside: {kind: redirected_statement, field: body, has: {matches: input}}}, {inside: {kind: pipeline}, not: {nthChild: 1}}, {inside: {kind: pipeline, inside: {kind: heredoc_redirect}}}]}
55rule: {kind: command, ${relation}}`;
56const _redirect = (id: 'write' | 'read', relation: string): string => `id: ${id}
57language: bash
58${_OWNER}
59rule: {kind: file_redirect, all: [{has: {field: destination, pattern: $DEST, not: {kind: number}}}, {matches: owner}, ${relation}]}`;
60const SCAN: Invocation = [
61 'ast-grep',
62 'scan',
63 '--stdin',
64 '--config',
65 '/dev/null',
66 '--json=compact',
67 '--inline-rules',
68 [
69 `id: command
70language: bash
71rule: {kind: command, any: [{pattern: $CMD $$$ARGS}, {pattern: $CMD}]}`,
72 `id: operand
73language: bash
74${_OWNER}
75 value: {kind: 'word, string, raw_string, concatenation, simple_expansion, expansion, command_substitution, number'}
76rule: {matches: value, follows: {matches: value}, inside: {kind: file_redirect, matches: owner}}`,
77 _marker('looped', 'inside: {kind: do_group, stopBy: end}'),
78 _marker('polled', "inside: {stopBy: end, any: [{kind: while_statement, not: {has: {field: condition, any: [{matches: read}, {has: {stopBy: end, matches: read}}]}}}, {kind: c_style_for_statement, not: {has: {field: condition, regex: '.'}}}]}"),
79 _marker('fed', 'any: [{has: {matches: input}}, {matches: stage}, {inside: {stopBy: end, matches: stage}}]'),
80 _redirect('write', "{not: {regex: '^\\d*<'}}"),
81 _redirect('read', "{regex: '^\\d*<'}"),
82 `id: clock
83language: bash
84rule: {any: [{kind: variable_name, regex: '^(SECONDS|EPOCHREALTIME|EPOCHSECONDS)$'}, {kind: command, has: {field: name, regex: '^date$'}, inside: {kind: arithmetic_expansion, stopBy: end}}]}`,
85 ].join('\n---\n'),
86];
87
88// --- [WORDS]
89
90const _unquoted = (_match: string, quote: string | undefined, body: string): string => (quote === '"' ? body.replace(_ESCAPED, '$<char>') : body);
91const _owner = (hit: Hit): number => (hit.ruleId === 'operand' || hit.ruleId === 'write' || hit.ruleId === 'read' ? hit.metaVariables.single.BODY : hit).range.byteOffset.start;
92const _destinations = (own: readonly Hit[], id: 'write' | 'read'): readonly string[] => own.flatMap((other) => (other.ruleId === id ? [other.metaVariables.single.DEST.text.replace(_QUOTED, _unquoted)] : []));
93
94const _script = (hits: readonly Hit[], context: Context): Script => {
95 const sorted = hits.toSorted((left, right) => left.range.byteOffset.start - right.range.byteOffset.start);
96 return {
97 commands: [...Map.groupBy(sorted, _owner).values()].flatMap((own): readonly Command[] => {
98 const hit = own.find((other) => other.ruleId === 'command');
99 if (hit === undefined) {
100 return [];
101 }
102 const rules = new Set(own.map((other) => other.ruleId));
103 const marked = (marker: Marker): boolean => context[marker] || rules.has(marker);
104 const tokens = [hit.metaVariables.single.CMD, ...(hit.metaVariables.multi.ARGS ?? []), ...own.filter((other) => other.ruleId === 'operand')].filter(({ text }) => _WORD.test(text));
105 const words = tokens.map(({ text }) => text.replace(_QUOTED, _unquoted));
106 return [{ words, spans: tokens.map(({ range }) => range.byteOffset), invocations: invocations(words), nested: context.nested, looped: marked('looped'), polled: marked('polled'), fed: marked('fed'), writes: _destinations(own, 'write'), reads: _destinations(own, 'read') }];
107 }),
108 clocks: sorted.flatMap((hit) => (hit.ruleId === 'clock' ? [hit.text] : [])),
109 };
110};
111
112const _bodies = (command: Command): readonly string[] => {
113 const invocation = command.invocations.at(-1);
114 if (invocation === undefined) {
115 return [];
116 }
117 const [program, ...rest] = invocation;
118 const { bodies, shell } = declared(program);
119 const { inputs, options, values } = operands(invocation);
120 return [...(program === 'eval' ? [rest.join(' ')] : []), ...(shell === true && options.includes('-c') ? inputs.slice(0, 1) : []), ...(bodies === undefined ? [] : [...inputs, ...values.flatMap(([name, value]) => (bodies.includes(name) ? [value] : []))])];
121};
122
123// --- [PARSE]
124
125const _joined = (scripts: readonly Script[]): Script => ({ commands: scripts.flatMap(({ commands }) => commands), clocks: scripts.flatMap(({ clocks }) => clocks) });
126
127const _parse = async (scan: Scanner, text: string, context: Context): Promise<Result<Script>> =>
128 bind(decoded<readonly Hit[]>('ast-grep', await scan(text)), async (hits): Promise<Result<Script>> => {
129 const script = _script(hits, context);
130 const expanded = await Promise.all(script.commands.map(async (command): Promise<Result<Script>> => map(all(await Promise.all(_bodies(command).map((body) => _parse(scan, body, { ...command, nested: true })))), (inner) => _joined([{ commands: [command], clocks: [] }, ...inner]))));
131 return map(all(expanded), (scripts) => _joined([{ commands: [], clocks: script.clocks }, ...scripts]));
132 });
133
134const parse = (scan: Scanner, command: string): Promise<Result<Script>> => _parse(scan, command, { looped: false, polled: false, fed: false, nested: false });
135
136// --- [SPANS]
137
138const offset = (command: Command, index: number): number => command.words.length - command.invocations.slice(index).reduce((count, invocation) => count + invocation.length, 0);
139
140// --- [EXPORTS] -------------------------------------------------------------------------
141
142export type { Command, Scanner, Script };
143export { offset, parse, SCAN };
144policies/invocation.ts 501 lines1// --- [TYPES] ---------------------------------------------------------------------------
2
3type Invocation = readonly [string, ...string[]];
4type Given = readonly [name: string, value: string];
5
6interface Program {
7 readonly valued?: readonly string[];
8 readonly arities?: ReadonlyMap<string, number>;
9 readonly bodies?: readonly string[];
10 readonly flags?: readonly string[];
11 readonly ends?: readonly string[];
12 readonly leading?: number;
13 readonly leadingOptions?: readonly string[];
14 readonly recursive?: readonly string[];
15 readonly stdin?: 'default' | 'always';
16 readonly stdinless?: readonly string[];
17 readonly informational?: readonly string[];
18 readonly wholeWords?: true;
19 readonly shell?: true;
20 readonly runs?: readonly (readonly string[])[] | '--';
21}
22interface Operands {
23 readonly inputs: readonly string[];
24 readonly options: readonly string[];
25 readonly values: readonly Given[];
26 readonly positions: readonly { readonly at: number; readonly names: readonly string[] }[];
27}
28interface ParsedOption {
29 readonly names: readonly string[];
30 readonly taken: number;
31 readonly joined: readonly string[];
32}
33
34// --- [CONSTANTS] -----------------------------------------------------------------------
35
36const _ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/u;
37const _PYTHON: Program = { valued: ['-W', '-X'], stdin: 'default', stdinless: ['-c', '-m'], informational: ['-V'] };
38const _SHELL: Program = { valued: ['-o', '-O'], stdin: 'default', stdinless: ['-c'], shell: true };
39const _AST_GREP: Program = {
40 valued: [
41 '-c',
42 '--config',
43 '-r',
44 '--rule',
45 '--rewrite',
46 '--inline-rules',
47 '-p',
48 '--pattern',
49 '--selector',
50 '--strictness',
51 '-k',
52 '--kind',
53 '-l',
54 '--lang',
55 '--format',
56 '--report-style',
57 '--filter',
58 '--min-severity',
59 '--no-ignore',
60 '--globs',
61 '-j',
62 '--threads',
63 '--color',
64 '--heading',
65 '--inspect',
66 '-A',
67 '--after',
68 '-B',
69 '--before',
70 '-C',
71 '--context',
72 '--max-results',
73 '--items',
74 '--type',
75 '--match',
76 '--view',
77 '--outline-rules',
78 '-t',
79 '--test-dir',
80 '--snapshot-dir',
81 '-f',
82 ],
83};
84const _SQL: Program = {
85 valued: ['-cmd', '-init', '-newline', '-nullvalue', '-separator', '-storage-version'],
86 leading: 1,
87 stdin: 'default',
88 stdinless: ['-c', '-s', '-f', '-no-stdin'],
89 informational: ['-h', '-help', '-version'],
90 wholeWords: true,
91};
92const _PROGRAMS: ReadonlyMap<string, Program> = new Map(
93 Object.entries({
94 sudo: {
95 valued: ['-C', '-D', '-g', '-h', '-p', '-R', '-T', '-U', '-u', '--close-from', '--chdir', '--group', '--host', '--prompt', '--chroot', '--command-timeout', '--other-user', '--user'],
96 runs: [[]],
97 },
98 doas: { valued: ['-C', '-u'], runs: [[]] },
99 env: { valued: ['-C', '-P', '-S', '-u'], runs: [[]] },
100 command: { runs: [[]] },
101 exec: { valued: ['-a'], runs: [[]] },
102 nice: { valued: ['-n'], runs: [[]] },
103 nohup: { runs: [[]] },
104 setsid: { runs: [[]] },
105 stdbuf: { valued: ['-e', '-i', '-o'], runs: [[]] },
106 timeout: { valued: ['-k', '-s', '--kill-after', '--signal'], leading: 1, runs: [[]] },
107 time: { valued: ['-o'], runs: [[]] },
108 xargs: { valued: ['-E', '-I', '-J', '-L', '-n', '-P', '-R', '-S', '-s'], stdin: 'always', runs: [[]] },
109 caffeinate: { valued: ['-t', '-w'], runs: [[]] },
110 arch: { valued: ['-arch', '-d'], runs: [[]] },
111 xcrun: { valued: ['--sdk', '--toolchain'], runs: [[]] },
112 lockf: { valued: ['-t'], leading: 1, runs: [[]] },
113 npx: { runs: [[]] },
114 npm: { runs: [['exec'], ['x']] },
115 pnpm: { runs: [[], ['exec'], ['dlx']] },
116 uv: {
117 valued: [
118 '--extra',
119 '--no-extra',
120 '--group',
121 '--no-group',
122 '--only-group',
123 '--no-editable-package',
124 '--env-file',
125 '-w',
126 '--with',
127 '--with-editable',
128 '--with-requirements',
129 '--package',
130 '--python-platform',
131 '--from',
132 '-c',
133 '--constraints',
134 '-b',
135 '--build-constraints',
136 '--overrides',
137 '--torch-backend',
138 '--bump',
139 '--output-format',
140 '--index',
141 '--default-index',
142 '-i',
143 '--index-url',
144 '--extra-index-url',
145 '-f',
146 '--find-links',
147 '--index-strategy',
148 '--keyring-provider',
149 '-P',
150 '--upgrade-package',
151 '--upgrade-group',
152 '--resolution',
153 '--prerelease',
154 '--prerelease-package',
155 '--fork-strategy',
156 '--exclude-newer',
157 '--exclude-newer-package',
158 '--no-sources-package',
159 '--reinstall-package',
160 '--link-mode',
161 '-C',
162 '--config-setting',
163 '--config-settings-package',
164 '--no-build-isolation-package',
165 '--no-build-package',
166 '--no-binary-package',
167 '--cache-dir',
168 '--refresh-package',
169 '-p',
170 '--python',
171 '--color',
172 '--allow-insecure-host',
173 '--directory',
174 '--project',
175 '--config-file',
176 ],
177 runs: [['run'], ['tool', 'run']],
178 },
179 poetry: { runs: [['run']] },
180 hatch: { runs: [['run']] },
181 mise: { runs: '--' },
182 doppler: { runs: '--' },
183 op: { runs: '--' },
184 hyperfine: {
185 valued: [
186 '-w',
187 '--warmup',
188 '-m',
189 '--min-runs',
190 '-M',
191 '--max-runs',
192 '-r',
193 '--runs',
194 '--reference-name',
195 '-D',
196 '--parameter-step-size',
197 '-S',
198 '--shell',
199 '--style',
200 '--sort',
201 '-u',
202 '--time-unit',
203 '--export-asciidoc',
204 '--export-csv',
205 '--export-json',
206 '--export-markdown',
207 '--export-orgmode',
208 '--output',
209 '--input',
210 '-n',
211 '--command-name',
212 ],
213 arities: new Map(Object.entries({ '-P': 3, '--parameter-scan': 3, '-L': 2, '--parameter-list': 2 })),
214 bodies: ['-s', '--setup', '--reference', '-p', '--prepare', '-C', '--conclude', '-c', '--cleanup'],
215 },
216 git: { valued: ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'] },
217 'ast-grep': _AST_GREP,
218 sg: _AST_GREP,
219 fd: {
220 valued: [
221 '-C',
222 '--base-directory',
223 '--search-path',
224 '-d',
225 '--max-depth',
226 '--min-depth',
227 '--exact-depth',
228 '-E',
229 '--exclude',
230 '-t',
231 '--type',
232 '-e',
233 '--extension',
234 '-S',
235 '--size',
236 '--changed-within',
237 '--changed-before',
238 '-o',
239 '--owner',
240 '--format',
241 '--batch-size',
242 '--ignore-file',
243 '-c',
244 '--color',
245 '--ignore-contain',
246 '-j',
247 '--threads',
248 '--max-results',
249 '--path-separator',
250 '--and',
251 ],
252 ends: ['-x', '--exec', '-X', '--exec-batch'],
253 leading: 1,
254 },
255 rg: {
256 valued: [
257 '-A',
258 '--after-context',
259 '-B',
260 '--before-context',
261 '-C',
262 '--context',
263 '-d',
264 '--max-depth',
265 '-E',
266 '--encoding',
267 '-e',
268 '--regexp',
269 '-f',
270 '--file',
271 '-g',
272 '--glob',
273 '--iglob',
274 '-j',
275 '--threads',
276 '-M',
277 '--max-columns',
278 '-m',
279 '--max-count',
280 '-r',
281 '--replace',
282 '-t',
283 '--type',
284 '-T',
285 '--type-not',
286 '--type-add',
287 '--type-clear',
288 '--max-filesize',
289 '--color',
290 '--colors',
291 '--sort',
292 '--sortr',
293 '--path-separator',
294 '--pre',
295 '--pre-glob',
296 '--ignore-file',
297 '--dfa-size-limit',
298 '--regex-size-limit',
299 '--engine',
300 '--field-context-separator',
301 '--field-match-separator',
302 '--context-separator',
303 '--hostname-bin',
304 '--hyperlink-format',
305 '--generate',
306 ],
307 leading: 1,
308 leadingOptions: ['-e', '--regexp', '-f', '--file', '--files', '--type-list'],
309 },
310 grep: {
311 valued: [
312 '-A',
313 '--after-context',
314 '-B',
315 '--before-context',
316 '-C',
317 '--context',
318 '-d',
319 '--directories',
320 '-D',
321 '--devices',
322 '-e',
323 '--regexp',
324 '-f',
325 '--file',
326 '-g',
327 '--glob',
328 '--iglob',
329 '-J',
330 '--jobs',
331 '-M',
332 '--file-magic',
333 '-m',
334 '--max-count',
335 '-N',
336 '--neg-regexp',
337 '-O',
338 '--file-extension',
339 '-t',
340 '--file-type',
341 '--include',
342 '--exclude',
343 '--include-dir',
344 '--exclude-dir',
345 '--include-from',
346 '--exclude-from',
347 '--label',
348 '--binary-files',
349 '--color',
350 '--colour',
351 '--colors',
352 '--colours',
353 '--encoding',
354 '--format',
355 '--replace',
356 '--from',
357 '--config',
358 ],
359 leading: 1,
360 leadingOptions: ['-e', '--regexp', '-f', '--file', '-N', '--neg-regexp'],
361 recursive: ['-r', '-R', '--recursive', '--dereference-recursive'],
362 stdin: 'default',
363 },
364 du: { valued: ['-B', '-I', '-d', '-t'] },
365 tree: {
366 valued: ['-L', '--level', '-I', '--ignore-glob', '-s', '--sort', '-t', '--time', '-w', '--width', '-F', '--classify', '--absolute', '--color', '--colour', '--color-scale', '--color-scale-mode', '--icons', '--hyperlink', '--time-style'],
367 },
368 ls: { valued: ['-D'], recursive: ['-R'] },
369 lsof: { valued: ['-c', '-d', '-D', '-f', '-F', '-g', '-i', '-L', '-o', '-p', '-r', '-s', '-S', '-T', '-u', '-x', '+d', '+D'] },
370 wait: { valued: ['-p'] },
371 cat: { stdin: 'default' },
372 wc: { stdin: 'default' },
373 head: { valued: ['-n', '-c'], stdin: 'default' },
374 tail: { valued: ['-b', '-c', '-n'], stdin: 'default' },
375 sort: { valued: ['-k', '-t', '-o', '-S', '-T', '--batch-size', '--parallel', '--random-source', '--compress-program'], stdin: 'default', stdinless: ['--files0-from'] },
376 uniq: { valued: ['-f', '-s'], stdin: 'default' },
377 cut: { valued: ['-b', '-c', '-f', '-d'], stdin: 'default' },
378 shasum: { valued: ['-a'], stdin: 'default' },
379 md5: { valued: ['-s'], stdin: 'default', stdinless: ['-s'] },
380 tr: { stdin: 'always' },
381 tee: { stdin: 'always' },
382 pbcopy: { stdin: 'always' },
383 read: { stdin: 'always', stdinless: ['-u'] },
384 base64: { valued: ['-b', '-i', '-o', '--break', '--input', '--output'], stdin: 'always', stdinless: ['-i', '--input'] },
385 sed: { valued: ['-e', '-f'], leading: 1, leadingOptions: ['-e', '-f', '--expression', '--file'], stdin: 'default' },
386 awk: { valued: ['-F', '-v', '-f'], leading: 1, leadingOptions: ['-f'], stdin: 'default' },
387 jq: { valued: ['-L', '--library-path', '--indent'], arities: new Map(Object.entries({ '--arg': 2, '--argjson': 2, '--slurpfile': 2, '--rawfile': 2 })), leading: 1, stdin: 'default', stdinless: ['-n', '--null-input'] },
388 yq: { valued: ['-o', '-p', '-I', '--output-format', '--input-format', '--indent', '--from-file'], leading: 1, leadingOptions: ['--from-file'], stdin: 'default', stdinless: ['-n', '--null-input'] },
389 sd: {
390 valued: ['-f', '-n', '--flags', '--max-replacements'],
391 flags: ['-p', '--preview', '-F', '--fixed-strings', '-s', '-A', '--across', '-h', '--help', '-V', '--version'],
392 leading: 2,
393 stdin: 'default',
394 informational: ['-h', '-V'],
395 },
396 yamlfmt: { valued: ['-conf', '-debug', '-exclude', '-extensions', '-formatter', '-gitignore_path', '-match_type', '-output_format'], wholeWords: true },
397 node: { stdin: 'default', stdinless: ['-e', '-p', '--eval', '--print', '--test', '--run'], informational: ['-v'] },
398 dotnet: { informational: ['-h', '-?', '-help', '-version'], wholeWords: true },
399 nx: { valued: ['-t', '--targets', '--target', '-p', '--projects', '-c', '--configuration', '--base', '--head', '--exclude', '--output-style'] },
400 python: _PYTHON,
401 python3: _PYTHON,
402 bash: _SHELL,
403 sh: _SHELL,
404 zsh: _SHELL,
405 dash: _SHELL,
406 ksh: _SHELL,
407 sqlite3: _SQL,
408 duckdb: _SQL,
409 } satisfies Record<string, Program>),
410);
411
412// --- [OPERATIONS] ----------------------------------------------------------------------
413
414// --- [OPTIONS]
415
416const declared = (program: string): Program => _PROGRAMS.get(program) ?? {};
417
418const _arity = (row: Program, name: string): number => row.arities?.get(name) ?? (row.valued?.includes(name) === true || row.bodies?.includes(name) === true ? 1 : 0);
419
420const _option = (row: Program, word: string): ParsedOption => {
421 const cut = word.indexOf('=');
422 const given = cut < 0 ? word : word.slice(0, cut);
423 const head = row.wholeWords === true && given.startsWith('--') ? given.slice(1) : given;
424 const names = head.startsWith('--') || row.wholeWords === true || _arity(row, head) > 0 ? [head] : [...head.slice(1)].map((letter) => `${head.charAt(0)}${letter}`);
425 const arities = names.map((name) => _arity(row, name));
426 const at = arities.findIndex((arity) => arity > 0);
427 const [taken = 0] = at === names.length - 1 && cut < 0 ? arities.slice(at) : [];
428 return { names: at < 0 ? names : names.slice(0, at + 1), taken, joined: cut < 0 ? [] : [word.slice(cut + 1)] };
429};
430
431const known = (program: string, word: string): boolean => {
432 const row = declared(program);
433 const { flags } = row;
434 return flags === undefined || _option(row, word).names.every((name) => flags.includes(name) || _arity(row, name) > 0);
435};
436
437const firstOperand = (invocation: Invocation, index: number): number => {
438 const [program] = invocation;
439 const word = invocation[index];
440 return word === undefined || word === '-' || word === '--' || !word.startsWith('-') || !known(program, word) ? index : firstOperand(invocation, index + 1 + _option(declared(program), word).taken);
441};
442
443const _split = (program: string, args: readonly string[], at: number): Operands => {
444 const empty: Operands = { inputs: [], options: [], values: [], positions: [] };
445 const row = declared(program);
446 const [head, ...rest] = args;
447 const flagged = head !== undefined && head !== '-' && head !== '--' && (head.startsWith('-') || (head.startsWith('+') && row.valued?.some((name) => name.startsWith('+')) === true)) && known(program, head);
448 const { names, taken, joined } = flagged ? _option(row, head) : { names: [], taken: 0, joined: [] };
449 const tail = head === undefined || head === '--' || names.some((name) => row.ends?.includes(name) === true) ? empty : _split(program, rest.slice(taken), at + 1 + taken);
450 return head === '--'
451 ? { ...empty, inputs: rest }
452 : {
453 inputs: [...(head === undefined || flagged ? [] : [head]), ...tail.inputs],
454 options: [...names, ...tail.options],
455 values: [...names.slice(-1).flatMap((name) => [...joined, ...rest.slice(0, taken)].map((value): Given => [name, value])), ...tail.values],
456 positions: [...(names.length === 0 ? [] : [{ at, names }]), ...tail.positions],
457 };
458};
459
460const operands = ([program, ...args]: Invocation): Operands => {
461 const row = declared(program);
462 const split = _split(program, args, 1);
463 return { ...split, inputs: split.inputs.slice(split.options.some((name) => row.leadingOptions?.includes(name) === true) ? 0 : (row.leading ?? 0)) };
464};
465
466// --- [INVOCATIONS]
467
468const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
469
470const _wrapped = (program: string, words: readonly string[], pending: readonly (readonly string[])[]): readonly string[] => {
471 const row = declared(program);
472 const [head, ...rest] = words;
473 if (head?.startsWith('-') === true) {
474 return _wrapped(program, rest.slice(_option(row, head).taken), pending);
475 }
476 const deeper = pending.flatMap(([first, ...more]) => (first !== undefined && first === head ? [more] : []));
477 if (deeper.length > 0) {
478 return _wrapped(program, rest, deeper);
479 }
480 return pending.some((path) => path.length === 0) ? words.slice(row.leading ?? 0) : [];
481};
482
483const _chain = (program: string, args: readonly string[]): readonly Invocation[] => {
484 const { runs = [] } = declared(program);
485 const launched = args.slice(args.includes('--') ? args.indexOf('--') + 1 : args.length);
486 const inner = runs === '--' ? launched : _wrapped(program, args, runs);
487 return [[program, ...args.slice(0, args.length - inner.length)], ...invocations(inner)];
488};
489
490const invocations = ([head, ...rest]: readonly string[]): readonly Invocation[] => {
491 if (head === undefined) {
492 return [];
493 }
494 return _ENV_ASSIGN.test(head) ? invocations(rest) : _chain(basename(head), rest);
495};
496
497// --- [EXPORTS] -------------------------------------------------------------------------
498
499export type { Invocation, Operands };
500export { basename, declared, firstOperand, invocations, known, operands };
501policies/policies.ts 428 lines1import { none, type Option, rendered, some } from '../composition.ts';
2import { type Command, offset, parse, type Scanner, type Script } from './command.ts';
3import { basename, declared, firstOperand, type Invocation, known, type Operands, operands } from './invocation.ts';
4
5// --- [TYPES] ---------------------------------------------------------------------------
6
7type Refinement = (args: readonly string[], existing: readonly string[], reason: string) => readonly string[];
8type GitCall = { readonly kind: 'aliased' } | GitSubcommand;
9type Decision = { readonly kind: 'deny'; readonly reason: string } | { readonly kind: 'allow'; readonly rewrite: Option<Rewrite> };
10
11interface Host {
12 readonly scan: Scanner;
13 readonly repo: () => Promise<Option<string>>;
14 readonly exists: (path: string) => Promise<boolean>;
15 readonly real: (path: string) => Promise<Option<string>>;
16 readonly home: () => Promise<Option<string>>;
17}
18interface GitRow {
19 readonly reason: string;
20 readonly any?: true;
21 readonly words?: readonly string[];
22 readonly prefixes?: readonly string[];
23 readonly safe?: readonly string[];
24 readonly refine?: Refinement;
25}
26interface GitSubcommand {
27 readonly kind: 'subcommand';
28 readonly key: string;
29 readonly row: GitRow;
30 readonly args: readonly string[];
31}
32interface Splice {
33 readonly start: number;
34 readonly end: number;
35 readonly text: string;
36}
37interface Build {
38 readonly at: number;
39 readonly subcommand: string;
40}
41interface Rewrite {
42 readonly command: string;
43 readonly notice: string;
44 readonly context: string;
45}
46
47// --- [CONSTANTS] -----------------------------------------------------------------------
48
49const _CLOUD = 'Library/CloudStorage';
50const _WORKTREE = 'creates a second checkout with its own metadata and sync cost. Work in the main checkout';
51const _HOME = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u;
52const _PRIMARY = /^(?:-.{2,}|\(|!)$/u;
53const _BREAK = /\n|(?<!\\)(?:\\\\)*\\n/u;
54const _TRAILING = /\/$/u;
55const _BINLOG = /^(?:--?|\/)(?:bl|binarylogger)(?::|$)/iu;
56const _NX_ENTRY = /(?:^|\/)nx\/bin\/nx(?:\.js)?$/u;
57const _DOTNET: ReadonlyMap<string, string> = new Map(
58 Object.entries({
59 build: 'compiles outside the Nx task graph and its cache. Run nx run <project>:build, -- forwarding MSBuild switches and --skip-nx-cache forcing the run, then read the binary log under .artifacts/dotnet/binlog/ through the binlog MCP',
60 test: 'runs tests outside the Nx task graph and its cache. Run nx run <project>:test',
61 format: 'checks or rewrites files outside the Nx task graph. Run nx run rasm:lint:dotnet-format to check or nx run rasm:format to write',
62 }),
63);
64const _MINI_CONFIGS: readonly (readonly [RegExp, string])[] = [
65 [/^project\.json$/u, 'package.json'],
66 [/^\.nxignore$/u, '.gitignore'],
67 [/^(?:\.mise(?:\..+)?\.toml|mise\..+\.toml|\.miserc\.toml|\.rtx\.toml|\.tool-versions|\.nvmrc|\.(?:node|python)-version)$/u, 'mise.toml'],
68 [/^tsconfig\.(?!base\.json$).+\.json$/u, 'tsconfig.json'],
69 [/^(?:\.?ruff\.toml|\.?mypy\.ini|pytest\.ini|tox\.ini|setup\.cfg)$/u, 'pyproject.toml'],
70 [/^biome\.jsonc$/u, 'biome.json'],
71 [/^\.yamllint(?:\.yml)?$/u, '.yamllint.yaml'],
72];
73
74// --- [OPERATIONS] ----------------------------------------------------------------------
75
76// --- [GIT]
77
78const _reset: Refinement = (args, existing) => {
79 const targets = args.filter((word) => !word.startsWith('-'));
80 const [target] = targets;
81 return target === undefined || args.includes('--') || targets.some((named) => existing.includes(named)) ? [] : [`git reset ${target} moves HEAD and drops commits from the branch`];
82};
83
84const _restore: Refinement = (args, _existing, reason) => {
85 const { options } = operands(['git', ...args]);
86 return options.some((name) => name === '-S' || name === '--staged') && !options.some((name) => name === '-W' || name === '--worktree') ? [] : [`git restore ${reason}`];
87};
88
89const _config: Refinement = (args, _existing, reason) => {
90 const alias = args.find((word) => word.startsWith('alias.'));
91 return alias !== undefined && args.slice(args.indexOf(alias) + 1).some((word) => !word.startsWith('-')) ? [`git config ${alias} ${reason}`] : [];
92};
93
94const _checkout: Refinement = (args, existing) => {
95 const [first, ...more] = args.filter((word) => word === '-' || !word.startsWith('-'));
96 const separated = args.includes('--');
97 if (!separated && args.some((word) => ['-b', '--orphan', '-t', '--track', '--detach'].includes(word))) {
98 return [];
99 }
100 if (separated || more.length > 0 || first === '.' || first?.startsWith(':') === true) {
101 return ['git checkout with a pathspec overwrites working-tree files. Edit the files'];
102 }
103 return first !== undefined && first !== '-' && existing.includes(first) ? [`git checkout ${first} names an existing path it overwrites. Edit the file`] : [];
104};
105
106const _GIT: ReadonlyMap<string, GitRow> = new Map(
107 Object.entries({
108 branch: { reason: 'deletes or force-moves a branch', words: ['-d', '-D', '-M', '--delete'], prefixes: ['--force'] },
109 checkout: { reason: 'discards local changes', words: ['-f', '-B', '-p', '--patch', '--ours', '--theirs'], prefixes: ['--force'], refine: _checkout },
110 clean: { reason: 'deletes untracked files. Remove the named files with rm', any: true },
111 config: { reason: 'defines a git alias that can hide a refused subcommand', refine: _config },
112 push: { reason: 'rewrites or deletes remote history', words: ['-f', '-d', '--delete', '--mirror', '--prune'], prefixes: ['--force', '+', ':'] },
113 rebase: { reason: 'rewrites commits other agents can hold', any: true },
114 reflog: { reason: 'erases reflog entries, the last recovery path', words: ['delete', 'drop', 'expire'] },
115 reset: { reason: 'wipes working-tree or index state', words: ['--hard', '--merge', '--keep'], refine: _reset },
116 restore: { reason: 'overwrites working-tree files. Edit the files', refine: _restore },
117 revert: { reason: 'reverses committed history', any: true },
118 stash: { reason: 'hides uncommitted work other agents depend on. Commit to a branch', any: true, safe: ['list', 'show'] },
119 switch: { reason: 'discards local changes', words: ['-f', '-C', '--discard-changes'], prefixes: ['--force'] },
120 worktree: { reason: _WORKTREE, any: true, safe: ['list'] },
121 } satisfies Record<string, GitRow>),
122);
123
124const _calls = (commands: readonly Command[]): readonly GitCall[] =>
125 commands
126 .flatMap((command) => command.invocations)
127 .filter(([program]) => program === 'git')
128 .flatMap((invocation): readonly GitCall[] => {
129 const index = firstOperand(invocation, 1);
130 const [key, ...args] = invocation.slice(index);
131 if (invocation.slice(1, index).some((word) => word.startsWith('alias.'))) {
132 return [{ kind: 'aliased' }];
133 }
134 const row = key === undefined ? undefined : _GIT.get(key);
135 return key === undefined || row === undefined ? [] : [{ kind: 'subcommand', key, row, args }];
136 });
137
138const _refusals = ({ key, row, args }: GitSubcommand, existing: readonly string[]): readonly string[] => {
139 const [first] = args;
140 if (first !== undefined && row.safe?.includes(first) === true) {
141 return [];
142 }
143 if (row.any === true) {
144 return [`git ${key} ${row.reason}`];
145 }
146 const hit = args.find((word) => row.words?.includes(word) === true || row.prefixes?.some((prefix) => word.startsWith(prefix)) === true);
147 return hit === undefined ? (row.refine?.(args, existing, row.reason) ?? []) : [`git ${key} ${hit} ${row.reason}`];
148};
149
150const _git = (calls: readonly GitCall[], existing: readonly string[]): readonly string[] => calls.flatMap((call) => (call.kind === 'aliased' ? ['inline git alias can hide a refused subcommand'] : _refusals(call, existing)));
151
152// --- [STDIN]
153
154const _informational = (program: string, options: readonly string[]): boolean => options.some((name) => name === '--help' || name === '--version' || declared(program).informational?.includes(name) === true);
155
156const _reads = (invocation: Invocation): boolean => {
157 const [program] = invocation;
158 const { stdin, stdinless, recursive } = declared(program);
159 const { inputs, options } = operands(invocation);
160 const fed = _informational(program, options) || options.some((name) => stdinless?.includes(name) === true || recursive?.includes(name) === true);
161 return stdin !== undefined && !fed && (stdin === 'always' || inputs.length === 0 || inputs.includes('-'));
162};
163
164const _stdin = (commands: readonly Command[]): readonly string[] => commands.flatMap((command) => (!command.fed && command.invocations.some(_reads) ? [`${command.words.join(' ')} reads standard input and nothing feeds it. Pass an operand, pipe, heredoc, herestring, or input redirect`] : []));
165
166// --- [WAIT]
167
168const _waits = (command: Command): readonly string[] => {
169 const waiters: ReadonlyMap<string, (parsed: Operands, wraps: boolean) => boolean> = new Map(
170 Object.entries({
171 sleep: () => true,
172 pwait: () => true,
173 wait: ({ inputs }) => inputs.length > 0,
174 caffeinate: ({ options }, wraps) => !wraps || options.includes('-w'),
175 tail: ({ options }) => options.includes('--pid'),
176 lsof: ({ options }) => options.some((name) => name === '-r' || name === '+r'),
177 } satisfies Record<string, (parsed: Operands, wraps: boolean) => boolean>),
178 );
179 const chain = command.invocations;
180 const line = command.words.join(' ');
181 return [...(chain.some((invocation, index) => waiters.get(invocation[0])?.(operands(invocation), index < chain.length - 1) === true) ? [`${line} waits`] : []), ...(command.polled ? [`${line} repeats until the loop condition changes`] : [])];
182};
183
184const _wait = (commands: readonly Command[]): readonly string[] => {
185 const reasons = commands.flatMap(_waits);
186 return reasons.length === 0 ? [] : [...reasons, "Act on the command's own exit, or run it with run_in_background and act on its completion notification"];
187};
188
189// --- [SCRIPT]
190
191const _writes = (command: Command): readonly string[] => command.invocations.slice(-1).flatMap(([program, ...rest]) => (program === 'echo' || program === 'printf' || (program === 'cat' && rest.length === 0) ? command.writes.filter((path) => !path.startsWith('/dev/')) : []));
192
193const _script = ({ commands, clocks }: Script): readonly string[] => {
194 const timer = "times by hand. Time it with hyperfine -N -r <runs> '<command>'";
195 return [
196 ...commands.flatMap((command, index) => {
197 const written = commands.slice(0, index).flatMap(_writes);
198 return [
199 ...[...command.reads, ...command.words].filter((path) => written.includes(path)).map((path) => `${path} written then read in one call. Read it in a later call`),
200 ...(command.looped ? _writes(command).map((path) => `${path} appended in a loop. Write it once after the loop`) : []),
201 ...(command.invocations.some(([program]) => program === 'time') ? [`${command.words.join(' ')} ${timer}`] : []),
202 ];
203 }),
204 ...clocks.map((clock) => `${clock} ${timer}`),
205 ];
206};
207
208// --- [DOTNET]
209
210const _subcommand = (invocation: Invocation): Option<string> => {
211 const [program, subcommand] = invocation;
212 return program !== 'dotnet' || subcommand === undefined || _informational(program, operands(invocation).options) ? none : some(subcommand);
213};
214
215const _dotnet = (commands: readonly Command[]): readonly string[] =>
216 commands
217 .flatMap((command) => command.invocations)
218 .flatMap((invocation) => {
219 const subcommand = _subcommand(invocation);
220 const reason = subcommand.kind === 'some' ? _DOTNET.get(subcommand.value) : undefined;
221 return subcommand.kind === 'none' || reason === undefined ? [] : [`dotnet ${subcommand.value} ${reason}`];
222 });
223
224// --- [WALK]
225
226const _starts = (invocation: Invocation): readonly string[] => {
227 const [program, ...args] = invocation;
228 const { inputs, options, values } = operands(invocation);
229 const here = (words: readonly string[]): readonly string[] => (words.length === 0 ? ['.'] : words);
230 const given = (names: readonly string[]): readonly string[] => values.flatMap(([name, value]) => (names.includes(name) ? [value] : []));
231 const recursive = (): readonly string[] => (options.some((name) => declared(program).recursive?.includes(name) === true) ? here(inputs) : []);
232 const walkers: ReadonlyMap<string, () => readonly string[]> = new Map(
233 Object.entries({
234 fd: () => here([...inputs, ...given(['-C', '--base-directory', '--search-path'])]),
235 find: () => {
236 const primary = args.findIndex((word) => _PRIMARY.test(word));
237 return here(operands([program, ...args.slice(0, primary < 0 ? args.length : primary)]).inputs);
238 },
239 rg: () => here(inputs),
240 grep: () => (given(['-d', '--directories']).includes('recurse') ? here(inputs) : recursive()),
241 du: () => here(inputs),
242 tree: () => here(inputs),
243 ls: recursive,
244 lsof: () => given(['+D']),
245 } satisfies Record<string, () => readonly string[]>),
246 );
247 return walkers.get(program)?.() ?? [];
248};
249
250const _located = async (real: Host['real'], path: string): Promise<Option<string>> => {
251 const cut = path.lastIndexOf('/');
252 const own = await real(path);
253 const found = own.kind === 'some' ? own : await real(cut < 0 ? '.' : path.slice(0, cut + 1)).then((folder) => (folder.kind === 'some' ? some(`${folder.value.replace(_TRAILING, '')}/${path.slice(cut + 1)}`) : none));
254 return found.kind === 'some' ? some(`${found.value.replace(_TRAILING, '')}/`) : none;
255};
256
257const _descends = (invocation: Invocation, cloud: string, places: ReadonlyMap<string, string>): boolean => {
258 const folders = _starts(invocation).flatMap((word) => places.get(word) ?? []);
259 const excluded = invocation.some((word) => word.includes(basename(_CLOUD)));
260 return folders.some((folder) => folder.startsWith(cloud)) || (!excluded && folders.some((folder) => cloud.startsWith(folder)));
261};
262
263const _walk = async (real: Host['real'], home: string, commands: readonly Command[]): Promise<readonly string[]> => {
264 const starts = commands.flatMap((command) => command.invocations).flatMap(_starts);
265 const [cloud, located] = await Promise.all([
266 _located(real, `${home}/${_CLOUD}`),
267 Promise.all(
268 starts.map(
269 async (word) =>
270 [
271 word,
272 await _located(
273 real,
274 word.replace(_HOME, () => home),
275 ),
276 ] as const,
277 ),
278 ),
279 ]);
280 const places = new Map(located.flatMap(([word, place]) => (place.kind === 'some' ? [[word, place.value] as const] : [])));
281 return cloud.kind === 'none'
282 ? []
283 : commands.flatMap((command) => (command.invocations.some((invocation) => _descends(invocation, cloud.value, places)) ? [`${command.words.join(' ')} descends into ~/${_CLOUD}. Dataless cloud placeholders there hang walkers on the file provider. Start outside ~/${_CLOUD} or exclude ${basename(_CLOUD)}`] : []));
284};
285
286// --- [REWRITE]
287
288const _quoted = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
289
290const _sourceWrites = (commands: readonly Command[]): readonly Splice[] => {
291 const modes = ['-U', '--update-all', '-i', '--interactive'];
292 const calls = commands.flatMap((command) => command.invocations.map((invocation, index) => ({ command, invocation, at: offset(command, index), parsed: operands(invocation) })));
293 const writers = calls.filter(({ invocation, parsed }) => (invocation[0] === 'ast-grep' || invocation[0] === 'sg') && parsed.inputs[0] === 'scan' && parsed.options.some((option) => modes.includes(option)) && !parsed.options.includes('--stdin'));
294 const prefixes = writers.flatMap(({ command, at, parsed }) => {
295 const start = command.spans[at];
296 const scan = command.spans[at + 1];
297 const interactive = parsed.options.includes('-i') || parsed.options.includes('--interactive');
298 return start === undefined || scan === undefined ? [] : [{ start: start.start, end: scan.end, text: `mise exec -- nx run rasm:rewrite${interactive ? ' --args=-i' : ''} --` }];
299 });
300 const positions = writers.flatMap(({ command, at, parsed }) => parsed.positions.map(({ at: position, names }) => ({ span: command.spans[at + position], names })));
301 const flags = positions
302 .filter(({ names }) => names.some((name) => modes.includes(name)))
303 .flatMap(({ span, names }) => {
304 const remaining = names.filter((name) => !modes.includes(name));
305 return span === undefined ? [] : [{ start: span.start, end: span.end, text: remaining.length === 0 ? '' : `-${remaining.map((name) => name.slice(1)).join('')}` }];
306 });
307 return [...prefixes, ...flags];
308};
309
310const _sd = (command: Command): readonly (Splice & { readonly option: '-A' | '--' })[] =>
311 command.nested
312 ? []
313 : command.invocations
314 .slice(-1)
315 .filter(([program]) => program === 'sd')
316 .flatMap((invocation) => {
317 const [program] = invocation;
318 const start = offset(command, command.invocations.length - 1);
319 const { options } = operands(invocation);
320 const operand = firstOperand(invocation, 1);
321 const rest = invocation.slice(operand);
322 const pattern = rest[0] === '--' ? rest[1] : rest[0];
323 const across = command.spans[start];
324 const find = command.spans[start + operand];
325 const breaks = pattern !== undefined && (options.some((name) => name === '-F' || name === '--fixed-strings') ? pattern.includes('\n') : _BREAK.test(pattern));
326 const dashed = rest.some((word) => word !== '-' && word !== '--' && word.startsWith('-') && !known(program, word));
327 return [
328 ...(across !== undefined && breaks && !_informational(program, options) && !options.some((name) => name === '-A' || name === '--across') ? [{ start: across.end, end: across.end, text: ' -A', option: '-A' as const }] : []),
329 ...(find !== undefined && dashed && !rest.includes('--') ? [{ start: find.start, end: find.start, text: '-- ', option: '--' as const }] : []),
330 ];
331 });
332
333const _managed = (command: Command): boolean => command.invocations.some(([program]) => program === 'mise');
334const _runsNx = (invocation: Invocation): boolean =>
335 invocation[0] === 'nx' ||
336 (invocation[0] === 'node' &&
337 operands(invocation)
338 .inputs.slice(0, 1)
339 .some((entry) => _NX_ENTRY.test(entry)));
340
341const _nx = (commands: readonly Command[]): readonly Splice[] => [
342 ...new Map(
343 commands.flatMap((command, position) => {
344 const root = commands.slice(0, position + 1).findLast((other) => !other.nested);
345 const index = command.invocations.findIndex(_runsNx);
346 const prior = command.invocations[index - 1]?.[0];
347 const launched = !command.nested && prior !== undefined && ['npx', 'npm', 'pnpm'].includes(prior);
348 const from = command.nested ? root?.spans[0] : command.spans[offset(command, launched ? index - 1 : index)];
349 const to = command.spans[offset(command, index)];
350 return index < 0 || _managed(command) || root === undefined || _managed(root) || from === undefined || to === undefined ? [] : [[from.start, { start: from.start, end: launched ? to.start : from.start, text: 'mise exec -- ' }] as const];
351 }),
352 ).values(),
353];
354
355const _builds = (command: Command): readonly Build[] =>
356 command.nested
357 ? []
358 : command.invocations.flatMap((invocation, index) => {
359 const subcommand = _subcommand(invocation);
360 const named = command.spans[offset(command, index) + 1];
361 return subcommand.kind === 'none' || !['publish', 'pack', 'msbuild'].includes(subcommand.value) || invocation.slice(2).some((word) => _BINLOG.test(word)) || named === undefined ? [] : [{ at: named.end, subcommand: subcommand.value }];
362 });
363
364const _rewrite = (commands: readonly Command[], text: string, root: Option<string>, id: string): Option<Rewrite> => {
365 const actions = { '-A': 'Pass -A on a find holding a line break', '--': 'Pass -- before a find opening with -' } as const;
366 const sd = commands.flatMap(_sd);
367 const added = [...new Set(sd.map(({ option }) => option))];
368 const unmanaged = _nx(commands);
369 const writers = _sourceWrites(commands);
370 const builds = root.kind === 'none' ? [] : commands.flatMap(_builds).map(({ at, subcommand }, index) => ({ at, path: `${root.value}/.artifacts/dotnet/binlog/${subcommand}-${id}-${index + 1}.binlog` }));
371 const notes: readonly (readonly [notice: string, ...actions: string[]])[] = [
372 ...(sd.length === 0 ? [] : [[`sd ran with ${added.join(' and ')} added`, ...added.map((option) => actions[option])] as const]),
373 ...(unmanaged.length === 0 ? [] : [['nx ran under mise exec', 'Call nx as mise exec -- nx'] as const]),
374 ...(builds.length === 0 ? [] : [['dotnet ran with -bl added', `Diagnose a failed build from ${builds.map(({ path }) => path).join(' and ')} with the dotnet-msbuild-diagnostics skill`] as const]),
375 ...(writers.length === 0 ? [] : [['ast-grep writes run through rasm:rewrite', 'Nx owns rewrite execution and source exclusion'] as const]),
376 ];
377 const bytes = new TextEncoder().encode(text);
378 const decoder = new TextDecoder();
379 const spliced = [...sd, ...unmanaged, ...writers, ...builds.map(({ at, path }) => ({ start: at, end: at, text: ` -bl:${_quoted(path)}` }))]
380 .toSorted((left, right) => left.start - right.start)
381 .reduce<{ readonly at: number; readonly pieces: readonly string[] }>((head, { start, end, text: inserted }) => ({ at: end, pieces: [...head.pieces, decoder.decode(bytes.subarray(head.at, start)), inserted] }), { at: 0, pieces: [] });
382 return notes.length === 0
383 ? none
384 : some({
385 command: [...spliced.pieces, decoder.decode(bytes.subarray(spliced.at))].join(''),
386 notice: notes.map(([notice]) => notice).join(' · '),
387 context: notes.flat().join('. '),
388 });
389};
390
391// --- [DECISION]
392
393const _refusal = async (host: Host, tool: 'Bash' | 'Monitor', script: Script, walkPolicy: boolean): Promise<Option<string>> => {
394 const { commands } = script;
395 const calls = _calls(commands);
396 const named = calls.flatMap((call) => (call.kind === 'subcommand' && (call.key === 'reset' || call.key === 'checkout') ? call.args.filter((word) => !word.startsWith('-')) : []));
397 const [existing, home] = await Promise.all([Promise.all(named.map(async (path) => ((await host.exists(path)) ? [path] : []))), walkPolicy && tool === 'Bash' ? host.home() : none]);
398 const walked = home.kind === 'some' ? await _walk(host.real, home.value, commands) : [];
399 const reasons = [_git(calls, existing.flat()), _stdin(commands), _wait(commands), ...(tool === 'Bash' ? [_script(script), _dotnet(commands)] : []), walked].find((found) => found.length > 0);
400 return reasons === undefined ? none : some([...new Set(reasons)].join('. '));
401};
402
403const _allowed = async (host: Host, tool: 'Bash' | 'Monitor', commands: readonly Command[], text: string, id: string): Promise<Decision> => {
404 const root = tool === 'Bash' && commands.some((command) => _builds(command).length > 0) ? await host.repo() : none;
405 return { kind: 'allow', rewrite: _rewrite(commands, text, root, id) };
406};
407
408const commandDecision = async (host: Host, tool: 'Bash' | 'Monitor', command: string, id: string, walkPolicy: boolean): Promise<Decision> => {
409 const parsed = await parse(host.scan, command);
410 if (parsed.kind === 'fault') {
411 return { kind: 'deny', reason: `command not parsed, ${rendered(parsed.faults)}` };
412 }
413 const refusal = await _refusal(host, tool, parsed.value, walkPolicy);
414 return refusal.kind === 'some' ? { kind: 'deny', reason: refusal.value } : _allowed(host, tool, parsed.value.commands, command, id);
415};
416
417const pathRefusal = (paths: readonly string[]): Option<string> => {
418 const found = paths.map(basename).flatMap((name) => _MINI_CONFIGS.flatMap(([pattern, owner]) => (pattern.test(name) ? [`${name} is a mini config. Edit ${owner}`] : [])));
419 return found.length === 0 ? none : some(found.join('. '));
420};
421
422const worktreeRefusal = (tool: 'EnterWorktree' | 'Agent'): string => `${tool === 'Agent' ? 'Agent with isolation worktree' : tool} ${_WORKTREE}`;
423
424// --- [EXPORTS] -------------------------------------------------------------------------
425
426export type { Decision, Host, Rewrite };
427export { commandDecision, pathRefusal, worktreeRefusal };
428ui/capture.ts 57 lines1import { bind, decoded, fromUndefined, map, none, type Option, ok, type Result, some } from '../composition.ts';
2import type { Capture, Comparison, Recorded } from '../hooks/state.d.ts';
3
4// --- [TYPES] ---------------------------------------------------------------------------
5
6type Printed = { readonly view: string; readonly mode: string | null; readonly changed?: number } | { readonly view: string; readonly camera: { readonly size: readonly [number, number] }; readonly comparison: Comparison | null };
7
8// --- [CONSTANTS] -----------------------------------------------------------------------
9
10const _NAMED = /\.artifacts\/[\w-]+\/[\w.-]+\.png/gu;
11const _PNG = /\.png$/u;
12
13// --- [OPERATIONS] ----------------------------------------------------------------------
14
15// --- [NAMING]
16
17const capturePath = (tool: string, text: string): Option<string> =>
18 fromUndefined(['mcp__rhino-mcp-platform__run_python', 'mcp__blender__execute_blender_code', 'mcp__mcp-for-blender__execute_blender_code'].includes(tool) ? [...text.matchAll(_NAMED)].map(([path]) => path).findLast((path) => !path.endsWith('-diff.png')) : undefined);
19
20// --- [RECORD]
21
22const recorded = (printed: Result<string>): Result<Option<Recorded>> =>
23 bind(decoded<readonly { readonly Capture?: string }[]>('exiftool', printed), (tags) => {
24 const text = tags[0]?.Capture;
25 return text === undefined
26 ? ok(none)
27 : map(decoded<Printed>('Capture', ok(text)), (stored) =>
28 some<Recorded>(
29 'camera' in stored ? { kind: 'blender', view: stored.view, size: stored.camera.size, comparison: stored.comparison === null ? none : some(stored.comparison) } : { kind: 'rhino', view: stored.view, mode: stored.mode === null ? none : some(stored.mode), changed: fromUndefined(stored.changed) },
30 ),
31 );
32 });
33
34// --- [CAPTION]
35
36const diff = ({ path, record }: Capture): Option<string> => {
37 if (record.kind === 'none') {
38 return none;
39 }
40 if (record.value.kind === 'blender') {
41 return record.value.comparison.kind === 'some' ? some(record.value.comparison.value.diff) : none;
42 }
43 return record.value.changed.kind === 'some' ? some(path.replace(_PNG, '-diff.png')) : none;
44};
45
46const caption = (head: string, record: Option<Recorded>): string => {
47 const facts = (stored: Recorded): readonly string[] =>
48 stored.kind === 'blender'
49 ? [`view ${stored.view}`, `camera ${stored.size.join('×')}`, ...(stored.comparison.kind === 'some' ? [`changed ${stored.comparison.value.changed}`, ...(stored.comparison.value.outside ? ['outside frame'] : [])] : [])]
50 : [`view ${stored.view}`, ...(stored.mode.kind === 'some' ? [`mode ${stored.mode.value}`] : []), ...(stored.changed.kind === 'some' ? [`changed ${stored.changed.value}`] : [])];
51 return [head, ...(record.kind === 'some' ? facts(record.value) : [])].join(' · ');
52};
53
54// --- [EXPORTS] -------------------------------------------------------------------------
55
56export { caption, capturePath, diff, recorded };
57ui/health.ts 33 lines1import { decoded, map, type Result } from '../composition.ts';
2import type { Service } from '../hooks/state.d.ts';
3import type { Invocation } from '../policies/invocation.ts';
4
5// --- [CONSTANTS] -----------------------------------------------------------------------
6
7const LAUNCHD_AGENTS: Invocation = ['yq', '-o=json', '.bootstrap.macos.launchd.agents // {}', 'mise.toml'];
8const LISTENERS: Invocation = ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'];
9const UID: Invocation = ['id', '-u'];
10
11// --- [OPERATIONS] ----------------------------------------------------------------------
12
13const services = (printed: Result<string>): Result<readonly Service[]> =>
14 map(decoded<Readonly<Record<string, { readonly args: readonly string[] }>>>('yq', printed), (rows) =>
15 Object.entries(rows).flatMap(([name, { args }]) => {
16 const port = args.find((_arg, index) => args[index - 1] === '--port');
17 return port === undefined ? [] : [{ name, port }];
18 }),
19 );
20
21const down = (known: readonly Service[], listeners: string): readonly Service[] => {
22 const ports = new Set(listeners.split('\n').flatMap((line) => (line.startsWith('n') ? [line.slice(line.lastIndexOf(':') + 1)] : [])));
23 return known.filter(({ port }) => !ports.has(port));
24};
25
26const remaining = (held: readonly Service[], restarted: ReadonlySet<string>): readonly Service[] => held.filter(({ name }) => !restarted.has(name));
27
28const kickstart = (name: string, uid: string): Invocation => ['launchctl', 'kickstart', '-k', `gui/${uid.trim()}/dev.mise.${name}`];
29
30// --- [EXPORTS] -------------------------------------------------------------------------
31
32export { down, kickstart, LAUNCHD_AGENTS, LISTENERS, remaining, services, UID };
33ui/render.tsx 86 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code';
2import { none, type Option, some } from '../composition.ts';
3import type { Capture, Notice, Service } from '../hooks/state.d.ts';
4import { basename } from '../policies/invocation.ts';
5import { caption, diff } from './capture.ts';
6
7// --- [TYPES] ---------------------------------------------------------------------------
8
9interface Row {
10 readonly label: 'hooks' | 'services';
11 readonly facts: string;
12 readonly button: Option<{ readonly hotkey: string; readonly label: string; readonly onPress: () => void }>;
13}
14
15// --- [OPERATIONS] ----------------------------------------------------------------------
16
17// --- [BAND]
18
19const bandRows = (notice: Option<Notice>, down: readonly Service[], restart: () => void): readonly Row[] => [
20 ...(notice.kind === 'none' ? [] : [{ label: 'hooks' as const, facts: notice.value.text, button: none }]),
21 ...(down.length === 0 ? [] : [{ label: 'services' as const, facts: down.map(({ name, port }) => `${name} down (${port})`).join(' · '), button: some({ hotkey: '1', label: 'restart', onPress: restart }) }]),
22];
23
24const band = ({ Box, Button, Text }: Elements[RenderSurface], rows: readonly Row[], below: RenderElement): RenderElement => (
25 <Box flexDirection="column">
26 {below}
27 {rows.map((row) => (
28 <Box flexDirection="row" justifyContent="space-between" key={row.label}>
29 <Box>
30 <Box flexShrink={0} width={2}>
31 <Text color="suggestion">✦</Text>
32 </Box>
33 <Box flexShrink={0} width={10}>
34 <Text dimColor={true}>{row.label}</Text>
35 </Box>
36 <Text wrap="truncate">{row.facts}</Text>
37 </Box>
38 {row.button.kind === 'none' ? null : (
39 <Box flexShrink={0} marginLeft={2}>
40 <Button hotkey={row.button.value.hotkey} label={row.button.value.label} onPress={row.button.value.onPress} plain={true} />
41 </Box>
42 )}
43 </Box>
44 ))}
45 </Box>
46);
47
48// --- [RESULTS]
49
50const resultRow = ({ Box, Text }: Elements[RenderSurface], below: RenderElement, line: string): RenderElement => (
51 <Box flexDirection="column">
52 {below}
53 <Box flexDirection="row">
54 <Box flexShrink={0} paddingLeft={2} width={5}>
55 <Text dimColor={true}>⎿</Text>
56 </Box>
57 <Text dimColor={true} wrap="wrap">
58 {line}
59 </Text>
60 </Box>
61 </Box>
62);
63
64const captureRow = (elements: Elements['terminal'], below: RenderElement, capture: Capture, columns: number): RenderElement => {
65 const { Box, Image } = elements;
66 const indent = 5;
67 const widest = 255;
68 const described = caption(basename(capture.path), capture.record);
69 const compared = diff(capture);
70 const pictures = [capture.path, ...(compared.kind === 'some' ? [compared.value] : [])];
71 return (
72 <Box flexDirection="column">
73 {resultRow(elements, below, described)}
74 <Box flexDirection="row" paddingLeft={indent}>
75 {pictures.map((file) => (
76 <Image alt={described} columns={Math.min(widest, Math.floor((columns - indent) / pictures.length))} key={file} rows={20} source={{ file, format: 'png', generation: capture.generation }} />
77 ))}
78 </Box>
79 </Box>
80 );
81};
82
83// --- [EXPORTS] -------------------------------------------------------------------------
84
85export { band, bandRows, captureRow, resultRow };
86