SLOPSHOPPER

function-hooks

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

newbandrowsguardpromptprocess
v1.0.0MITupdated 2026-10-09bsamiee/Rasm/plugins/function-hooks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · function-hooks
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ Denied by function-hooks: command not parsed, ast-grep output does not decode as JSON, SyntaxError: JSON P ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

[RASM]

Rasm is a polyglot monorepo with macOS-first development and portable code and tooling for Linux and Windows.

[01]-[LAYOUT]

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

[02]-[FLOW]

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

[03]-[TASKS]

  • Targets call one tool, arguments on the command, configuration in the tool's own file
  • nx run rasm:check runs every lint:<checker> and typecheck:<checker> root target, nx run rasm:lint:<checker> one checker
  • nx run <project>:<target> runs one target of one project
  • nx 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 replay
  • nx run <project>:install installs built products, packing Rhino and Blender projects first
  • nx 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 builds
  • nx run rasm:clean removes declared outputs and tool residue, prunes unused installations and caches, and recovers stale macOS processes
  • nx run rasm:rewrite -- --filter='^<id>$' <path> applies one rule's fix across a path
  • nx run rasm:outline -- <path> lists a path's declarations, --items selects local, exported, imported, or all items, --view the depth
  • Workspace plugin names each project's tags and targets by project file and Python edges by import, @nx/dotnet and @nx/vitest infer theirs
  • File-based app directories are one project each, named by path, build, format, and lint run one <target>:<file> target per entry file
  • nx run rasm:format runs every app's format and nx run rasm:lint:dotnet-format every app's lint
  • Project vite.config.ts infers build, run from that project, and its serve configuration runs the Vite development server
  • Tools one host supplies join a project's target, root targets hold commands no project owns
  • Inputs name the files a tool reads and its version as runtime, outputs name the files it writes
  • Caches and outputs sit under root .cache/ and .artifacts/, each tool relocated through one setting every run reads, or its skill states why not

[04]-[OWNERS]

[INDEX][CONCERN][OWNER]
[01]Tool binarymise.toml [tools] at latest, prereleases included
[02]Process variablemise.toml [env]
[03]SDK versionglobal.json for .NET, xcode-select for Swift
[04]Package versionpnpm-workspace.yaml catalog, pyproject.toml [project] or group, Directory.Packages.props row
[05].NET tool packagedotnet dnx <id> on the command
[06]Task graphnx.json, root package.json nx
[07]Checker configurationTool's own file, pyproject.toml [tool.*] for every Python tool
[08]SecretDoppler, mise.toml [env] exec row for every process, doppler run around one command
[09]Resource or repository settingTyped row of the program under infra/, applied by nx run rasm:infra:up
[10]Tool with no consumerMachine setup
[11]Ghidra installHomebrew formula ghidra, path named in mise.toml [env]
[12]Xcode build settingXcode.xcconfig, per-product rows in the .xcodeproj target
[13]Swift package version.xcodeproj package requirement
[14]Agent harness pluginplugins/<name>
[15]Local MCP servicemise.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 versionRoot Ruff per-file-target-version for files, nested requires-python for host trees
  • Tool rows name a release where latest resolves a development build
  • Tool consumers are targets, MCP rows, skills, .gitattributes filters, and CLAUDE.md [CLI_TOOLING] rows
  • Facts sit once in their owning file, other files name the owner
  • Mini configs, wrappers, and aliases beside an owner are corrected at the owner

[05]-[QUALITY]

  • .NET: dotnet build and dotnet format style --verify-no-changes at zero findings
  • Python: ruff, ty, and mypy at zero findings
  • TypeScript: biome check at zero findings, tsc --build under strict options
  • Swift: warnings as errors, strict memory safety, every supported upcoming feature, swift-format lint --strict and swiftlint lint at zero findings
  • Java: google-java-format --aosp and pmd check at zero findings
  • Checker decisions: PMD AvoidAccessibilityAlteration skips use-ghidra Headers.run, Ghidra's PreProcessor takes a DefineTable through package-private field defs alone
  • Tree: yamllint, yamlfmt -lint, actionlint with shellcheck over workflow run steps, and ast-grep rule families
  • Writers: dotnet format, ruff format, Biome, yamlfmt, google-java-format, swiftlint lint --fix then swift-format per Xcode project
  • Checks run through Nx targets alone, each target with every command, dependency, and path the target declares
  • .NET targets write .artifacts/dotnet/binlog/<purpose>-{}.binlog for the binlog MCP to read a failed or slow run
  • Failing checks are fixed in the code or the rule, severity stays as configured

[06]-[STRUCTURE]

  • eng/<language>/ owns engineering workflows over repository projects and artifacts
  • tools/ provides capabilities for development tools and applications
  • Apps group by product under apps/<product>/, with a <host>/ folder per host application
  • Libraries group by language under libs/<language>/, with host-bound packages under a <host>/ folder
  • Build and task graph take .NET hosts from the <host>/ path folder, Blender hosts from blender_manifest.toml
  • libs/ packages point down an acyclic graph, each .NET and TypeScript package consumable alone through declared dependencies
  • Python packages build into one rasm wheel
  • First-party Python imports use from rasm.<module> import <member> for private Blender extension relocation
  • Projects under a rhino folder compile against RhinoCommon, RhinoHost token grasshopper adds Grasshopper2
  • Installed Rhino supplies host assemblies at runtime, build output holds none
  • Project files define projects, never project.json
  • Project files are .csproj, .cs files opening with #!, package.json with tsconfig.json, py.typed, blender_manifest.toml, and .xcodeproj
  • Workspace.slnx lists every project .csproj
  • .xcodeproj basenames name the Nx project, its scheme, and its product
  • Projects hold no src/ directory and no folder with one file, folders group by domain per language
  • Changes replace structure in place, one commit holds change and removal, new structure keeps its predecessor's name
  • Packages, namespaces, routes, contracts, and directories carry no version suffix or v1 folder
  • Schema libraries apply the delta from owning types to the live database, with no migration file or history table
  • Displays, documents, and defaults show imperial units, domain values hold SI quantities converted at the boundary
Source 13 files
hooks/register.ts 403 lines
1import 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 };
403
composition.ts 71 lines
1import 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 };
71
context/plan.ts 26 lines
1import { 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 };
26
observation/delivery.ts 33 lines
1import 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 };
33
observation/row.ts 54 lines
1import 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 };
54
observation/sql.ts 146 lines
1import 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 };
146
policies/command.ts 144 lines
1import { 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 };
144
policies/invocation.ts 501 lines
1// --- [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 };
501
policies/policies.ts 428 lines
1import { 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 };
428
ui/capture.ts 57 lines
1import { 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 };
57
ui/health.ts 33 lines
1import { 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 };
33
ui/render.tsx 86 lines
1import 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