The lifecycle machinery the other plugins assume: shell-output compression that never rewrites a read whose bytes matter, a git guard, a delegation gate (code…

The lifecycle machinery the other kodflow plugins assume: a git and forge guard that reads a Bash line as the shell would and never rewrites it, the delegation gate (code in a git repository is produced by subagents), session context, a formatter, a turn-end quality gate, one structured log, and the session task list.
Two halves. The shell scripts (hooks/scripts/) hold every hard guard and run on any Claude Code. The function hooks (hooks/mod/, a mod: TypeScript the engine loads from hooks/hooks.json → modules) serve the task tools, blank the attribution text, agree a merge with the user, double the Bash guards, compress Bash output and mask secrets in every tool's result; they need Claude Code 2.1.293 or later.
hooks/mod/register.ts is wiring only, one on(...) per event; the logic sits in pure modules beside it (tasks.ts, store.ts, calls.ts, paths.ts, lint.ts, shell.ts, patterns.ts, guards.ts, merge.ts, blast.ts, filter.ts, veil.ts, pipeline.ts, fable.ts, cleanup.ts, and for the three machine guards machine-guards.ts over a port, with modwall.ts, collision.ts, zsh.ts), which never touch $: the engine follows $ and on only inside register.ts.
| Event | What the mod does |
|---|---|
session.start | registers task_create, task_update, task_epic, task_focus, task_list, task_assign_human (isDeferred: false), listed as mcp__kodflow-hooks__task_*, and the /lint-setup command |
classic.SessionStart with source: compact | adds to the context the tasks still waiting on the user (task_assign_human), read fresh from tasks.json, so a summary cannot drop a pending "go" |
command.run on lint-setup | checks ktn-linter for a Go module (below) |
classic.SessionStart, prompt.submit, every tool.call | writes the beat, <config>/kodflow/sessions/<session>/mod-beat (time in seconds), before the shell hook of the same event runs, once the tools are registered |
tool.call on a task tool | answers it (below); a refusal is { deny } with the text the model reads, an internal failure a text error, never a crash |
tool.call on Bash | one pipeline of ordered policies (pipeline.ts), below |
tool.call on a forge merge (merge_pull_request, merge_merge_request, merge_when_pipeline_succeeds, auto_merge) | the merge gate, below |
every tool.call, after the tool ran | secrets masked in the result (veil.ts), below |
prompt.submit | remembers when the user last pressed Enter (origin.kind composer, notePrompt of machine-guards.ts, read through lastComposerAt()): the merge, blast-radius and collision questions are put only to someone who typed in the last 10 minutes |
attribution.text | "" for commit and pr; exemption and remedy untouched |
plugin.register | modwall: refuses every user-tier hooks module whose provenance is not *@kodflow (the list is in modwall.ts, nothing the model writes widens it); built-in and managed tiers are not judged. Each decision goes to $.store (modwall, last 100) and the debug log; a refusal is toasted once the session binds |
command.run · /kodflow-mods | the modules judged at this load, and the refusals on record |
session.start | zsh-safe: ZDOTDIR → hooks/zsh, the user's own in KODFLOW_USER_ZDOTDIR ("" when unset); hooks/zsh/.zshenv gives ZDOTDIR back, sources the user's .zshenv, then setopt nonomatch noequals: echo .none prints .none instead of failing |
tool.call on Edit, Write, MultiEdit, NotebookEdit | collision-guard: when another session of this machine, beating within 30 min, wrote the same file within 30 min (its edits.tsv), asks Continue / Move to a worktree (the model is told to call EnterWorktree) / Cancel, but only when the user typed a prompt at the terminal (origin.kind composer, noted by prompt.submit) in the last 10 minutes, the presence rule of the merge gate: otherwise it journals the collision to the debug log and lets the edit through, so a night run never waits on a question; once written, notes <seconds>\t<path> in <config>/kodflow/sessions/<session>/edits.tsv (no prompt, no content); <config>/projects is never checked |
session.end | empties this session's edits.tsv |
agent.spawn | gated models: passes or refuses a spawn on the model it would run on, never writes one (below) |
tool.call on Skill | gated models: a skill whose frontmatter names one passes only with the session's grant, asked once like a spawn |
classic.PreModelSwitch | a /model the user typed or picked grants the session a gated model; a switch from the SDK asks, and is refused with nobody at the prompt |
tool.call on Bash, Edit, Write, MultiEdit, NotebookEdit from a subagent | cleanup on close: notes the absolute .worktrees/<name> roots its arguments name (~ and $HOME expanded) under its id in <config>/kodflow/sessions/<session>/worktrees.json, once per path, without waiting |
tool.call on task_update completing a main-agent task | cleanup on close: once the answer is settled, starts hooks/scripts/cleanup.sh on the worktrees of the task's worker, and when the completion closes the epic, of every worker of the epic and every subagent started on it; never awaited (below) |
prompt.submit from the composer | writes <config>/kodflow/sessions/<session>/composer-at (seconds), the presence on-stop.sh reads before proposing agent feedback |
One on("tool.call", { tool: "Bash" }), its policies in a fixed order, each with its own failure rule. No policy ever answers allow, and none answers { result } but the filter (and the veil, on every tool).
| # | Policy | What it does | A failure |
|---|---|---|---|
| 1 | merge gate (merge.ts) | below | refuses |
| 2 | guards (guards.ts) | the shell's Bash guards doubled, refusal only: same reader (shell.ts, a port of on-tool.sh's), same order of blocks, same motifs; the bash-guard corpus is replayed against both. The staged-secret scan stays the shell's alone; the mcp.json refusal is doubled (the index read by name only), and the generic tool.call hook holds the forge tools to it | is logged, the call goes on |
| 3 | blast radius (blast.ts) | rm recursive and forced, git reset --hard, a forced push: $.ui.ask while the user is at the prompt; nobody there, the call goes on | is logged, the call goes on |
| 4 | the call | the shell's PreToolUse runs beneath, as ever | — |
| 5 | output filter (filter.ts, L3) | a line that is exactly one git status, git log, git diff, ls -l, find or grep -rn gets its stdout shortened (hints, index lines, columns, repeated prefixes); never a read whose bytes count (cat, head, tail, sed, diff, git show, git cat-file, …), never a failed, interrupted, persisted, image, background or structured result, only when stdout is a string and the engine version is inside FILTER_VERSIONS (CI checks the pinned one is). Every other key of the result is kept | returns the result untouched |
Each decision and failure is one ModPolicy line in the session log.
A subagent never merges. From the main thread a merge passes only:
-R, GH_REPO, an API path, owner/repo, project_id, else the session directory's single remote; anything ambiguous (no number, a branch, a GraphQL node id, a raw HTTP call, two merges on a line, a cd before an unnamed one) is refused;--match-head-commit <sha>, --sha <sha>, -f sha=<sha>, "sha"), nothing rewritten; pinned to another head, refused with the new one;gh or glab (15 s, a failure refuses);$.ui.ask. A yes writes <config>/kodflow/sessions/<sid>/merge-ok/<tool_use_id>, the SHA first, and lets the call go on intact; the yes holds five minutes per (repository, number, SHA).Written consent. When the user's last prompt typed at the terminal (origin.kind composer) already says it, the question is skipped: a word that merges standing alone (merge, merger, fusionne, fusionner, fusionnes), no question mark, no negation, wait or later (ne, pas, jamais, attends, plus tard, not, wait, later, before…), counted on the words typed only (a pasted block, a paste placeholder and a code span are dropped first). That prompt writes the same token as a yes and serves one merge: it is consumed, and the next merge without a new message is asked about. Each use is journaled merge: written consent: <first 80 characters>. Everything above still holds: a subagent, an unpinned or moved head, a CI not green are refused whatever the prompt says. KODFLOW_MERGE_CONSENT=off (environment or the env block of the settings) keeps the question every time; a failed read of that switch keeps it too. Accepted gap: the presence rule comes first, so a consent written more than 10 minutes ago gives "waiting for the user's OK", never a merge.
on-tool.sh refuses a main-thread merge without that token, fresh and naming a SHA the call carries, and spends it. The one exception is merge-exemptions.txt (versioned, fingerprinted, changed by pull request only, each use journaled root_guard: merge-exempt), where kodflow/claude-marketplace is never honoured.
After every tool ran, never on its input: the value of a known credential variable of the session (SECRET_VARS), a credential by its shape (provider prefixes, Bearer, private key blocks, user:password@, password=…), and a token of 20 characters or more, letters and digits mixed, above 4.3 bits per character, unless it is a hash, a UUID, a path, an integrity digest or base64 of plain text. A masked value is gone, nothing keeps it. An errored result with a secret is withheld with the masked text. Past 2 000 000 characters or 1.5 s, the result comes back as it was (fail open).
Every hook fails open. KODFLOW_MOD=off (environment or the env block of the settings) makes the module pass-through: no tool, no beat, no marker. A modwall judge that throws lets the module load; with no one to answer (-p) or the dialog dismissed, collision-guard lets the edit through.
modwall judges only the modules that register after kodflow-hooks: one loaded before it (load order is the engine's) is admitted by core alone, and /kodflow-mods says so. This limit is accepted: closing it would take kodflow-hooks into the managed prepend tier, which managed settings alone can do, and they are not used. on-session.sh stays the net: it warns about any plugin outside the kodflow marketplace that declares modules.
The shell reads the beat (lib/beat.sh): younger than 5 s, the task tools answer; older, the mod was loaded and is gone; absent, it never loaded.
Without the mod (Claude Code < 2.1.293, KODFLOW_MOD=off, a failed reload) | |
|---|---|
| task tools | absent: tasks are not tracked, the status line shows no epic |
| triage gate, triage directive, epic state in the prompt, session-start review | off: nothing could lift a gate the tools cannot answer |
built-in TaskCreate/TodoWrite | allowed again: they are the only list left |
| attribution text | Claude Code's own (the shell guard still refuses AI attribution in a commit) |
| what the user is told | once per session that the task tools did not load (not with KODFLOW_MOD=off); after an unload, /reload-plugins |
| merge from the main thread | refused by the shell (no token): the user merges | | output filter, secret veil, blast-radius question, doubled guards | absent |
Every hard guard (git, delegation, protected paths, secrets) is shell and does not depend on the mod.
Hooks registered on the same event run in parallel — the Claude Code hooks reference says so in one line, and it decides the design. A chain of small scripts on PreToolUse has no order: the guard may inspect a command the rewriter has already changed. The only way to get a sequence is to have one script per event and put the sequence inside it.
Every script runs the same four stages, in this order, and leaves at the first stage that has nothing more to do:
| Stage | Meaning | Exit |
|---|---|---|
| 1 · gate | the cheapest test that lets most calls leave at once | 0 |
| 2 · block | what must stop the call | 2, reason on stderr |
| 3 · transform | what Claude reads (context, the task identity of a task_* call), emitted as one JSON document; a Bash command is never rewritten | 0 |
| 4 · observe | logging and advice, off the critical path | 0 |
Every path that is not a deliberate block exits 0. A PreToolUse hook that fails by accident blocks every shell call of the session.
| Event | Script | Gate | Block | Transform | Observe |
|---|---|---|---|---|---|
PreToolUse · Bash | on-tool.sh | no git/gh/glab/curl/wget/http on the line once unquoted (and no $'…') | read by the shell reader below: the main thread producing into a repository (delegation gate) · --no-verify, -n on a commit, -c core.hooksPath · AI attribution or workflow leak in a commit message, AI attribution in a PR/MR title or body · credential shapes in the staged blobs · a forced push (--force, -f, +refspec), with the command to run instead · from a subagent: any merge, any push to the default branch · from the main thread: a merge without the mod's token or an exemption | none: the input is never rewritten | log; a main-thread merge is tagged root_guard: merge (refused), merge-ok or merge-exempt |
PreToolUse · task tools | on-tool.sh | the mod's task tools never reach it (a call a function hook answers fires no PreToolUse); the old MCP names are logged and passed | built-in TaskCreate/TodoWrite while the mod beats: their chat panel duplicates the status line, the refusal points at the kodflow task tools | — | log |
PreToolUse · Write/Edit | on-tool.sh | main thread writing inside a git work tree (memory and Claude configuration excepted) · no file path | the main thread producing into a repository (delegation gate) · protected path (defaults or .claude/protected-paths) | — | log (no linter call: ktn-linter reports through its own command hooks) |
PreToolUse · forge MCP (mcp__github__*, mcp__gitlab__*, mcp__GitKraken__*) | on-tool.sh | no message, title, body, description, branch or merge field, and a subagent or a read/merge/review/issue tool (decided without jq) | a forge write from the main thread (files, branches, pull requests) · AI attribution or workflow leak in a commit field (message, commit_message, commit_title, squash_commit_message, merge_commit_message) · AI attribution in the title, body or description of a PR/MR tool · from a subagent: a merge (merge_pull_request, merge_merge_request, merge_when_pipeline_succeeds, auto_merge), a file write to main/master · from the main thread: a merge without the mod's token or an exemption | — | log; a main-thread merge is tagged root_guard: merge (refused), merge-ok or merge-exempt |
PostToolUse · Write/Edit | on-tool.sh | file absent, markdown, .claude/ | — | format (Makefile fmt/format first, then the formatter for the extension) and say so when the bytes changed | edited-file tracker · risky construct warning once per session · log |
PreToolUse · Agent/Task | on-tool.sh | gated-model fallback first, every caller, no jq; then the main thread only | a gated model when the mod does not beat · from the main thread, a general-purpose dispatch whose description names an installed specialist's technology (routing gate, below) | — | log, root_guard: route-deny, route-override or route-noindex |
PreToolUse · Skill | on-tool.sh | the mod does not beat | a skill whose frontmatter names a gated model | — | — |
PostToolUse · Task/Agent | on-tool.sh | — | — | — | agent-models.tsv (agent id, resolved model, per-call effort) · log tagged root_guard: dispatch |
PostToolUse · SubagentHandback | on-tool.sh | — | — | — | the report's agent feedback, as at SubagentStop |
PostToolUse · other | on-tool.sh | — | — | — | log |
PostToolUseFailure | on-tool.sh | — | — | — | remediation hint · log (error redacted) |
SessionStart | on-session.sh | — | — | routing-installed.tsv for the routing gate · a notice when the session starts on a gated model · the open tasks to reconcile, while the mod beats · post-compaction rules on compact · a warning when rtk is absent · a warning to the user (systemMessage) and to Claude when disableAllHooks is set, when CLAUDE_CODE_PLUGIN_DIRS is set, or when an enabled plugin, a skills directory or a plugin dir outside the kodflow marketplace declares modules · the same warning when an installed guard (on-tool.sh, merge-exemptions.txt, hooks/patterns/*.txt) differs from hooks/fingerprints.sha256, when that file is missing, or when it differs from the marketplace clone's (D8: nothing is blocked; the file is generated by scripts/hooks-fingerprints.sh and checked by CI) | log |
SessionEnd | on-session.sh | — | — | — | one log line with the session's event count (1.5 s budget) |
PreCompact | on-session.sh | — | — | — | log |
ConfigChange | on-session.sh | — | — | — | log · bypassPermissions flagged in security-events.jsonl |
UserPromptSubmit | on-user.sh | — | — | branch, latest plan, latest goal · the cleanup notices (cleanup-notices, consumed) · while the mod beats: the main agent's epics (active one with its task in progress, other open ones with done/total) when tasks.json exists, and the triage directive with the epic mandatory on task_create · the delegation line (code in a repository: dispatch or message the epic's subagent) unless KODFLOW_ROOT=off · no triage directive on a turn the user did not type · a systemMessage when the mod is gone | raise the triage gate (mod beating) and reset the Stop loop counter, except on a turn the user did not type · log |
Notification | on-user.sh | — | — | bell (terminalSequence) on idle, permission and elicitation prompts | log |
SubagentStart | on-agent.sh | — | — | the standing rules, injected into the subagent (own worktree, deliver through a PR, and rule 6: end with ## Agent feedback) | running-agents registry, with the main agent's active epic at start · log |
SubagentStop | on-agent.sh | stop_hook_active | — | — | running-agents registry · its agent feedback in agent-feedback.jsonl · log |
TaskCreated · TaskCompleted · TeammateIdle | on-agent.sh | — | — | — | log |
Stop | on-stop.sh | stop_hook_active · 3 feedbacks without a new prompt | — | feedback in one document: ktn-linter run report (stdout only; a run that did not complete said in one line, a refusal beside a live daemon silent) on this session's Go packages · the CLAUDE.md of each directory changed this session, once per directory · the main agent's tasks still open in its active epic (tasks with no epic when none is active, and the open ones with no epic whatever is active; tasks MCP, and the built-in list unless CLAUDE_CODE_ENABLE_TODO_TOOLS is off), once per open set · an active epic with tasks to do but none in_progress or waiting, every turn until corrected · more untied tasks in_progress than free workers, every turn · a task in_progress whose worker has stopped, once per set · a main-agent task in_progress on an epic no running subagent is attributed to and not tied to a running worker (the main thread producing), once per set, unless KODFLOW_ROOT=off · a triage cause left unsettled (triage-pending), every turn, not on a system turn · the subagents' new agent feedback, once, to a user present in the last 10 minutes, with the instruction to ask before any issue | bell · log |
The user's rule: the main thread triages, manages epics and tasks, dispatches, reviews, merges after the user's OK, and informs. Code production in a git repository is delegated: every epic is carried by at least one subagent working in its own git worktree beside the repository (<repo parent>/.worktrees/<repo>-<epic-slug>; when that parent is or sits under ~/Documents, ~/Desktop or ~/Downloads, which macOS TCC protects, ~/Projects/.worktrees/<repo>-<epic-slug> instead) that delivers through a PR. The main thread keeps every other permission — it is also the workstation's sysadmin (sudo, apt, systemctl, nmcli, installs, files outside repositories, configuration).
The gate is mechanical. A PreToolUse payload carries agent_id only when the call comes from inside a subagent (measured on 855 real events: every one carrying it came from a subagent, no main-thread one did), so a call without it is the main thread's own hand. Subagents are never gated. The main thread is refused exactly three things:
| Tool | Refused | Still allowed |
|---|---|---|
Write/Edit/MultiEdit/NotebookEdit | a file inside a git work tree: its directory and parents are walked up looking for .git (a directory, or a worktree's gitdir file), no exec, after the path is normalised lexically | everything outside a repository; <config>/projects/*/memory/*, <config>/settings.json, settings.local.json, <config>/CLAUDE.md and ~/CLAUDE.md even inside one |
Bash | git commit, push, rebase, cherry-pick, merge, am, apply, revert, reset --hard, worktree add; gh pr create, glab mr create — wherever the shell reader finds them (below) | everything else: no allow-list, no redirection rule, unknown commands pass; gh pr merge and glab mr merge pass the delegation gate, and the merge gate then asks for the mod's token (above) |
forge MCP (mcp__github__*, mcp__gitlab__*, mcp__GitKraken__*) | file, branch, push and pull-request writes, and any tool not classified | reads, merges, reviews, comments, issues (decided in the jq-free fast path) |
A regex over the raw line cannot tell git "commit", \git commit or =git commit from a commit, and it reads a heredoc that merely quotes a commit as one. on-tool.sh does the shell's first pass instead: it cuts the line into simple commands on ; && || | & and newlines, removes quotes and backslashes, decodes $'…', joins \+newline, sets heredoc and here-string bodies aside as the command's stdin, walks past VAR=value, reserved words and wrappers (env, command, exec, sudo, doas, nohup, nice, timeout, xargs, rtk, rtk proxy…), and queues for another pass every text the shell would itself run: $( ), backticks, <( ), bash -c/sh -c/zsh -c, eval, su -c/sg -c, a heredoc or a here-string fed to a shell, and echo …/cat <<EOF piped into one. A heredoc fed to anything else is data: cat > notes.md <<EOF quoting a commit and a .claude/ path passes.
It never expands a variable or an alias. Those, and an interpreter other than a shell (python -c "os.system(…)"), find -exec and a git alias defined on the line, are the known gaps; each has a known_gap case in the corpus.
The attribution guard reads its rules from kodflow/post-commit, the gate every repository runs on GitHub: scripts/patterns.txt and scripts/patterns-strict.txt, vendored byte for byte in hooks/patterns/ at the commit hooks/patterns/SOURCE pins. An installed plugin reads that copy and never the
hooks/mod/register.ts 902 lines1// register.ts — kodflow-hooks' function hooks: wiring only.
2//
3// One `on` per event, each a few lines that read `$` and hand the work to the
4// pure modules beside this file (tasks.ts, store.ts, calls.ts, paths.ts).
5// The shell scripts keep every hard guard; this module serves the session
6// task list as six tools, blanks the attribution text, and beats: it writes
7// <config>/kodflow/sessions/<session>/mod-beat before each event it sees, so
8// the shell hooks know the task tools exist before they gate on them. After a
9// compaction it tells the model again which tasks wait on the user, and it
10// serves /lint-setup (lint.ts).
11//
12// Bash goes through one ordered pipeline (pipeline.ts): the merge gate
13// (merge.ts), the shell's guards doubled (guards.ts), the blast radius
14// (blast.ts), the call, then its output compressed (filter.ts). Every tool's
15// result then has its secrets masked (veil.ts) before the model reads it.
16//
17// Gated models (fable.ts): an agent.spawn that would run on one passes only
18// with the session's grant in $.store, asked once while the user is at the
19// prompt; a /model the user types grants it (classic.PreModelSwitch). The
20// hook never writes the model it judged.
21//
22// Every hook fails open: a throw is caught and the event goes on, except a
23// task tool's own call, which answers the failure as text, a merge, which a
24// failure refuses, and a spawn whose raw input names a gated model. KODFLOW_MOD=off turns the whole module into
25// pass-through, with no marker written.
26//
27// Cleanup on close (cleanup.ts): the worktrees a subagent writes into are
28// noted; a task_update that completes a worker's task, or closes an epic,
29// hands them to hooks/scripts/cleanup.sh, started without waiting for it.
30import type { Register } from "claude-code";
31import {
32 TOOLS,
33 MAIN,
34 humanContext,
35 isTaskError,
36 plan,
37 resolveWorkers,
38 type ToolName,
39} from "./tasks.ts";
40import { load, lock, mutate, unique, type Port } from "./store.ts";
41import { LINT_SETUP, lintSetup, type LintPort } from "./lint.ts";
42import {
43 argsOf,
44 attributionText,
45 isOff,
46 logPayload,
47 mainLoop,
48 settleCauses,
49 taskTool,
50 triageCauses,
51} from "./calls.ts";
52import {
53 agentsFile,
54 beatFile,
55 beatText,
56 composerFile,
57 configDir,
58 mergeTokenFile,
59 safe,
60 sessionDir,
61 triageFile,
62} from "./paths.ts";
63import {
64 admit,
65 consumeComposerPrompt,
66 endGuards,
67 guardWrite,
68 lastComposerAt,
69 lastComposerPrompt,
70 notePrompt,
71 modsReport,
72 startGuards,
73 type GuardPort,
74} from "./machine-guards.ts";
75import { parse } from "./shell.ts";
76import {
77 forgeMcpJson,
78 inspect,
79 refusalText,
80 verdict,
81 type GuardCtx,
82} from "./guards.ts";
83import { loadRules, type Rules } from "./patterns.ts";
84import {
85 classifyBash,
86 classifyMcp,
87 gate,
88 type MergePort,
89} from "./merge.ts";
90import { blastRadius } from "./blast.ts";
91import { filterResult, versionOk } from "./filter.ts";
92import { veilResult } from "./veil.ts";
93import { mayMerge, mergeFailure, runBash } from "./pipeline.ts";
94import {
95 WRITING_TOOLS,
96 claim,
97 closingOf,
98 note,
99 worktreePaths,
100 type Closing,
101} from "./cleanup.ts";
102import {
103 anyGated,
104 gateMode,
105 grantKey,
106 failClosedTokens,
107 forced,
108 judgeSkill,
109 judgeSpawn,
110 skillPaths,
111 judgeSwitch,
112 isGated,
113 parseGated,
114 refusal as fableRefusal,
115 definitionPaths,
116 type SpawnPort,
117} from "./fable.ts";
118
119// Set once every tool registered; the beat is a promise that they did.
120let toolsReady = false;
121
122// The merge question and the blast-radius question are only put to someone
123// who is there: a message typed at the prompt in the last 10 minutes, as
124// machine-guards.ts records it (notePrompt, in prompt.submit).
125const PRESENCE_MS = 10 * 60_000;
126const present = () => {
127 const at = lastComposerAt();
128 return at !== undefined && Date.now() - at <= PRESENCE_MS;
129};
130
131// Read once a session: the engine's version, the credential variables' values.
132let validated: Promise<boolean> | undefined;
133let secrets: Promise<string[]> | undefined;
134// The vendored §5 rules; a failed read stays undefined and is tried again.
135let rules: Rules | undefined;
136
137const off = async ($: any): Promise<boolean> =>
138 isOff(await $.env.get("KODFLOW_MOD"));
139
140async function config($: any): Promise<string | undefined> {
141 return configDir(
142 await $.env.get("CLAUDE_CONFIG_DIR"),
143 await $.env.get("HOME"),
144 );
145}
146
147async function ensureTools($: any): Promise<void> {
148 if (toolsReady) return;
149 for (const tool of TOOLS)
150 await $.tool.register({ ...tool, isDeferred: false });
151 toolsReady = true;
152}
153
154// `early`: the settings hooks' SessionStart runs before the session binds,
155// when `$.tool.register` still rejects; session.start registers the tools
156// before the first prompt. The beat there says the mod is loaded. Should the
157// registration then fail, no beat follows, and the first prompt finds it
158// stale and lifts whatever the SessionStart hook raised.
159async function beat($: any, session?: string, early = false): Promise<void> {
160 if (!toolsReady && !early) return;
161 const cfg = await config($);
162 if (!cfg) return;
163 const dir = sessionDir(cfg, session || (await $.session.id()));
164 await $.fs.write(beatFile(dir), beatText(Date.now()));
165}
166
167// The user's open tasks, read fresh, for the context a compaction lost
168async function humanAfterCompact(
169 $: any,
170 session: string,
171): Promise<string | undefined> {
172 const cfg = await config($);
173 if (!cfg) return undefined;
174 return humanContext(
175 await load(port($), sessionDir(cfg, session), Math.floor(Date.now() / 1000)),
176 );
177}
178
179async function lintPort($: any): Promise<LintPort> {
180 return {
181 exists: (path) => $.fs.exists(path).catch(() => false),
182 isDir: (path) =>
183 $.fs.stat(path).then(
184 (s: { kind: string }) => s.kind === "directory",
185 () => false,
186 ),
187 read: (path) =>
188 $.fs.read(path).then(
189 (t: string) => t,
190 () => undefined,
191 ),
192 run: (argv, timeoutMs) =>
193 $.process.run(argv, { timeoutMs }).catch(() => undefined),
194 ask: (question, options) =>
195 $.ui.ask(question, options).catch(() => undefined),
196 vars: {
197 KTN_USER_DAEMON: await $.env.get("KTN_USER_DAEMON"),
198 },
199 sleep: (ms) => $.clock.sleep(ms),
200 root: await $.session.root(),
201 };
202}
203
204function port($: any): Port {
205 return {
206 read: (path) =>
207 $.fs.read(path).then(
208 (t: string) => t,
209 () => undefined,
210 ),
211 write: (path, text) => $.fs.write(path, text),
212 run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
213 mtime: (path) =>
214 $.fs.stat(path).then(
215 (s: { mtimeMs: number }) => s.mtimeMs,
216 () => undefined,
217 ),
218 sleep: (ms) => $.clock.sleep(ms),
219 now: () => Date.now(),
220 };
221}
222
223async function serve(
224 $: any,
225 tool: ToolName,
226 e: any,
227): Promise<{ result: string } | { deny: string }> {
228 const cfg = await config($);
229 if (!cfg)
230 return {
231 deny: "kodflow task tools: no absolute CLAUDE_CONFIG_DIR or HOME to keep the list in",
232 };
233 const session = await $.session.id();
234 const dir = sessionDir(cfg, session);
235 const io = port($);
236 const agent = e.agentId ? safe(e.agentId) : MAIN;
237 const nowSec = () => Math.floor(Date.now() / 1000);
238 let answer: { result: string } | { deny: string };
239 let closing: Closing | undefined;
240 try {
241 const step = plan(tool, agent, argsOf(e));
242 // The main agent completing a task: the list before the update is kept,
243 // since the update drops the task's worker link
244 const completing =
245 tool === "task_update" &&
246 agent === MAIN &&
247 argsOf(e).status === "completed";
248 let workers: ReturnType<typeof resolveWorkers> = [];
249 if (tool === "task_update") {
250 const listed = await $.agent.list().catch(() => undefined);
251 workers = resolveWorkers(
252 listed,
253 await io.read(agentsFile(dir)),
254 nowSec(),
255 );
256 }
257 const text = step.mutates
258 ? await mutate(io, dir, (data) => {
259 const before = completing ? JSON.parse(JSON.stringify(data)) : undefined;
260 const out = step.run(data, { now: nowSec(), workers });
261 if (before) closing = closingOf(before, data, argsOf(e).id);
262 return out;
263 })
264 : step.run(await load(io, dir, nowSec()), { now: nowSec(), workers });
265 answer = { result: text };
266 } catch (err) {
267 if (!isTaskError(err)) {
268 // Fail open: the store or the host failed, and a gate the task tools
269 // cannot settle would stop the session
270 if (mainLoop(e.agentId))
271 await io.run(["rm", "-f", triageFile(dir)]).catch(() => undefined);
272 throw err;
273 }
274 answer = { deny: err.message };
275 }
276 // A refusal leaves the gate as it was; a call that succeeded settles the
277 // causes it answers. Never at the cost of the answer of an applied change.
278 if ("result" in answer)
279 await settleTriage(io, dir, tool, argsOf(e), e.agentId).catch(
280 () => undefined,
281 );
282 // Started, never awaited: gh and git push reach the network, and the
283 // answer to the task call does not wait on them
284 if ("result" in answer && closing) void cleanupAfter($, dir, closing);
285 // One line in .claude/logs/<branch>/session.jsonl, through lib/event.jq
286 const payload = logPayload(session, await $.session.root(), e, answer);
287 await $.process
288 .run(["bash", `${$.plugin.root}/hooks/scripts/on-mod.sh`], {
289 stdin: JSON.stringify(payload),
290 timeoutMs: 3000,
291 })
292 .catch(() => undefined);
293 return answer;
294}
295
296// The worktrees of the workers a completion closes, to cleanup.sh, which
297// removes only what is merged, published and clean (KODFLOW_CLEANUP=off:
298// nothing). Every failure is swallowed: the task call has already answered.
299async function cleanupAfter($: any, dir: string, closing: Closing): Promise<void> {
300 try {
301 if (isOff(await $.env.get("KODFLOW_CLEANUP"))) return;
302 const io = port($);
303 const paths = await claim(io, dir, closing, await io.read(agentsFile(dir)));
304 if (!paths.length) return;
305 await $.process.run(
306 ["bash", `${$.plugin.root}/hooks/scripts/cleanup.sh`, dir, ...paths],
307 { timeoutMs: 300_000 },
308 );
309 } catch {
310 // fail open
311 }
312}
313
314// The worktrees a subagent writes into, noted once per path and session
315const noted = new Set<string>();
316async function noteWorktrees($: any, e: any): Promise<void> {
317 try {
318 const text =
319 typeof e.command === "string" ? e.command : JSON.stringify(argsOf(e));
320 const paths = worktreePaths(text.slice(0, 20_000), await $.env.get("HOME"));
321 if (!paths.length) return;
322 const cfg = await config($);
323 if (!cfg) return;
324 const dir = sessionDir(cfg, await $.session.id());
325 const fresh = paths.filter((p) => !noted.has(`${dir}\t${e.agentId}\t${p}`));
326 if (!fresh.length) return;
327 await note(port($), dir, String(e.agentId), fresh);
328 for (const p of fresh) noted.add(`${dir}\t${e.agentId}\t${p}`);
329 } catch {
330 // fail open
331 }
332}
333
334// Drop from triage-pending the causes this call settled: the file goes with
335// the last one, the others stay for their own gesture. Under the lock the
336// shell takes to append a cause (on-user.sh, on-session.sh), so a message
337// that arrives meanwhile is never lost; replaced through a temporary file
338// and `mv`, so the gate never reads half a list. A lock not obtained in time
339// is written through, as lib/lock.sh's callers do.
340async function settleTriage(
341 io: Port,
342 dir: string,
343 tool: ToolName,
344 args: Record<string, unknown>,
345 agentId: unknown,
346): Promise<void> {
347 if (!mainLoop(agentId)) return;
348 const file = triageFile(dir);
349 // Most calls find no gate, or answer none of its causes: no lock for them
350 const seen = await io.read(file);
351 if (seen === undefined) return;
352 const before = triageCauses(seen);
353 if (settleCauses(before, tool, args, agentId).length === before.length) return;
354 const lockDir = `${dir}/.triage.lock.d`;
355 const held = await lock(io, lockDir);
356 try {
357 const text = await io.read(file);
358 if (text === undefined) return;
359 const causes = triageCauses(text);
360 const left = settleCauses(causes, tool, args, agentId);
361 if (left.length === causes.length) return;
362 if (left.length === 0) {
363 await io.run(["rm", "-f", file]);
364 return;
365 }
366 const tmp = `${dir}/.triage.${unique()}.tmp`;
367 await io.write(tmp, `${left.join("\n")}\n`);
368 if ((await io.run(["mv", "-f", tmp, file])).exitCode !== 0)
369 await io.run(["rm", "-f", tmp]);
370 } finally {
371 if (held) await io.run(["rmdir", lockDir]);
372 }
373}
374
375// modwall, collision-guard and zsh-safe (machine-guards.ts) reach `$` through this
376function guardPort($: any): GuardPort {
377 return {
378 off: () => off($),
379 config: () => config($),
380 read: (path) =>
381 $.fs.read(path).then(
382 (t: string) => t,
383 () => undefined,
384 ),
385 write: (path, text) => $.fs.write(path, text),
386 list: (path) => $.fs.list(path),
387 session: () => $.session.id(),
388 cwd: () => $.session.cwd(),
389 ask: (question, options) =>
390 $.ui.ask(question, { options, header: "Collision" }),
391 storeGet: (key) => $.store.get(key),
392 storeSet: (key, value) => $.store.set(key, value),
393 log: (text) => $.ui.log(text, { to: "debug" }),
394 toast: (text) => $.ui.toast(text, { timeoutMs: 8000 }),
395 registerMods: (description) =>
396 $.command.register({ name: "kodflow-mods", description }),
397 zdotdir: () => $.env.get("ZDOTDIR"),
398 userZdotdir: () => $.env.get("KODFLOW_USER_ZDOTDIR"),
399 setZdotdir: (value) => $.env.set("ZDOTDIR", value),
400 setUserZdotdir: (value) => $.env.set("KODFLOW_USER_ZDOTDIR", value),
401 pluginRoot: $.plugin.root,
402 now: () => Date.now(),
403 };
404}
405
406// One line in .claude/logs/<branch>/session.jsonl for a policy's decision.
407function journal(
408 $: any,
409 e: any,
410 policy: string,
411 decision: string,
412 error?: unknown,
413): void {
414 void (async () => {
415 const payload: Record<string, unknown> = {
416 hook_event_name: "ModPolicy",
417 session_id: await $.session.id(),
418 cwd: await $.session.cwd(),
419 tool_use_id: e.tool_use_id,
420 tool_name: e.tool,
421 tool_input: typeof e.command === "string" ? { command: e.command } : {},
422 reason: `${policy}:${decision}`,
423 };
424 if (e.agentId) payload.agent_id = e.agentId;
425 if (error !== undefined)
426 payload.error = String((error as Error)?.message ?? error);
427 await $.process.run(
428 ["bash", `${$.plugin.root}/hooks/scripts/on-mod.sh`],
429 { stdin: JSON.stringify(payload), timeoutMs: 3000 },
430 );
431 })().catch(() => undefined);
432}
433
434// The credential variables' values. $.env.get takes literal names only (the
435// engine lists what a module reads), so veil.ts's SECRET_VARS is spelled out
436// here, and tests/mod/pipeline.test.ts holds the two lists equal.
437async function secretValues($: any): Promise<string[]> {
438 const values: (string | undefined)[] = await Promise.all([
439 $.env.get("ANTHROPIC_API_KEY"),
440 $.env.get("ANTHROPIC_AUTH_TOKEN"),
441 $.env.get("CLAUDE_CODE_OAUTH_TOKEN"),
442 $.env.get("OPENAI_API_KEY"),
443 $.env.get("GITHUB_TOKEN"),
444 $.env.get("GH_TOKEN"),
445 $.env.get("GH_ENTERPRISE_TOKEN"),
446 $.env.get("GITLAB_TOKEN"),
447 $.env.get("GL_TOKEN"),
448 $.env.get("CI_JOB_TOKEN"),
449 $.env.get("NPM_TOKEN"),
450 $.env.get("NODE_AUTH_TOKEN"),
451 $.env.get("AWS_SECRET_ACCESS_KEY"),
452 $.env.get("AWS_SESSION_TOKEN"),
453 $.env.get("OP_SERVICE_ACCOUNT_TOKEN"),
454 $.env.get("OP_CONNECT_TOKEN"),
455 $.env.get("VAULT_TOKEN"),
456 $.env.get("NOMAD_TOKEN"),
457 $.env.get("CONSUL_HTTP_TOKEN"),
458 $.env.get("CLOUDFLARE_API_TOKEN"),
459 $.env.get("CF_API_TOKEN"),
460 $.env.get("SLACK_TOKEN"),
461 $.env.get("SLACK_BOT_TOKEN"),
462 $.env.get("DOCKER_PASSWORD"),
463 $.env.get("HF_TOKEN"),
464 $.env.get("GOOGLE_API_KEY"),
465 $.env.get("SENTRY_AUTH_TOKEN"),
466 $.env.get("DATABASE_URL"),
467 ]);
468 return values.filter((v): v is string => !!v);
469}
470
471async function projectDir($: any): Promise<string> {
472 return (await $.env.get("CLAUDE_PROJECT_DIR")) || (await $.session.cwd());
473}
474
475function mergePort($: any, e: any): MergePort {
476 return {
477 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
478 ask: (question, options) =>
479 $.ui.ask(question, { options, header: "Merge" }),
480 writeToken: async (text) => {
481 const cfg = await config($);
482 if (!cfg) throw new Error("no absolute CLAUDE_CONFIG_DIR or HOME");
483 const dir = sessionDir(cfg, await $.session.id());
484 await $.fs.write(mergeTokenFile(dir, e.tool_use_id), text);
485 },
486 now: () => Date.now(),
487 lastComposerAt: () => lastComposerAt(),
488 exemptions: () => $.fs.read(`${$.plugin.root}/merge-exemptions.txt`),
489 note: (text) => journal($, e, "merge", text),
490 lastPrompt: () => lastComposerPrompt(),
491 consume: () => consumeComposerPrompt(),
492 consentOff: async () =>
493 (await $.env.get("KODFLOW_MERGE_CONSENT")) === "off",
494 };
495}
496
497// The session directory's GitHub or GitLab remote, when there is exactly one.
498async function origin($: any): Promise<string | undefined> {
499 const dir = await projectDir($);
500 const remotes = await $.process.run(["git", "-C", dir, "remote"], {
501 timeoutMs: 5000,
502 });
503 const names = remotes.stdout.split("\n").filter(Boolean);
504 if (remotes.exitCode !== 0 || names.length !== 1) return undefined;
505 const url = await $.process.run(
506 ["git", "-C", dir, "remote", "get-url", names[0]],
507 { timeoutMs: 5000 },
508 );
509 return url.exitCode === 0 ? url.stdout.trim() : undefined;
510}
511
512async function guardCtx(
513 $: any,
514 e: any,
515 patterns: () => Promise<Rules | undefined>,
516): Promise<GuardCtx> {
517 const dir = await projectDir($);
518 const cfg = await config($);
519 const agentId = e.agentId || undefined;
520 return {
521 agentId,
522 rootMain: !agentId && (await $.env.get("KODFLOW_ROOT")) !== "off",
523 projectDir: dir,
524 home: await $.env.get("HOME"),
525 configDir: cfg,
526 read: (path) =>
527 $.fs
528 .read(path.startsWith("/") ? path : `${dir}/${path}`)
529 .catch(() => undefined),
530 git: async (gdir, args) => {
531 const r = await $.process
532 .run(["git", "-C", gdir, ...args], { timeoutMs: 5000 })
533 .catch(() => undefined);
534 return r && r.exitCode === 0 ? r.stdout : undefined;
535 },
536 // The merge gate ran first and wrote the token for this very call
537 mergeAllowed: async () => {
538 if (!cfg) return false;
539 const token = await $.fs
540 .read(mergeTokenFile(sessionDir(cfg, await $.session.id()), e.tool_use_id))
541 .catch(() => undefined);
542 const sha = token?.split("\n")[0] ?? "";
543 return /^[0-9a-f]{40}$/.test(sha) && String(e.command).includes(sha);
544 },
545 patterns,
546 };
547}
548
549async function blast($: any, line: string): Promise<{ deny: string } | undefined> {
550 const labels = blastRadius(parse(line));
551 if (!labels.length || !present()) return undefined;
552 const q = `Run this? ${labels.join(" ; ").slice(0, 300)}`;
553 let answer: string;
554 try {
555 answer = await $.ui.ask(q, { options: ["Run it", "Do not run it"], header: "Irreversible" });
556 } catch {
557 answer = "dismissed";
558 }
559 if (answer === "Run it") return undefined;
560 return {
561 deny: refusalText({
562 motif: "blast",
563 title: "COMMAND NOT RUN — the user did not confirm it",
564 lines: [labels.join(" ; ").slice(0, 300), `Answer: ${answer}`],
565 }),
566 };
567}
568
569// The gated list as this copy of the plugin ships it (hooks/gated-models.txt)
570const gatedText = ($: any): Promise<string | undefined> =>
571 $.fs.read(`${$.plugin.root}/hooks/gated-models.txt`).catch(() => undefined);
572
573// Who gave the session its grant, and when; the key is the session's. Only
574// $.store holds it: a file under the session directory could be written by
575// any tool call, so the shell never reads a grant and refuses without the mod.
576async function grantSession($: any, session: string, by: string): Promise<void> {
577 await $.store.set(grantKey(session), { at: Math.floor(Date.now() / 1000), by });
578}
579
580// One spawn judged at a time: two parallel dispatches on a gated model put
581// one question to the user, and the second reads the grant the first wrote.
582let judging: Promise<unknown> = Promise.resolve();
583
584// What agent.spawn's judge reaches; every raw value it reads is kept in `raw`
585// so that a throw can still refuse a spawn naming a gated model.
586async function spawnPort($: any, raw: unknown[]): Promise<SpawnPort & { skillFile: (s: string) => Promise<string | undefined> }> {
587 const session = await $.session.id();
588 return {
589 gated: () => gatedText($),
590 mode: async () => gateMode(await $.env.get("KODFLOW_FABLE")),
591 definition: async (type) => {
592 const cfg = await config($);
593 if (!cfg) return undefined;
594 const installed = await $.fs
595 .read(`${cfg}/plugins/installed_plugins.json`)
596 .catch(() => undefined);
597 for (const path of definitionPaths(type, {
598 project: await projectDir($).catch(() => undefined),
599 config: cfg,
600 installed,
601 })) {
602 const text = await $.fs.read(path).catch(() => undefined);
603 if (text !== undefined) {
604 raw.push(text.slice(0, 4000));
605 return text;
606 }
607 }
608 return undefined;
609 },
610 envModel: async () => {
611 const v = await $.env.get("CLAUDE_CODE_SUBAGENT_MODEL");
612 raw.push(v);
613 return v || undefined;
614 },
615 envForce: async () => forced(await $.env.get("CLAUDE_CODE_SUBAGENT_MODEL_FORCE")),
616 granted: async () => !!(await $.store.get(grantKey(session))),
617 skillFile: async (skill: string) => {
618 const cfg = await config($);
619 if (!cfg) return undefined;
620 const installed = await $.fs
621 .read(`${cfg}/plugins/installed_plugins.json`)
622 .catch(() => undefined);
623 for (const path of skillPaths(skill, { project: await projectDir($).catch(() => undefined), config: cfg, installed })) {
624 const text = await $.fs.read(path).catch(() => undefined);
625 if (text !== undefined) {
626 raw.push(text.slice(0, 4000));
627 return text;
628 }
629 }
630 return undefined;
631 },
632 grant: () => grantSession($, session, "asked at a spawn"),
633 present,
634 ask: (question, options) => $.ui.ask(question, { options, header: "Gated model" }),
635 };
636}
637
638export const register: Register = (on) => {
639 on("session.start", async ($, e, next) => {
640 if (!(await off($))) {
641 await ensureTools($);
642 await $.command.register({
643 name: "lint-setup",
644 description: LINT_SETUP.description,
645 argumentHint: LINT_SETUP.argumentHint,
646 });
647 }
648 await startGuards(guardPort($)).catch(() => undefined);
649 return next(e);
650 });
651
652 on("classic.SessionStart", async ($, e, next) => {
653 if (await off($)) return next(e);
654 await ensureTools($).catch(() => undefined);
655 await beat($, e.session_id, true);
656 const r = await next(e);
657 if (e.source !== "compact") return r;
658 const text = await humanAfterCompact($, e.session_id).catch(() => undefined);
659 return text
660 ? { ...r, additionalContext: [...(r.additionalContext ?? []), text] }
661 : r;
662 }).catch(($, e, next) => next(e));
663
664 on("prompt.submit", async ($, e, next) => {
665 notePrompt(e.origin, Date.now(), e.text);
666 if (!(await off($))) {
667 await ensureTools($);
668 await beat($);
669 if ((e.origin as { kind?: unknown } | undefined)?.kind === "composer") {
670 const cfg = await config($);
671 if (cfg)
672 await $.fs
673 .write(composerFile(sessionDir(cfg, await $.session.id())), beatText(Date.now()))
674 .catch(() => undefined);
675 }
676 }
677 return next(e);
678 }).catch(($, e, next) => next(e));
679
680 // The gated-model rule at the spawn: pass or refuse, never a model written
681 on("agent.spawn", async ($, e, next) => {
682 const raw: unknown[] = [e.model, e.parentModel];
683 const at = { tool: "Agent", tool_use_id: e.tool_use_id, agentId: e.parentAgentId };
684 let verdict;
685 try {
686 if (await off($)) return next(e);
687 // Read before anything that can fail: a throw later still knows a
688 // gated CLAUDE_CODE_SUBAGENT_MODEL was there
689 raw.push(await $.env.get("CLAUDE_CODE_SUBAGENT_MODEL"));
690 const run = judging.then(async () => judgeSpawn(e, await spawnPort($, raw)));
691 judging = run.catch(() => undefined);
692 verdict = await run;
693 } catch (err) {
694 const tokens = failClosedTokens(await gatedText($));
695 const named = raw.find((r) => typeof r === "string" && isGated(r, tokens));
696 if (anyGated(raw, tokens)) {
697 journal($, at, "fable", "error-refused", err);
698 return { deny: fableRefusal(String(named), "the check failed") };
699 }
700 journal($, at, "fable", "error-passed", err);
701 await $.ui
702 .toast("kodflow-hooks: the gated-model check failed on a spawn naming no gated model; it went on", {
703 timeoutMs: 8000,
704 })
705 .catch(() => undefined);
706 return next(e);
707 }
708 journal($, at, "fable", `${verdict.kind}: ${verdict.why}`);
709 if (verdict.kind === "refuse") return { deny: fableRefusal(verdict.model, verdict.why) };
710 return next(e);
711 }).catch(($, e, next) => next(e));
712
713 // A skill pinned to a gated model would switch the main conversation to it
714 on("tool.call", { tool: "Skill" }, async ($, e, next) => {
715 const raw: unknown[] = [];
716 const skill = String((argsOf(e as any) as any)?.skill ?? "").replace(/^\//, "");
717 const at = { tool: "Skill", tool_use_id: e.tool_use_id, agentId: e.agentId };
718 let verdict;
719 try {
720 if (await off($) || !skill) return next(e);
721 const run = judging.then(async () => judgeSkill(skill, await spawnPort($, raw)));
722 judging = run.catch(() => undefined);
723 verdict = await run;
724 } catch (err) {
725 if (anyGated(raw, failClosedTokens(await gatedText($)))) {
726 journal($, at, "fable", "error-refused", err);
727 return { deny: fableRefusal(skill, "the check failed") };
728 }
729 journal($, at, "fable", "error-passed", err);
730 return next(e);
731 }
732 if (verdict.kind === "refuse") {
733 journal($, at, "fable", `refuse: ${verdict.why}`);
734 return { deny: fableRefusal(verdict.model, verdict.why) };
735 }
736 return next(e);
737 }).catch(($, e, next) => next(e));
738
739 // A /model the user types, or the picker, is the consent (D5); a switch from
740 // the SDK asks like a spawn, and is refused with nobody at the prompt
741 on("classic.PreModelSwitch", async ($, e, next) => {
742 const r = await next(e);
743 try {
744 if (await off($) || r.permissionDecision === "deny") return r;
745 const session = await $.session.id();
746 const v = judgeSwitch(e, parseGated(await gatedText($)), gateMode(await $.env.get("KODFLOW_FABLE")));
747 const at = { tool: "PreModelSwitch", tool_use_id: e.prompt_id };
748 if (v.kind === "grant") await grantSession($, session, v.why);
749 let deny: string | undefined;
750 if (v.kind === "ask" && !(await $.store.get(grantKey(session)))) {
751 let answer = "nobody at the prompt";
752 if (present()) {
753 answer = await $.ui
754 .ask(`Switch this session to ${e.to_model}, a gated model (${v.why})?`, {
755 options: ["Switch", "Stay"],
756 header: "Gated model",
757 })
758 .catch(() => "dismissed");
759 }
760 if (answer === "Switch") await grantSession($, session, `asked at a ${v.why}`);
761 else deny = fableRefusal(String(e.to_model), `${v.why}; ${answer}`);
762 }
763 journal($, at, "fable", deny ? `refuse: ${v.why}` : `${v.kind}: ${v.why}`);
764 return deny ? { ...r, permissionDecision: "deny", permissionDecisionReason: deny } : r;
765 } catch (err) {
766 // After next(e): answer from r, never call next again. A gated target
767 // from anywhere but the user's own /model is refused.
768 const gated = isGated(e.to_model, failClosedTokens(await gatedText($)));
769 if (!gated || e.source === "command" || e.source === "picker") return r;
770 return { ...r, permissionDecision: "deny", permissionDecisionReason: fableRefusal(String(e.to_model), `the check failed: ${String((err as Error)?.message ?? err)}`) };
771 }
772 }).catch(($, e, next) => next(e));
773
774 on("tool.call", async ($, e, next) => {
775 if (await off($)) return next(e);
776 const tool = taskTool(e.tool);
777 // Before next: the shell's PreToolUse runs inside it and reads the beat
778 await beat($).catch(() => undefined);
779 if (tool) return serve($, tool, e);
780 if (e.agentId && WRITING_TOOLS.has(e.tool)) void noteWorktrees($, e);
781 // A forge tool writing an mcp.json: refused for every caller, as the
782 // shell does; doubled, so a failure lets the call on to the shell
783 if (/^mcp__(github|gitlab|GitKraken)__/.test(e.tool)) {
784 try {
785 const mj = await forgeMcpJson(
786 e.tool,
787 argsOf(e as any) ?? {},
788 await guardCtx($, e, async () => rules),
789 );
790 if (mj) {
791 journal($, e, "mcp-json", "deny");
792 return { deny: refusalText(mj) };
793 }
794 } catch (err) {
795 journal($, e, "mcp-json", "error", err);
796 }
797 }
798 // A forge merge tool: the same gate as a Bash merge, a failure refuses
799 const merge = classifyMcp(e.tool, argsOf(e as any));
800 if (merge.kind === "merge") {
801 const g = await gate(merge, { agentId: e.agentId }, mergePort($, e)).catch(
802 (err) => mergeFailure(err),
803 );
804 if (g && "deny" in g) {
805 journal($, e, "merge", "deny");
806 return g;
807 }
808 }
809 const r: any = await next(e);
810 try {
811 secrets ??= secretValues($);
812 const veiled = veilResult(r, { envValues: await secrets, now: Date.now });
813 if (veiled !== r) journal($, e, "veil", "masked");
814 return veiled;
815 } catch (err) {
816 journal($, e, "veil", "error", err);
817 return r;
818 }
819 }).catch(($, e, next) => {
820 if (next.called) return next(e);
821 if (taskTool(e.tool))
822 return {
823 deny: `kodflow task tools failed: ${next.error.message ?? next.error.kind}`,
824 };
825 if (classifyMcp(e.tool, argsOf(e as any)).kind === "merge")
826 return mergeFailure(next.error.message ?? next.error.kind);
827 return next(e);
828 });
829
830 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
831 if (await off($)) return next(e);
832 const line = typeof e.command === "string" ? e.command : "";
833 return runBash(() => next(e), {
834 merge: async () => {
835 const c = await classifyBash(line, {
836 ghRepo: await $.env.get("GH_REPO"),
837 origin: () => origin($),
838 });
839 const g = await gate(c, { agentId: e.agentId }, mergePort($, e));
840 return g && "deny" in g ? g : undefined;
841 },
842 guards: async () => {
843 rules ??= await loadRules(
844 (path) => $.fs.read(path),
845 `${$.plugin.root}/hooks/patterns`,
846 );
847 const ctx = await guardCtx($, e, async () => rules);
848 const found = await inspect(line, ctx);
849 const refusal = found && (await verdict(line, found, ctx));
850 return refusal ? { deny: refusalText(refusal) } : undefined;
851 },
852 blast: () => blast($, line),
853 filter: async (r) => {
854 validated ??= $.session.version().then(
855 (v: any) => versionOk(v?.base),
856 () => false,
857 );
858 return filterResult(line, r, await validated!);
859 },
860 log: (policy, decision, error) => journal($, e, policy, decision, error),
861 });
862 }).catch(($, e, next) => {
863 if (next.called) return next(e);
864 if (mayMerge(e.command))
865 return mergeFailure(next.error.message ?? next.error.kind);
866 return next(e);
867 });
868
869 on("command.run", { command: "lint-setup" }, async ($, e) => {
870 try {
871 return { text: await lintSetup(await lintPort($), e.args ?? "") };
872 } catch (err: any) {
873 return { text: `/lint-setup failed: ${err?.message ?? String(err)}` };
874 }
875 });
876
877 on("attribution.text", async ($, e, next) => {
878 const text = attributionText(e.kind);
879 return text === undefined || (await off($)) ? next(e) : { text };
880 });
881
882 // modwall: refuses a user-tier module from outside the kodflow marketplace
883 on("plugin.register", async ($, e, next) => {
884 const refused = await admit(guardPort($), e);
885 return refused ?? next(e);
886 }).catch(($, e, next) => next(e));
887
888 on("command.run", { command: "kodflow-mods" }, async ($) => ({
889 text: await modsReport(guardPort($)),
890 }));
891
892 on("session.end", async ($, e, next) => {
893 await endGuards(guardPort($)).catch(() => undefined);
894 return next(e);
895 });
896
897 // collision-guard: a file another live session wrote in the last 30 min
898 on("tool.call", { tool: ["Edit", "Write", "MultiEdit", "NotebookEdit"] }, async ($, e, next) =>
899 guardWrite(guardPort($), e as any, () => next(e)),
900 ).catch(($, e, next) => next(e));
901};
902hooks/mod/tasks.ts 1193 lines1// tasks.ts — the session task list, one list per agent. Pure: no `$`, no I/O.
2//
3// Tasks are grouped in epics, one per subject. An agent can have several epics
4// open at once; one of them is active (the one being worked on). Every task
5// names its epic when it is created — there is no default. The file format
6// (tasks.json, version 2) is a contract shared with the status line and the
7// shell hooks (lib/epics.jq), which read it directly.
8//
9// This is a line-for-line port of the former mcp/tasks.py: same messages, same
10// order of validation, timestamps in seconds, lengths in code points. The
11// conformance corpus (tests/mod/conformance.corpus.ts), recorded from tasks.py,
12// holds it to that.
13//
14// One tool came after the port: task_assign_human files a task only the user
15// can do (a "go", a Touch ID, a gesture on a NAS). It is a task like the
16// others, with `assignee: "human"` and the status `waiting`, so the readers
17// of tasks.json that know nothing of the field still count it right.
18//
19// The task tree came after it too (kodflow-hud draws it): three optional
20// fields a task carries only once set, so a file without them is read as
21// before and every reader that ignores them (the status line decodes named
22// fields, lib/epics.jq picks the ones it uses) reads version 2 unchanged:
23// `parent`, the task it is a subtask of (three levels at most); `blockedBy`,
24// the tasks it waits on; `ref`, a short handle (T0, C6) the tree shows.
25
26/** The title column of the task tree; tasks.py allowed 40. */
27export const MAX_SUBJECT = 18;
28export const MAX_TITLE = 20;
29/** A ref is a handle (T0, C6, #12), not a name. */
30export const MAX_REF = 5;
31/** Task, subtask, sub-subtask. */
32export const MAX_DEPTH = 3;
33export const STATUSES = [
34 "pending",
35 "in_progress",
36 "waiting",
37 "completed",
38 "deleted",
39] as const;
40export const MAIN = "main";
41/** `assignee` of a task only the user can do (task_assign_human). */
42export const HUMAN = "human";
43/** At most this many of them open at once: a queue nobody reads is noise. */
44export const MAX_HUMAN_OPEN = 5;
45
46/** A refusal the caller reads: the tool answers `{ deny: message }`. */
47export class TaskError extends Error {
48 readonly kind = "task-error";
49}
50
51export function isTaskError(err: unknown): err is TaskError {
52 return (
53 typeof err === "object" &&
54 err !== null &&
55 (err as { kind?: unknown }).kind === "task-error"
56 );
57}
58
59type Obj = Record<string, any>;
60export type Data = Obj & {
61 version: 2;
62 next_id: number;
63 next_epic: number;
64 epics: Obj[];
65 active: Record<string, any>;
66 tasks: Obj[];
67};
68
69/** A subagent of the session, as the caller resolved it. */
70export type Worker = {
71 id: string;
72 type: string;
73 name?: string;
74 alive: boolean;
75 started?: number;
76};
77
78/** What a step reads from the world: the time in seconds, the subagents. */
79export type Ctx = { now: number; workers: readonly Worker[] };
80
81// --- Python semantics the port depends on -----------------------------------
82
83const isObj = (v: unknown): v is Obj =>
84 typeof v === "object" && v !== null && !Array.isArray(v);
85const isInt = (v: unknown): v is number =>
86 typeof v === "number" && Number.isInteger(v);
87// dict.get(key, default): a key present with null keeps null
88const get = (o: Obj, k: string, d: unknown): any =>
89 Object.hasOwn(o, k) ? o[k] : d;
90// `x or y`
91const truthy = (v: unknown): boolean =>
92 !(
93 v === undefined ||
94 v === null ||
95 v === false ||
96 v === 0 ||
97 v === "" ||
98 (Array.isArray(v) && v.length === 0) ||
99 (isObj(v) && Object.keys(v).length === 0)
100 );
101const or = <T>(v: unknown, d: T): any => (truthy(v) ? v : d);
102
103/** str(value) for the JSON values a tool call can carry. */
104export function pyStr(v: unknown): string {
105 if (typeof v === "string") return v;
106 if (v === undefined || v === null) return "None";
107 if (v === true) return "True";
108 if (v === false) return "False";
109 if (typeof v === "number") return String(v);
110 return JSON.stringify(v);
111}
112
113// str.strip(): Python's whitespace, not JavaScript's (\x1c-\x1f in, out)
114const WS =
115 "\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
116const STRIP = new RegExp(`^[${WS}]+|[${WS}]+$`, "gu");
117export const strip = (s: string): string => s.replace(STRIP, "");
118
119/** len(): code points, not UTF-16 units. */
120export const codePoints = (s: string): number => [...s].length;
121
122// --- the file ----------------------------------------------------------------
123
124/**
125 * Bring a tasks.json of any earlier shape to version 2, in memory.
126 *
127 * v1 kept one epic per agent, {"<agent>": {"id", "title"}}; v2 keeps a list
128 * of epics, several open at once, and the agent's active one in `active`.
129 * The file is rewritten in v2 by the next mutation.
130 */
131export function upgrade(raw: unknown, now: number): Data {
132 const data: Obj = isObj(raw) ? raw : {};
133 let epics: any = data.epics;
134 const active: Obj = isObj(data.active) ? data.active : {};
135 if (isObj(epics)) {
136 const converted: Obj[] = [];
137 for (const [agent, epic] of Object.entries(epics)) {
138 if (isObj(epic) && isInt(epic.id)) {
139 converted.push({
140 id: epic.id,
141 agent,
142 title: pyStr(or(epic.title, "")),
143 created: now,
144 touched: now,
145 });
146 active[agent] = epic.id;
147 }
148 }
149 epics = converted;
150 }
151 if (!Array.isArray(epics)) epics = [];
152 epics = epics.filter((e: unknown) => isObj(e) && isInt(e.id));
153 let tasks: any[] = Array.isArray(data.tasks) ? data.tasks : [];
154 tasks = tasks.filter((t) => isObj(t) && Object.hasOwn(t, "id"));
155 for (const task of tasks) {
156 task.id = pyStr(task.id);
157 if (!Object.hasOwn(task, "agent")) task.agent = MAIN;
158 if (!Object.hasOwn(task, "status")) task.status = "pending";
159 if (!Object.hasOwn(task, "subject")) task.subject = "";
160 if (!isInt(task.epic)) task.epic = 0;
161 }
162 const ids: number[] = epics.map((e: Obj) => e.id);
163 const highest = ids.length ? Math.max(...ids) : 0;
164 Object.assign(data, {
165 version: 2,
166 next_id: isInt(data.next_id) ? data.next_id : 1,
167 next_epic: Math.max(
168 isInt(data.next_epic) ? data.next_epic : 1,
169 highest + 1,
170 ),
171 epics,
172 active,
173 tasks,
174 });
175 return data as Data;
176}
177
178/** Parse the file's text; anything unreadable is an empty list. */
179export function parse(text: string | undefined, now: number): Data {
180 let raw: unknown = {};
181 if (text !== undefined) {
182 try {
183 raw = JSON.parse(text);
184 } catch {
185 raw = {};
186 }
187 }
188 return upgrade(raw, now);
189}
190
191/** The file's text, as tasks.py wrote it (indent 1, UTF-8 kept). */
192export const serialize = (data: Data): string => JSON.stringify(data, null, 1);
193
194// --- validation ----------------------------------------------------------------
195
196function checkText(
197 value: unknown,
198 what: string,
199 limit: number,
200 hint: string,
201): string {
202 const v = strip(pyStr(or(value, "")));
203 if (!v) throw new TaskError(`${what} is required`);
204 const n = codePoints(v);
205 if (n > limit)
206 throw new TaskError(
207 `${what} is ${n} characters, the limit is ${limit}: ${hint}`,
208 );
209 return v;
210}
211
212const checkSubject = (subject: unknown): string =>
213 checkText(
214 subject,
215 "subject",
216 MAX_SUBJECT,
217 "it is the title column of the task tree and is shown in full on the status line. " +
218 "Shorten it to an imperative title and move the detail to description.",
219 );
220
221/** A ref given: the handle, or "" to clear it. */
222function checkRef(value: unknown): string {
223 const v = strip(pyStr(value));
224 const n = codePoints(v);
225 if (n > MAX_REF)
226 throw new TaskError(
227 `ref is ${n} characters, the limit is ${MAX_REF}: a short handle such as T0 or C6, ` +
228 'shown in the tree\'s identifier column; ref="" clears it.',
229 );
230 return v;
231}
232
233/** A task id as a caller spells it: "12", "#12" or 12. */
234const idOf = (value: unknown): string => strip(pyStr(value)).replace(/^#+/, "");
235
236/** blocked_by given: a list of ids (or one, or "3, 4"), once each, in order. */
237function checkIds(value: unknown): string[] {
238 const items = Array.isArray(value) ? value : pyStr(value).split(/[\s,]+/);
239 const ids: string[] = [];
240 for (const item of items) {
241 const id = idOf(item);
242 if (id && !ids.includes(id)) ids.push(id);
243 }
244 return ids;
245}
246
247const checkTitle = (title: unknown): string =>
248 checkText(
249 title,
250 "title",
251 MAX_TITLE,
252 "it is the label of a pill on the status line. Use a short noun phrase " +
253 '("SDK status-line", "api-gateway").',
254 );
255
256// --- epics ---------------------------------------------------------------------
257
258const agentEpics = (data: Data, agent: string): Obj[] =>
259 data.epics.filter((e) => get(e, "agent", MAIN) === agent);
260
261function findEpic(data: Data, agent: string, epicId: unknown): Obj | undefined {
262 return agentEpics(data, agent).find((e) => e.id === epicId);
263}
264
265/** The id of the agent's active epic, 0 when it has none. */
266function activeEpic(data: Data, agent: string): any {
267 const epicId = get(data.active, agent, 0);
268 return findEpic(data, agent, epicId) ? epicId : 0;
269}
270
271const tasksOf = (data: Data, agent: string, epicId: unknown): Obj[] =>
272 data.tasks.filter(
273 (t) => get(t, "agent", MAIN) === agent && get(t, "epic", 0) === epicId,
274 );
275
276function progress(
277 data: Data,
278 agent: string,
279 epicId: unknown,
280): [number, number] {
281 const tasks = tasksOf(data, agent, epicId);
282 return [tasks.filter((t) => t.status === "completed").length, tasks.length];
283}
284
285/** Open: a task not completed, or the active epic with no task yet. */
286function isOpen(data: Data, agent: string, epic: Obj): boolean {
287 const [done, total] = progress(data, agent, epic.id);
288 return done < total || (total === 0 && activeEpic(data, agent) === epic.id);
289}
290
291/** The agent's open epics, most recently touched first. */
292function openEpics(data: Data, agent: string): Obj[] {
293 return agentEpics(data, agent)
294 .filter((e) => isOpen(data, agent, e))
295 .sort((a, b) => get(b, "touched", 0) - get(a, "touched", 0));
296}
297
298function touch(data: Data, agent: string, epicId: unknown, now: number): void {
299 const epic = findEpic(data, agent, epicId);
300 if (epic) epic.touched = now;
301}
302
303function parseEpicId(value: unknown): number | null {
304 const text = strip(pyStr(value)).replace(/^#+/, "");
305 return /^[0-9]+$/.test(text) ? parseInt(text, 10) : null;
306}
307
308function describeOpen(data: Data, agent: string): string {
309 const epics = openEpics(data, agent);
310 if (epics.length === 0) return "no epic is open";
311 const active = activeEpic(data, agent);
312 return (
313 "open epics: " +
314 epics
315 .map((e) => `#${e.id} ${e.title}${e.id === active ? " (active)" : ""}`)
316 .join(", ")
317 );
318}
319
320const EPIC_SYNTAX =
321 "task_create(subject, epic=<id>) files the task in one of your epics; epic=0 files it outside " +
322 "any epic; task_epic(title) opens a new epic first.";
323
324// --- workers ---------------------------------------------------------------------
325
326/** A worker is named by its agent id, its SendMessage name or its type. */
327const matches = (w: Worker, name: string): boolean =>
328 name === w.id || name === (w.type || "") || (!!w.name && name === w.name);
329
330/**
331 * Whether the worker linked to a task is still running, by its recorded id
332 * alone: an agent resumed by SendMessage keeps its id (measured on 2.1.296).
333 */
334function workerAlive(workers: readonly Worker[], task: Obj): boolean {
335 const entry = workers.find((w) => w.id === (task.worker_id || ""));
336 return entry !== undefined && entry.alive;
337}
338
339function since(started: number, now: number): string {
340 const minutes = Math.max(
341 0,
342 Math.floor(Math.trunc(now - (started || 0)) / 60),
343 );
344 return minutes >= 60
345 ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}`
346 : `${minutes} min`;
347}
348
349/** Who is counted: the running workers, and who holds each task in progress. */
350function workersReport(ctx: Ctx, data: Data, agent: string): string {
351 const live = ctx.workers
352 .filter((w) => w.alive)
353 .map((w, i) => [w, i] as const)
354 .sort(([a, i], [b, j]) => (a.started || 0) - (b.started || 0) || i - j)
355 .map(([w]) => w);
356 const lines = [
357 "Running subagents: " +
358 (live
359 .map((w) =>
360 w.started === undefined
361 ? `${w.type || "?"} (id ${w.id})`
362 : `${w.type || "?"} (id ${w.id}, for ${since(w.started, ctx.now)})`,
363 )
364 .join(", ") || "none"),
365 ];
366 for (const t of data.tasks) {
367 if (get(t, "agent", MAIN) !== agent || t.status !== "in_progress") continue;
368 if (truthy(t.worker)) {
369 lines.push(
370 `#${t.id} ${t.subject}: worker ${t.worker} (${workerAlive(ctx.workers, t) ? "running" : "STOPPED"})`,
371 );
372 } else {
373 lines.push(`#${t.id} ${t.subject}: no worker linked`);
374 }
375 }
376 return lines.join("\n");
377}
378
379/** Resolve the worker a task is handed to; refuse a stopped or busy one. */
380function claimWorker(
381 ctx: Ctx,
382 data: Data,
383 agent: string,
384 taskId: string,
385 worker: string,
386): string {
387 const named = ctx.workers.filter((w) => matches(w, worker));
388 const running = named.filter((w) => w.alive);
389 const unblock =
390 "Name a running subagent (its type or id, listed above) with worker=, or start one for this task " +
391 "first; a task nobody runs stays pending.";
392 if (running.length === 0) {
393 const why = named.length
394 ? "is stopped"
395 : "is not a subagent of this session";
396 throw new TaskError(
397 `cannot start #${taskId} on worker ${worker}: it ${why}.\n${workersReport(ctx, data, agent)}\n${unblock}`,
398 );
399 }
400 const holding = new Map<string, Obj>();
401 for (const t of data.tasks) {
402 if (
403 get(t, "agent", MAIN) === agent &&
404 t.status === "in_progress" &&
405 t.id !== taskId &&
406 truthy(t.worker_id)
407 ) {
408 holding.set(t.worker_id, t);
409 }
410 }
411 const free = running.filter((w) => !holding.has(w.id));
412 if (free.length === 0) {
413 const t = holding.get(running[0]!.id)!;
414 throw new TaskError(
415 `cannot start #${taskId} on worker ${worker}: it already holds #${t.id} ${t.subject}. One task per worker: set #${t.id} to ` +
416 `completed, pending or waiting first, or hand this one to another subagent.\n${workersReport(ctx, data, agent)}`,
417 );
418 }
419 return free[0]!.id;
420}
421
422/**
423 * How many tasks an agent may have in progress: one per worker.
424 *
425 * A subagent is one worker. The main agent is one worker plus every
426 * subagent it has running: a task handed to a subagent is in progress
427 * while that subagent works, and only then.
428 */
429const inProgressCap = (ctx: Ctx, agent: string): number =>
430 agent === MAIN ? 1 + ctx.workers.filter((w) => w.alive).length : 1;
431
432// --- the tree ------------------------------------------------------------------------
433
434const taskOf = (data: Data, agent: string, id: string): Obj | undefined =>
435 data.tasks.find((t) => t.id === id && get(t, "agent", MAIN) === agent);
436
437/** 1 for a task with no parent; a parent naming nothing ends the chain. */
438function depthOf(data: Data, agent: string, task: Obj): number {
439 const seen = new Set<string>();
440 let depth = 1;
441 for (let t = task; truthy(t.parent) && !seen.has(t.id); depth++) {
442 seen.add(t.id);
443 const up = taskOf(data, agent, pyStr(t.parent));
444 if (!up) break;
445 t = up;
446 }
447 return depth;
448}
449
450/** Levels under a task: 0 for a leaf, 1 with children, 2 with grandchildren. */
451function heightOf(data: Data, agent: string, id: string, seen = new Set<string>()): number {
452 if (seen.has(id)) return 0;
453 seen.add(id);
454 let height = 0;
455 for (const t of data.tasks)
456 if (get(t, "agent", MAIN) === agent && pyStr(get(t, "parent", "")) === id)
457 height = Math.max(height, 1 + heightOf(data, agent, t.id, seen));
458 return height;
459}
460
461/**
462 * File `task` under `parentId`, "" taking it out of any: the parent exists in
463 * the agent's list, is in the same epic, is neither the task nor under it,
464 * and the task with its own subtasks still fits in MAX_DEPTH levels.
465 */
466function setParent(data: Data, agent: string, task: Obj, parentId: string): void {
467 if (!parentId) {
468 delete task.parent;
469 return;
470 }
471 const parent = taskOf(data, agent, parentId);
472 if (!parent)
473 throw new TaskError(
474 `parent: no task #${parentId} in this agent's list to file #${task.id} under`,
475 );
476 for (let t: Obj | undefined = parent, i = 0; t && i <= data.tasks.length; i++) {
477 if (t.id === task.id)
478 throw new TaskError(
479 `parent: #${task.id} cannot go under #${parentId}, which is #${task.id} itself or one of its subtasks: that would be a cycle`,
480 );
481 t = truthy(t.parent) ? taskOf(data, agent, pyStr(t.parent)) : undefined;
482 }
483 const epic = get(task, "epic", 0);
484 if (get(parent, "epic", 0) !== epic)
485 throw new TaskError(
486 `parent: #${parentId} is in epic #${get(parent, "epic", 0)} and #${task.id} in epic #${epic}: a subtask is filed in its parent's epic`,
487 );
488 const depth = depthOf(data, agent, parent) + 1 + heightOf(data, agent, task.id);
489 if (depth > MAX_DEPTH)
490 throw new TaskError(
491 `parent: under #${parentId}, #${task.id} would reach ${depth} levels, the limit is ${MAX_DEPTH} ` +
492 "(task, subtask, sub-subtask): file it higher up, or split the subject into its own task",
493 );
494 task.parent = parentId;
495}
496
497/** The chain from `from` to `to` along blockedBy, when there is one. */
498function waitPath(data: Data, agent: string, from: string, to: string): string[] | undefined {
499 const seen = new Set<string>();
500 const walk = (id: string): string[] | undefined => {
501 if (id === to) return [id];
502 if (seen.has(id)) return undefined;
503 seen.add(id);
504 const t = taskOf(data, agent, id);
505 for (const next of Array.isArray(t?.blockedBy) ? t!.blockedBy : []) {
506 const rest = walk(pyStr(next));
507 if (rest) return [id, ...rest];
508 }
509 return undefined;
510 };
511 return walk(from);
512}
513
514/**
515 * Make `task` wait on `ids`, [] for none: each one exists in the agent's
516 * list, is not the task, and does not already wait on it, however far.
517 */
518function setBlockers(data: Data, agent: string, task: Obj, ids: string[]): void {
519 if (!ids.length) {
520 delete task.blockedBy;
521 return;
522 }
523 for (const id of ids) {
524 if (id === task.id)
525 throw new TaskError(`blocked_by: #${task.id} cannot wait on itself`);
526 if (!taskOf(data, agent, id))
527 throw new TaskError(`blocked_by: no task #${id} in this agent's list`);
528 const path = waitPath(data, agent, id, task.id);
529 if (path)
530 throw new TaskError(
531 `blocked_by: #${task.id} waiting on #${id} would be a cycle, #${id} already waits on #${task.id} ` +
532 `(${path.map((p) => `#${p}`).join(" → ")})`,
533 );
534 }
535 task.blockedBy = ids;
536}
537
538/** A deleted task leaves no dangling link: its subtasks go up a level. */
539function unlink(data: Data, agent: string, gone: Obj): void {
540 for (const t of data.tasks) {
541 if (get(t, "agent", MAIN) !== agent) continue;
542 if (pyStr(get(t, "parent", "")) === gone.id) {
543 if (truthy(gone.parent)) t.parent = gone.parent;
544 else delete t.parent;
545 }
546 if (Array.isArray(t.blockedBy) && t.blockedBy.some((b: unknown) => pyStr(b) === gone.id)) {
547 t.blockedBy = t.blockedBy.filter((b: unknown) => pyStr(b) !== gone.id);
548 if (!t.blockedBy.length) delete t.blockedBy;
549 }
550 }
551}
552
553/** What task_create and task_update take beyond tasks.py's arguments. */
554type TreeArgs = { parent?: string; blockers?: string[]; ref?: string };
555
556const given = (v: unknown): boolean => v !== undefined && v !== null;
557
558function treeArgs(args: Obj): TreeArgs {
559 const out: TreeArgs = {};
560 if (given(args.parent)) out.parent = idOf(args.parent);
561 if (given(args.blocked_by)) out.blockers = checkIds(args.blocked_by);
562 if (given(args.ref)) out.ref = checkRef(args.ref);
563 return out;
564}
565
566function applyTree(data: Data, agent: string, task: Obj, tree: TreeArgs): void {
567 if (tree.parent !== undefined) setParent(data, agent, task, tree.parent);
568 if (tree.blockers !== undefined) setBlockers(data, agent, task, tree.blockers);
569 if (tree.ref !== undefined) {
570 if (tree.ref) task.ref = tree.ref;
571 else delete task.ref;
572 }
573}
574
575// --- the tools ---------------------------------------------------------------------
576
577/** A tool call checked: whether it writes, and the change it makes. */
578export type Step = { mutates: boolean; run: (data: Data, ctx: Ctx) => string };
579
580function taskCreate(agent: string, args: Obj): Step {
581 const subject = checkSubject(args.subject);
582 const tree = treeArgs(args);
583 const wanted = args.epic;
584 return {
585 mutates: true,
586 run(data, ctx) {
587 // No default epic: a task that silently lands in whatever epic happens
588 // to be active is how work gets filed under the wrong subject. The
589 // caller names the epic every time, 0 included.
590 if (
591 wanted === undefined ||
592 wanted === null ||
593 strip(pyStr(wanted)) === ""
594 ) {
595 throw new TaskError(
596 `epic is required: name the epic this task belongs to; ${describeOpen(data, agent)}. ${EPIC_SYNTAX}`,
597 );
598 }
599 const epicId = parseEpicId(wanted);
600 if (epicId === null || (epicId !== 0 && !findEpic(data, agent, epicId))) {
601 throw new TaskError(
602 `no epic ${pyStr(wanted)} for this agent; ${describeOpen(data, agent)}. ${EPIC_SYNTAX}`,
603 );
604 }
605 const task = {
606 id: String(data.next_id),
607 agent,
608 epic: epicId,
609 subject,
610 description: or(args.description, ""),
611 status: "pending",
612 created: ctx.now,
613 updated: ctx.now,
614 };
615 applyTree(data, agent, task, tree);
616 data.next_id += 1;
617 data.tasks.push(task);
618 touch(data, agent, epicId, ctx.now);
619 let note = "";
620 if (epicId && epicId !== activeEpic(data, agent)) {
621 note = ` (epic #${epicId} is not the active one: task_focus it when you start working on it)`;
622 } else if (epicId) {
623 note = ` in epic #${epicId} ${findEpic(data, agent, epicId)!.title}`;
624 }
625 return `Task #${task.id} created: ${subject}${note}`;
626 },
627 };
628}
629
630function taskUpdate(agent: string, args: Obj): Step {
631 const taskId = strip(pyStr(or(args.id, ""))).replace(/^#+/, "");
632 // A caller that names the argument something else — task_id is the common
633 // slip — would otherwise look the empty id up and be told "no task # in
634 // this agent's list", which sends the reader hunting for an ownership
635 // problem instead of a missing argument.
636 if (!taskId) throw new TaskError('task_update needs the task id: id="36"');
637 const status = args.status;
638 let subject = args.subject;
639 if (
640 status !== undefined &&
641 status !== null &&
642 !(STATUSES as readonly unknown[]).includes(status)
643 ) {
644 throw new TaskError(`status must be one of ${STATUSES.join(", ")}`);
645 }
646 if (subject !== undefined && subject !== null)
647 subject = checkSubject(subject);
648 const tree = treeArgs(args);
649 // worker only means something on a start; with any other status it is
650 // ignored, and leaving in_progress drops the link anyway
651 const worker =
652 status === "in_progress" ? strip(pyStr(or(args.worker, ""))) : "";
653 return {
654 mutates: true,
655 run(data, ctx) {
656 const idx = data.tasks.findIndex(
657 (t) => t.id === taskId && get(t, "agent", MAIN) === agent,
658 );
659 if (idx < 0)
660 throw new TaskError(`no task #${taskId} in this agent's list`);
661 const task = data.tasks[idx]!;
662 touch(data, agent, get(task, "epic", 0), ctx.now);
663 if (status === "deleted") {
664 data.tasks.splice(idx, 1);
665 unlink(data, agent, task);
666 return `Task #${taskId} deleted`;
667 }
668 // The tree is checked before anything moves: a refusal leaves it as it was
669 applyTree(data, agent, task, tree);
670 // Nobody but the user works on the user's task: no start, no worker
671 if (
672 isHuman(task) &&
673 (status === "in_progress" || status === "pending")
674 ) {
675 throw new TaskError(
676 `#${taskId} is assigned to the user: it stays waiting until they answer, then completed (or deleted when it no longer applies).`,
677 );
678 }
679 // One task in progress per worker: an amber cell on the status
680 // line must mean someone is on it right now
681 const starting =
682 status === "in_progress" && task.status !== "in_progress";
683 if (worker) {
684 // A named worker is checked for itself: running, and on no other
685 // task. The headcount below cannot tell a task held by a live
686 // subagent from one left by a dead one.
687 task.worker_id = claimWorker(ctx, data, agent, taskId, worker);
688 task.worker = worker;
689 } else if (starting) {
690 const busy = data.tasks.filter(
691 (t) =>
692 get(t, "agent", MAIN) === agent &&
693 t.status === "in_progress" &&
694 t.id !== taskId,
695 );
696 const cap = inProgressCap(ctx, agent);
697 if (busy.length >= cap) {
698 const workers =
699 cap === 1 ? "you" : `you and ${cap - 1} running subagent(s)`;
700 throw new TaskError(
701 `cannot start #${taskId}: ${busy.length} task(s) already in progress (${busy.map((t) => `#${t.id} ${t.subject}`).join(", ")}) ` +
702 `for ${cap} worker(s), ${workers}.\n${workersReport(ctx, data, agent)}\n` +
703 "One task per worker: complete a task nobody is on, or set it to pending or waiting; " +
704 "to hand this one to a running subagent, pass worker=<its type or id> with " +
705 "status=in_progress (refused if that subagent is stopped or already holds a task).",
706 );
707 }
708 }
709 if (truthy(status) && status !== "in_progress") {
710 // The link lasts as long as the task is in progress
711 delete task.worker;
712 delete task.worker_id;
713 }
714 if (truthy(status)) task.status = status;
715 if (truthy(subject)) task.subject = subject;
716 if (args.description !== undefined && args.description !== null)
717 task.description = args.description;
718 task.updated = ctx.now;
719 const held = truthy(task.worker) ? ` on worker ${task.worker}` : "";
720 return `Task #${taskId} updated: ${task.subject} (${task.status}${held})`;
721 },
722 };
723}
724
725function taskList(agent: string): Step {
726 return {
727 mutates: false,
728 run(data) {
729 const active = activeEpic(data, agent);
730 const lines: string[] = [];
731 const rows = (tasks: Obj[]) =>
732 lines.push(
733 ...tasks.map(
734 (t) =>
735 `#${t.id} [${t.status}] ${t.subject}${isHuman(t) ? " (assigned to the user)" : ""}`,
736 ),
737 );
738 if (active) {
739 const epic = findEpic(data, agent, active)!;
740 const [done, total] = progress(data, agent, active);
741 lines.push(`Epic #${active}: ${epic.title} (active, ${done}/${total})`);
742 if (total) rows(tasksOf(data, agent, active));
743 else lines.push("No tasks yet.");
744 }
745 const others = openEpics(data, agent).filter((e) => e.id !== active);
746 if (others.length) {
747 lines.push("Other open epics:");
748 for (const epic of others) {
749 const [done, total] = progress(data, agent, epic.id);
750 lines.push(`#${epic.id} ${epic.title} ${done}/${total}`);
751 }
752 }
753 const loose = tasksOf(data, agent, 0);
754 if (loose.length) {
755 lines.push(
756 active
757 ? "Tasks with no epic:"
758 : "No active epic. Tasks with no epic:",
759 );
760 rows(loose);
761 }
762 return lines.join("\n") || "No tasks and no epic.";
763 },
764 };
765}
766
767function taskEpic(agent: string, args: Obj): Step {
768 const title = checkTitle(args.title);
769 return {
770 mutates: true,
771 run(data, ctx) {
772 for (const epic of openEpics(data, agent)) {
773 if (epic.title === title) {
774 data.active[agent] = epic.id;
775 epic.touched = ctx.now;
776 const [done, total] = progress(data, agent, epic.id);
777 return `Epic #${epic.id} ${title} is already open (${done}/${total}): focused it instead of opening a duplicate.`;
778 }
779 }
780 const epic = {
781 id: data.next_epic,
782 agent,
783 title,
784 created: ctx.now,
785 touched: ctx.now,
786 };
787 data.next_epic += 1;
788 data.epics.push(epic);
789 data.active[agent] = epic.id;
790 const others = openEpics(data, agent).filter((e) => e.id !== epic.id);
791 const tail = others.length
792 ? " Still open: " +
793 others.map((e) => `#${e.id} ${e.title}`).join(", ") +
794 "."
795 : "";
796 return `Epic #${epic.id} ${title} opened and active: file its tasks with task_create(epic=${epic.id}).${tail}`;
797 },
798 };
799}
800
801function taskFocus(agent: string, args: Obj): Step {
802 const wanted = strip(pyStr(or(args.epic, "")));
803 if (!wanted)
804 throw new TaskError("epic is required: an epic id or its exact title");
805 return {
806 mutates: true,
807 run(data, ctx) {
808 const epics = openEpics(data, agent);
809 const epicId = parseEpicId(wanted);
810 let match = epics.filter((e) => e.id === epicId);
811 if (!match.length) match = epics.filter((e) => e.title === wanted);
812 if (!match.length) {
813 const closed = epicId !== null && findEpic(data, agent, epicId);
814 const why = closed
815 ? `epic ${wanted} is completed`
816 : `no open epic ${wanted}`;
817 throw new TaskError(
818 `${why}; ${describeOpen(data, agent)}. Open a new one with task_epic.`,
819 );
820 }
821 const epic = match[0]!;
822 data.active[agent] = epic.id;
823 epic.touched = ctx.now;
824 const [done, total] = progress(data, agent, epic.id);
825 return `Epic #${epic.id} ${epic.title} is now active (${done}/${total}): file its tasks with task_create(epic=${epic.id}).`;
826 },
827 };
828}
829
830// --- the user's tasks ----------------------------------------------------------------
831
832const isHuman = (t: Obj): boolean => get(t, "assignee", "") === HUMAN;
833
834/** The main agent's tasks assigned to the user and not yet answered. */
835export function humanOpen(data: Data): Obj[] {
836 return data.tasks.filter(
837 (t) =>
838 get(t, "agent", MAIN) === MAIN &&
839 isHuman(t) &&
840 t.status !== "completed",
841 );
842}
843
844function taskAssignHuman(agent: string, args: Obj): Step {
845 // The user talks to the main agent: a subagent's need reaches them
846 // through the main agent's answer, never as a task of its own list.
847 if (agent !== MAIN)
848 throw new TaskError(
849 "task_assign_human is the main agent's: say in your answer what you need from the user, and the main agent assigns it.",
850 );
851 const subject = checkSubject(args.subject);
852 const wanted = args.epic;
853 return {
854 mutates: true,
855 run(data, ctx) {
856 const epicId =
857 wanted === undefined || wanted === null ? null : parseEpicId(wanted);
858 if (!epicId || !findEpic(data, agent, epicId)) {
859 throw new TaskError(
860 `epic is required: a task for the user belongs to the epic it unblocks; ${describeOpen(data, agent)}. task_epic(title) opens one first.`,
861 );
862 }
863 const open = humanOpen(data);
864 if (open.length >= MAX_HUMAN_OPEN) {
865 throw new TaskError(
866 `the user already has ${open.length} tasks waiting (${open.map((t) => `#${t.id} ${t.subject}`).join(", ")}), the limit is ${MAX_HUMAN_OPEN}: ` +
867 "close the ones they answered with task_update(status=completed), or delete the ones that no longer apply, first.",
868 );
869 }
870 const task = {
871 id: String(data.next_id),
872 agent,
873 epic: epicId,
874 subject,
875 description: or(args.description, ""),
876 status: "waiting",
877 assignee: HUMAN,
878 created: ctx.now,
879 updated: ctx.now,
880 };
881 data.next_id += 1;
882 data.tasks.push(task);
883 touch(data, agent, epicId, ctx.now);
884 return (
885 `Task #${task.id} assigned to the user, waiting: ${subject} in epic #${epicId} ${findEpic(data, agent, epicId)!.title}. ` +
886 `Ask them for it in your reply; once they have answered, task_update(id="${task.id}", status="completed").`
887 );
888 },
889 };
890}
891
892/**
893 * What the model is told again after a compaction: the user's open tasks,
894 * so a "go" asked before the summary is still known to be awaited.
895 * Undefined when there is none.
896 */
897export function humanContext(data: Data): string | undefined {
898 const open = humanOpen(data);
899 if (!open.length) return undefined;
900 const line = (t: Obj): string => {
901 const epic = findEpic(data, MAIN, get(t, "epic", 0));
902 const what = strip(pyStr(or(t.description, "")));
903 const detail = what
904 ? `: ${codePoints(what) > 200 ? [...what].slice(0, 200).join("") + "…" : what}`
905 : "";
906 return ` - #${t.id} ${t.subject}${epic ? ` (epic #${epic.id} ${epic.title})` : ""}${detail}`;
907 };
908 return [
909 "Tasks assigned to the user (task_assign_human), still waiting on them:",
910 ...open.map(line),
911 "Once the user has answered one, close it with task_update(id, status=completed); do not redo what only they can do.",
912 ].join("\n");
913}
914
915export const TOOL_NAMES = [
916 "task_create",
917 "task_update",
918 "task_epic",
919 "task_focus",
920 "task_list",
921 "task_assign_human",
922] as const;
923export type ToolName = (typeof TOOL_NAMES)[number];
924
925/**
926 * Check a call's arguments and return the change it makes. Throws TaskError
927 * on the checks that need no file, in tasks.py's order; the rest throw from
928 * `run`, which sees the file as it is under the lock.
929 */
930export function plan(tool: ToolName, agent: string, args: Obj): Step {
931 switch (tool) {
932 case "task_create":
933 return taskCreate(agent, args);
934 case "task_update":
935 return taskUpdate(agent, args);
936 case "task_epic":
937 return taskEpic(agent, args);
938 case "task_focus":
939 return taskFocus(agent, args);
940 case "task_list":
941 return taskList(agent);
942 case "task_assign_human":
943 return taskAssignHuman(agent, args);
944 }
945}
946
947// --- who the workers are ------------------------------------------------------------
948
949const STALE_AGENT = 12 * 3600; // a subagent with no stop for this long was lost with its session
950const LIVE = new Set(["running", "waiting", "idle"]);
951
952/**
953 * The session's subagents. `listed` is `$.agent.list()` (undefined when it
954 * failed) and decides who runs; agents.json, written by on-agent.sh under
955 * the same ids, only adds when each started. Without the list, agents.json
956 * decides alone, as tasks.py did.
957 */
958export function resolveWorkers(
959 listed:
960 | readonly { id: string; type: string; name?: string; status: string }[]
961 | undefined,
962 agentsJson: string | undefined,
963 now: number,
964): Worker[] {
965 let recorded: Obj = {};
966 try {
967 const parsed = agentsJson === undefined ? {} : JSON.parse(agentsJson);
968 const agents = isObj(parsed) ? or(parsed.agents, {}) : {};
969 if (isObj(agents)) recorded = agents;
970 } catch {
971 recorded = {};
972 }
973 const startedOf = (id: string): number | undefined => {
974 const e = recorded[id];
975 return isObj(e) && typeof e.started === "number" ? e.started : undefined;
976 };
977 if (listed) {
978 return listed.map((a) => {
979 const w: Worker = { id: a.id, type: a.type, alive: LIVE.has(a.status) };
980 if (a.name) w.name = a.name;
981 const started = startedOf(a.id);
982 if (started !== undefined) w.started = started;
983 return w;
984 });
985 }
986 return Object.entries(recorded)
987 .filter(([, e]) => isObj(e))
988 .map(([id, e]) => {
989 const w: Worker = {
990 id,
991 type: pyStr(or(e.type, "")),
992 alive:
993 get(e, "stopped", null) === null &&
994 (or(e.started, 0) as number) >= now - STALE_AGENT,
995 };
996 if (typeof e.started === "number") w.started = e.started;
997 return w;
998 });
999}
1000
1001// --- what the model reads -------------------------------------------------------------
1002
1003const TREE =
1004 " Optional, for the user's task tree: parent=<id> files it as a subtask (three levels at most, in its " +
1005 "parent's epic), blocked_by=[<id>, ...] names the tasks it waits on (no cycle), ref names a short handle " +
1006 "of 5 characters at most (T0, C6). A pending task whose blockers are all completed shows as ready, one " +
1007 "with a blocker left as blocked; the longest blocked_by chain is the epic's critical path.";
1008
1009/** The tree's arguments, the same on task_create and task_update. */
1010const TREE_PROPS: Obj = {
1011 parent: {
1012 type: "string",
1013 description:
1014 'Id of the task this one is a subtask of, in the same epic; three levels at most. "" for none.',
1015 },
1016 blocked_by: {
1017 type: "array",
1018 items: { type: "string" },
1019 description:
1020 "Ids of the tasks this one waits on; [] for none. A cycle is refused.",
1021 },
1022 ref: {
1023 type: "string",
1024 maxLength: MAX_REF,
1025 description:
1026 'Short handle shown in the tree, 5 characters at most ("T0", "C6"). "" clears it.',
1027 },
1028};
1029
1030const WORKFLOW =
1031 " Work is grouped in epics, one per subject; several can be open at once, one is active (the one being " +
1032 "worked on) and every task names its epic explicitly. The user's status line shows every open epic live, so the list must say what is true at every " +
1033 "moment: in_progress while you work on a task, waiting while it is blocked on the user, completed as soon " +
1034 "as it is done and verified.";
1035
1036export const TOOLS: readonly {
1037 name: ToolName;
1038 description: string;
1039 inputSchema: Obj;
1040}[] = [
1041 {
1042 name: "task_create",
1043 description:
1044 "Add a task. Every message of the user gets one, a question included: file it before acting, and " +
1045 "set it completed as soon as it is answered or done. epic is REQUIRED, there is no default: the id " +
1046 "of the epic the task belongs to (task_list, or the Epics line given with each prompt, lists them), " +
1047 "or 0 for a task outside any epic; open a new subject with task_epic first. task_focus the epic when " +
1048 'you start its work. A change to an already completed task is a new task "Rework #N: ..." in that ' +
1049 "task's epic. subject: at most 18 characters, the title column of the task tree, " +
1050 'imperative, no final punctuation ("Add effort gauge"); detail goes in description, which the tree ' +
1051 "shows under the selected task. New tasks start pending." +
1052 TREE +
1053 WORKFLOW,
1054 inputSchema: {
1055 type: "object",
1056 properties: {
1057 subject: {
1058 type: "string",
1059 maxLength: MAX_SUBJECT,
1060 description:
1061 "Short imperative title, 18 characters at most; the detail goes in description.",
1062 },
1063 description: { type: "string", description: "What needs to be done." },
1064 epic: {
1065 type: "integer",
1066 minimum: 0,
1067 description:
1068 "Required. Id of one of your epics, or 0 for no epic. No default.",
1069 },
1070 ...TREE_PROPS,
1071 },
1072 required: ["subject", "epic"],
1073 },
1074 },
1075 {
1076 name: "task_update",
1077 description:
1078 "Update one of your tasks: set in_progress before starting it, completed as soon as its work is done " +
1079 "and verified, waiting when it is blocked on the user (a decision, an approval, an answer), deleted " +
1080 "when it no longer applies. Never end a turn with tasks left to do but none in_progress or waiting: " +
1081 "either one is under way, or they wait on the user and say so. One task in_progress per worker: you " +
1082 "may have one, plus one per running subagent (each subagent works on exactly one task); switching " +
1083 "to another task means setting the current one back to pending first, and a task handed to a " +
1084 "subagent goes in_progress once that subagent has started, with worker=<its type or id> so the task is " +
1085 "tied to it. With worker, the start is refused if that subagent is stopped or already holds another " +
1086 "task; without it, a start beyond the worker count is refused. The link is dropped when the task " +
1087 "leaves in_progress. A refusal lists the running subagents and who holds each task in progress. " +
1088 "subject: at most 18 characters, the detail goes in description. parent, blocked_by and ref " +
1089 'change the task\'s place in the tree (parent="" or blocked_by=[] or ref="" clears one); a deleted ' +
1090 "task's subtasks move up a level and the tasks waiting on it stop waiting.",
1091 inputSchema: {
1092 type: "object",
1093 properties: {
1094 id: {
1095 type: "string",
1096 description: "Task id, as returned by task_create.",
1097 },
1098 status: { type: "string", enum: [...STATUSES] },
1099 subject: {
1100 type: "string",
1101 maxLength: MAX_SUBJECT,
1102 description: "New title, 18 characters at most.",
1103 },
1104 description: { type: "string" },
1105 worker: {
1106 type: "string",
1107 description:
1108 "With status=in_progress: the running subagent that takes the task, by " +
1109 "type (its name) or agent id. Optional.",
1110 },
1111 ...TREE_PROPS,
1112 },
1113 required: ["id"],
1114 },
1115 },
1116 {
1117 name: "task_epic",
1118 description:
1119 "Open a new epic and make it active: a separate subject with its own task list and its own pill on " +
1120 "the user's status line. Use it when the user brings a new subject, rather than appending its tasks " +
1121 "to an unrelated epic; other open epics stay open. If an open epic already has this exact title it " +
1122 'is focused instead. title: at most 20 characters, a short noun phrase ("SDK status-line").' +
1123 WORKFLOW,
1124 inputSchema: {
1125 type: "object",
1126 properties: {
1127 title: {
1128 type: "string",
1129 maxLength: MAX_TITLE,
1130 description: "Subject of the epic, 20 characters at most.",
1131 },
1132 },
1133 required: ["title"],
1134 },
1135 },
1136 {
1137 name: "task_focus",
1138 description:
1139 "Make an open epic the active one, by id or exact title, when the work turns back to it: the status " +
1140 "line expands it. Completed epics cannot be focused; open a new one " +
1141 "with task_epic." +
1142 WORKFLOW,
1143 inputSchema: {
1144 type: "object",
1145 properties: {
1146 epic: {
1147 type: "string",
1148 description: 'Epic id ("3" or "#3") or its exact title.',
1149 },
1150 },
1151 required: ["epic"],
1152 },
1153 },
1154 {
1155 name: "task_list",
1156 description:
1157 "List the active epic's tasks with ids and statuses, one line per other open epic " +
1158 "(id, title, done/total), and tasks with no epic.",
1159 inputSchema: { type: "object", properties: {} },
1160 },
1161 {
1162 name: "task_assign_human",
1163 description:
1164 "Assign the user a task only they can do: a go or an approval, a Touch ID or a password typed on " +
1165 "their machine, a gesture on hardware you cannot reach. The task is filed waiting, marked as the " +
1166 "user's, in the epic it unblocks (epic is required), and shows on their status line. Say in your " +
1167 "reply what you need from them. Once they have answered, close it with task_update(status=completed); " +
1168 "it never goes in_progress. At most 5 open at once. Main agent only: a subagent says what it needs in " +
1169 "its answer.",
1170 inputSchema: {
1171 type: "object",
1172 properties: {
1173 subject: {
1174 type: "string",
1175 maxLength: MAX_SUBJECT,
1176 description:
1177 'What the user must do, imperative, 18 characters at most ("Go for NAS reboot").',
1178 },
1179 description: {
1180 type: "string",
1181 description: "Exactly what to do or decide, and where.",
1182 },
1183 epic: {
1184 type: "integer",
1185 minimum: 1,
1186 description: "Required. Id of the epic this unblocks.",
1187 },
1188 },
1189 required: ["subject", "epic"],
1190 },
1191 },
1192];
1193hooks/mod/store.ts 128 lines1// store.ts — reading and rewriting a session's tasks.json. No `$`: the host is
2// reached through a Port, which register.ts builds on `$` and the tests on
3// memory or on node's own file system.
4//
5// The file is read fresh on every call and replaced whole: written to a
6// temporary file in the same directory, then renamed over tasks.json by `mv`,
7// so a reader (status line, shell hooks) never sees half a list. `$.fs` has
8// no rename, hence `mv` through `$.process.run`, its exit code checked.
9//
10// Two writers are kept apart twice. Inside this process (the main loop and
11// every subagent share one copy of the mod) a promise queue per directory
12// runs one mutation at a time. Across processes (a second session on the
13// same id) a lock directory, created by `mkdir`, which is atomic: the same
14// protocol as the shell's lib/lock.sh, stale after KF_LOCK_STALE seconds and
15// broken by renaming it to a unique name. A lock not obtained in time is
16// written through, as lib/lock.sh's callers do: hooks fail open.
17import { parse, serialize, type Data } from "./tasks.ts";
18import { tasksFile } from "./paths.ts";
19
20export type RunResult = { exitCode: number; stdout: string; stderr: string };
21
22export type Port = {
23 /** The file's text, undefined when it is missing or unreadable. */
24 read(path: string): Promise<string | undefined>;
25 /** Write the whole text, creating directories as needed. */
26 write(path: string, text: string): Promise<void>;
27 /** Run a command by argv, no shell. */
28 run(argv: readonly string[]): Promise<RunResult>;
29 /** Modification time in milliseconds, undefined when missing. */
30 mtime(path: string): Promise<number | undefined>;
31 sleep(ms: number): Promise<void>;
32 /** Wall clock in milliseconds. */
33 now(): number;
34};
35
36export const LOCK_STALE_MS = 10_000;
37export const LOCK_WAIT_MS = 2_000;
38const LOCK_POLL_MS = 50;
39
40let counter = 0;
41export const unique = (): string =>
42 `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}.${++counter}`;
43
44const queues = new Map<string, Promise<unknown>>();
45
46/** Run `work` after every earlier mutation of the same directory settled. */
47function enqueue<T>(dir: string, work: () => Promise<T>): Promise<T> {
48 const prev = queues.get(dir) ?? Promise.resolve();
49 const next = prev.then(work, work);
50 const tail = next.catch(() => undefined);
51 queues.set(dir, tail);
52 void tail.then(() => {
53 if (queues.get(dir) === tail) queues.delete(dir);
54 });
55 return next;
56}
57
58/** Take the lock directory; true when held, false when written through. */
59export async function lock(port: Port, lockDir: string): Promise<boolean> {
60 const deadline = port.now() + LOCK_WAIT_MS;
61 for (;;) {
62 if ((await port.run(["mkdir", lockDir])).exitCode === 0) return true;
63 const m = await port.mtime(lockDir);
64 if (m !== undefined && port.now() - m >= LOCK_STALE_MS) {
65 // Of two waiters that both see it stale, one rename wins; the other
66 // finds nothing to move and goes round again.
67 const grave = `${lockDir}.stale.${unique()}`;
68 if ((await port.run(["mv", lockDir, grave])).exitCode === 0) {
69 await port.run(["rmdir", grave]);
70 continue;
71 }
72 }
73 if (port.now() >= deadline) return false;
74 await port.sleep(LOCK_POLL_MS);
75 }
76}
77
78/** Replace tasks.json through a temporary file and `mv`. */
79export async function save(port: Port, dir: string, data: Data): Promise<void> {
80 const tmp = `${dir}/.tasks.${unique()}.tmp`;
81 await port.write(tmp, serialize(data));
82 const moved = await port.run(["mv", "-f", tmp, tasksFile(dir)]);
83 if (moved.exitCode !== 0) {
84 await port.run(["rm", "-f", tmp]);
85 throw new Error(
86 `tasks.json not replaced: mv exited ${moved.exitCode}: ${moved.stderr.trim()}`,
87 );
88 }
89}
90
91/** The list as the file holds it now, for a call that writes nothing. */
92export async function load(
93 port: Port,
94 dir: string,
95 nowSec: number,
96): Promise<Data> {
97 return parse(await port.read(tasksFile(dir)), nowSec);
98}
99
100/**
101 * Read the list, apply `change`, write it back: one at a time per directory,
102 * under the lock. When `change` throws (a refusal), nothing is written.
103 */
104export function mutate<T>(
105 port: Port,
106 dir: string,
107 change: (data: Data) => T,
108): Promise<T> {
109 return enqueue(dir, async () => {
110 const made = await port.run(["mkdir", "-p", dir]);
111 if (made.exitCode !== 0)
112 throw new Error(`cannot create ${dir}: ${made.stderr.trim()}`);
113 const lockDir = `${dir}/.lock.d`;
114 const held = await lock(port, lockDir);
115 try {
116 const data = parse(
117 await port.read(tasksFile(dir)),
118 Math.floor(port.now() / 1000),
119 );
120 const result = change(data);
121 await save(port, dir, data);
122 return result;
123 } finally {
124 if (held) await port.run(["rmdir", lockDir]);
125 }
126 });
127}
128hooks/mod/lint.ts 340 lines1// lint.ts — ktn-linter, for Go projects only, and the /lint-setup command.
2// No `$`: register.ts hands the flow a LintPort built on `$`, the tests one
3// in memory.
4//
5// A Go project is the go.mod nearest a changed file (for /lint-setup, the
6// path it is given), never above the session's root: making.codes has none
7// at its root, only projects/making.codes/go.mod. The rule is written twice,
8// once per side, and both are held to the same fixtures
9// (tests/fixtures/gomod.json): here with `$.fs.exists`, in
10// hooks/scripts/lib/gomod.sh with `[ -f ]`.
11//
12// /lint-setup checks what ktn-linter needs in a session (the binary, its own
13// command hooks, the per-user daemon when KTN_USER_DAEMON=1, the module's
14// configuration) and proposes each correction through `$.ui.ask`. The hooks
15// and the daemon are ktn-linter's to manage — `hook install`, `hook doctor`,
16// `daemon ensure` — never this plugin's: nothing here speaks to a port
17// (kodflow/ktn-linter#771). Nothing is installed or started unasked; the
18// configuration belongs to the user and is never written.
19//
20// The binary is `ktn-linter`. `project-linter`, its old name, is only a
21// fallback when ktn-linter is not on PATH.
22
23export type RunResult = { exitCode: number; stdout: string; stderr: string };
24
25export type LintPort = {
26 exists(path: string): Promise<boolean>;
27 /** Whether the path is a directory; false when missing. */
28 isDir(path: string): Promise<boolean>;
29 /** The file's text, undefined when missing or unreadable. */
30 read(path: string): Promise<string | undefined>;
31 /** Run by argv; undefined when the command could not be started at all. */
32 run(
33 argv: readonly string[],
34 timeoutMs: number,
35 ): Promise<RunResult | undefined>;
36 /** The label chosen; undefined when nobody can answer (a -p run, a dismissal). */
37 ask(
38 question: string,
39 options: readonly string[],
40 ): Promise<string | undefined>;
41 /** The variables the check reads, as the session has them. */
42 vars: {
43 KTN_USER_DAEMON?: string;
44 };
45 sleep(ms: number): Promise<void>;
46 /** The session's root directory, absolute. */
47 root: string;
48};
49
50const parent = (p: string): string => {
51 const cut = p.lastIndexOf("/");
52 return cut <= 0 ? "/" : p.slice(0, cut);
53};
54
55const trimSlash = (p: string): string =>
56 p.length > 1 ? p.replace(/\/+$/, "") : p;
57
58/**
59 * The directory of the go.mod nearest `path` (a file, or a directory when
60 * `isDir`), at or below `root`; undefined when there is none or `path` is
61 * outside `root`. Both absolute.
62 */
63export async function goModuleOf(
64 path: string,
65 root: string,
66 exists: (p: string) => Promise<boolean>,
67 isDir = false,
68): Promise<string | undefined> {
69 const top = trimSlash(root);
70 const p = trimSlash(path);
71 if (!top.startsWith("/") || !p.startsWith("/")) return undefined;
72 if (p !== top && !p.startsWith(top === "/" ? "/" : `${top}/`))
73 return undefined;
74 let d = isDir ? p : parent(p);
75 for (;;) {
76 if (await exists(`${d === "/" ? "" : d}/go.mod`)) return d;
77 if (d === top || d === "/") return undefined;
78 d = parent(d);
79 }
80}
81
82// --- configuration ---------------------------------------------------------------
83
84/** What is wrong with a .ktn-linter.yaml, read line by line: empty when nothing. */
85export function yamlProblems(text: string): string[] {
86 const problems: string[] = [];
87 const lines = text.split("\n");
88 let inPhases = false;
89 let enabled = false;
90 let disabled = false;
91 for (const line of lines) {
92 if (/^\S/.test(line)) inPhases = /^phases:\s*(#.*)?$/.test(line);
93 if (/^max_phase:/.test(line))
94 problems.push("max_phase is no longer read: list phases.enabled instead");
95 const scope = /^review_scope:\s*([^\s#]*)/.exec(line);
96 if (scope && !["diff", "full"].includes(scope[1]!.replace(/["']/g, "")))
97 problems.push(`review_scope is "${scope[1]}": it must be diff or full`);
98 if (inPhases && /^\s+enabled:/.test(line)) enabled = true;
99 if (inPhases && /^\s+disabled:/.test(line)) disabled = true;
100 }
101 if (enabled && disabled)
102 problems.push("phases.enabled and phases.disabled are both set: keep one");
103 return problems;
104}
105
106// --- /lint-setup -----------------------------------------------------------------
107
108export const LINT_SETUP = {
109 name: "lint-setup",
110 description:
111 "Check ktn-linter for this Go project: binary, hooks, daemon, configuration. Proposes each fix.",
112 argumentHint: "[path in a Go module]",
113} as const;
114
115// The public mirror: kodflow/ktn-linter, the source, is private.
116const RELEASES = "https://github.com/kodflow/ktn/releases";
117
118/** The binary's name, then the old one, looked up only when the first is absent. */
119const BINARIES = ["ktn-linter", "project-linter"] as const;
120
121/** A subcommand the binary does not know: an older release than `hook install`. */
122const UNKNOWN_COMMAND = 40;
123
124// The latest release's archive for this machine, checked against the
125// release's checksums.txt, then ktn-linter and ktn-hook (the client its
126// command hooks run) into ~/.local/bin, each through a temporary file renamed
127// over the old one. Asked for before it runs.
128const INSTALL = [
129 "set -eu",
130 `tag=$(curl -fsSL --max-time 20 https://api.github.com/repos/kodflow/ktn/releases/latest | sed -n 's/.*"tag_name": *"\\([^"]*\\)".*/\\1/p' | head -n 1)`,
131 '[ -n "$tag" ]',
132 "os=$(uname -s | tr '[:upper:]' '[:lower:]')",
133 'case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch=$(uname -m) ;; esac',
134 'asset="ktn-linter_${os}_${arch}.tar.gz"',
135 'work=$(mktemp -d); trap \'rm -rf "$work"\' EXIT',
136 `curl -fsSL --max-time 120 "${RELEASES}/download/$tag/$asset" -o "$work/$asset"`,
137 `curl -fsSL --max-time 20 "${RELEASES}/download/$tag/checksums.txt" -o "$work/checksums.txt"`,
138 'want=$(awk -v a="$asset" \'$2 == a { print $1 }\' "$work/checksums.txt")',
139 'if command -v sha256sum >/dev/null 2>&1; then got=$(sha256sum "$work/$asset"); else got=$(shasum -a 256 "$work/$asset"); fi',
140 '[ -n "$want" ] && [ "${got%% *}" = "$want" ] || { echo "checksum mismatch for $asset" >&2; exit 1; }',
141 'tar -xzf "$work/$asset" -C "$work" ktn-linter ktn-hook',
142 'dir=$HOME/.local/bin; mkdir -p "$dir"',
143 'for b in ktn-linter ktn-hook; do tmp=$(mktemp "$dir/.$b.XXXXXX"); cp "$work/$b" "$tmp"; chmod 755 "$tmp"; mv -f "$tmp" "$dir/$b"; done',
144 'printf "%s %s\\n" "$tag" "$dir/ktn-linter"',
145].join("\n");
146
147const YES_INSTALL = "Install";
148const YES_HOOKS = "Install the hooks";
149const YES_DAEMON = "Start it";
150const NOT_NOW = "Not now";
151
152async function binary(io: LintPort, bin: string): Promise<string | undefined> {
153 const r = await io.run([bin, "version"], 5000);
154 if (!r || r.exitCode !== 0) return undefined;
155 return r.stdout.trim().split("\n")[0] || "unknown version";
156}
157
158type Doctor =
159 | { kind: "ok" }
160 | { kind: "defects"; defects: string[] }
161 | { kind: "unsupported" }
162 | { kind: "failed"; detail: string };
163
164/** `hook doctor --json`, read: the installation of ktn-linter's own hooks. */
165async function doctor(io: LintPort, bin: string): Promise<Doctor> {
166 const r = await io.run([bin, "hook", "doctor", "--json"], 30_000);
167 if (!r) return { kind: "failed", detail: `${bin} hook doctor could not run` };
168 if (r.exitCode === UNKNOWN_COMMAND) return { kind: "unsupported" };
169 let report: { ok?: unknown; defects?: unknown } | undefined;
170 try {
171 report = JSON.parse(r.stdout);
172 } catch {
173 report = undefined;
174 }
175 if (!report || typeof report !== "object")
176 return {
177 kind: "failed",
178 detail: (r.stderr.trim() || r.stdout.trim() || `exit ${r.exitCode}`).slice(0, 300),
179 };
180 if (report.ok === true && r.exitCode === 0) return { kind: "ok" };
181 const defects = Array.isArray(report.defects)
182 ? report.defects.map((d: any) =>
183 `${d?.code ?? "defect"}${d?.host ? ` (${d.host})` : ""}: ${d?.message ?? ""}`.trim(),
184 )
185 : [];
186 return { kind: "defects", defects: defects.length ? defects : [`exit ${r.exitCode}`] };
187}
188
189/** The whole check, as the text the command prints. */
190export async function lintSetup(io: LintPort, args: string): Promise<string> {
191 const root = trimSlash(io.root);
192 const wanted = args.trim();
193 const target = !wanted
194 ? root
195 : wanted.startsWith("/")
196 ? trimSlash(wanted)
197 : `${root === "/" ? "" : root}/${wanted.replace(/^\.\/+/, "")}`;
198 const dir = await io.isDir(target);
199 const module = await goModuleOf(target, root, (p) => io.exists(p), dir);
200 if (!module) {
201 return [
202 `No go.mod at or above ${target} inside ${root}: not a Go project, so the hooks do not run`,
203 "ktn-linter here, and there is nothing to set up.",
204 "Name a file or a directory of a Go module to check it: /lint-setup <path>.",
205 ].join("\n");
206 }
207 const lines = [`Go module: ${module}`];
208
209 // --- binary
210 let bin: string = BINARIES[0];
211 let version: string | undefined;
212 for (const name of BINARIES) {
213 version = await binary(io, name);
214 if (version) {
215 bin = name;
216 break;
217 }
218 }
219 lines.push(
220 !version
221 ? "binary missing: ktn-linter is not on PATH"
222 : bin === BINARIES[0]
223 ? `binary ok, ${version}`
224 : `binary ok, ${version} (found as ${bin}, the old name: ktn-linter is the one to install)`,
225 );
226 if (!version) {
227 const yes = await io.ask(
228 `ktn-linter is not installed. Install its latest release from ${RELEASES} (ktn-linter and ktn-hook, checksum verified) into ~/.local/bin?`,
229 [YES_INSTALL, NOT_NOW],
230 );
231 if (yes === YES_INSTALL) {
232 const r = await io.run(["bash", "-c", INSTALL], 180_000);
233 const [tag, path] = (r?.stdout.trim() ?? "").split(" ");
234 if (r && r.exitCode === 0 && path) {
235 bin = path;
236 version = (await binary(io, bin)) ?? tag;
237 lines.push(
238 ` installed ${tag} at ${path} (add ~/.local/bin to PATH for the hooks)`,
239 );
240 } else {
241 lines.push(
242 ` install failed: ${(r?.stderr.trim() || "could not run bash").slice(0, 300)}`,
243 );
244 }
245 } else {
246 lines.push(` not installed: download it from ${RELEASES}`);
247 }
248 }
249
250 // --- hooks: ktn-linter's own command hooks, installed and judged by it
251 if (!version) {
252 lines.push("hooks needs the binary first");
253 } else {
254 let d = await doctor(io, bin);
255 if (d.kind === "ok") {
256 lines.push("hooks ok, ktn-linter hook doctor reports a sound installation");
257 } else if (d.kind === "unsupported") {
258 lines.push(
259 `hooks unavailable: ${bin} predates \`hook install\` (ktn-linter v1.46.0); install the latest release from ${RELEASES}`,
260 );
261 } else if (d.kind === "failed") {
262 lines.push(`hooks unknown: ${d.detail}`);
263 } else {
264 lines.push(`hooks ${d.defects.length} defect(s) reported by hook doctor:`);
265 for (const x of d.defects) lines.push(` - ${x}`);
266 const yes = await io.ask(
267 "ktn-linter's command hooks are not sound. Run `ktn-linter hook install`? It rewrites its own entries in your user settings, and nothing else.",
268 [YES_HOOKS, NOT_NOW],
269 );
270 if (yes === YES_HOOKS) {
271 const r = await io.run([bin, "hook", "install"], 120_000);
272 d = await doctor(io, bin);
273 lines.push(
274 d.kind === "ok"
275 ? " installed; hook doctor now reports ok (restart the session to load them)"
276 : ` hook install did not leave a sound installation: ${(r?.stderr.trim() || (d.kind === "defects" ? d.defects.join("; ") : "see ktn-linter hook doctor")).slice(0, 300)}`,
277 );
278 } else {
279 lines.push(
280 " not installed: ktn-linter hook install, then ktn-linter hook doctor",
281 );
282 }
283 }
284 }
285
286 // --- daemon: the per-user one when opted in, else the MCP server's own
287 if (io.vars.KTN_USER_DAEMON !== "1") {
288 lines.push(
289 "daemon per project, started by the MCP server (ktn-linter mcp install; make hooks-install in ktn-linter's own repository)",
290 );
291 } else if (!version) {
292 lines.push("daemon needs the binary first");
293 } else {
294 const ok = async () => (await io.run([bin, "daemon", "status"], 10_000))?.exitCode === 0;
295 let alive = await ok();
296 lines.push(
297 alive
298 ? "daemon ok, ktn-linter daemon status reports the per-user daemon healthy"
299 : "daemon down: ktn-linter daemon status does not report a healthy per-user daemon",
300 );
301 if (!alive) {
302 const yes = await io.ask(
303 "The per-user ktn-linter daemon is not running. Start it with `ktn-linter daemon ensure`?",
304 [YES_DAEMON, NOT_NOW],
305 );
306 if (yes === YES_DAEMON) {
307 await io.run([bin, "daemon", "ensure"], 30_000);
308 alive = await ok();
309 lines.push(
310 alive
311 ? " started (ktn-linter daemon ensure)"
312 : " did not come up: see ktn-linter daemon status",
313 );
314 } else {
315 lines.push(" not started: ktn-linter daemon ensure");
316 }
317 }
318 }
319
320 // --- configuration: .ktn-linter.yaml, .project-linter.yaml as a fallback
321 let name = ".ktn-linter.yaml";
322 let yaml = await io.read(`${module}/${name}`);
323 if (yaml === undefined) {
324 const old = await io.read(`${module}/.project-linter.yaml`);
325 if (old !== undefined) {
326 name = ".project-linter.yaml";
327 yaml = old;
328 }
329 }
330 const problems =
331 yaml === undefined ? [] : yamlProblems(yaml).map((p) => `${name}: ${p}`);
332 lines.push(
333 problems.length
334 ? `config ${problems.length} problem(s), to fix by hand (this command never writes them):`
335 : `config ok (${yaml === undefined ? "ktn-linter's defaults" : name})`,
336 );
337 for (const p of problems) lines.push(` - ${p}`);
338 return lines.join("\n");
339}
340hooks/mod/calls.ts 135 lines1// calls.ts — the small decisions register.ts makes on an event. Pure.
2import { TOOL_NAMES, type ToolName } from "./tasks.ts";
3
4export const PLUGIN = "kodflow-hooks";
5const PREFIX = `mcp__${PLUGIN}__`;
6
7/** The task tool a `tool.call` names, undefined for any other tool. */
8export function taskTool(tool: unknown): ToolName | undefined {
9 if (typeof tool !== "string" || !tool.startsWith(PREFIX)) return undefined;
10 const name = tool.slice(PREFIX.length);
11 return (TOOL_NAMES as readonly string[]).includes(name)
12 ? (name as ToolName)
13 : undefined;
14}
15
16// What `tool.call` carries beside the tool's own arguments
17const RESERVED = new Set([
18 "tool",
19 "tool_use_id",
20 "agentId",
21 "requestMeta",
22 "consent",
23]);
24
25/** The arguments the model gave, without the envelope. */
26export function argsOf(e: Record<string, unknown>): Record<string, unknown> {
27 const args: Record<string, unknown> = {};
28 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) args[k] = v;
29 return args;
30}
31
32/**
33 * The text the engine asks the model to put in a commit or a pull request:
34 * none. The other kinds (the exemption, the remedy) are the engine's own
35 * sentences about its commit gate and are left as they are.
36 */
37export function attributionText(kind: unknown): string | undefined {
38 return kind === "commit" || kind === "pr" ? "" : undefined;
39}
40
41/** KODFLOW_MOD=off: the mod hooks nothing and leaves no marker. */
42export const isOff = (value: string | undefined): boolean => value === "off";
43
44/** The main loop's call: no agent id. A subagent's carries its own. */
45export const mainLoop = (agentId: unknown): boolean =>
46 agentId === undefined || agentId === null || agentId === "";
47
48/**
49 * The causes a triage-pending file holds, one per line, in order: `prompt
50 * <id>` for each user message not filed yet (on-user.sh, one line per
51 * message) and `resume` (a session that came back with open tasks,
52 * on-session.sh). An empty file is the format before causes: one prompt.
53 */
54export function triageCauses(text: string): string[] {
55 const causes = text
56 .split("\n")
57 .map((line) => line.trim())
58 .filter(Boolean);
59 return causes.length ? causes : ["prompt"];
60}
61
62// A line's kind: its first word. Anything but resume is a message to file.
63const kindOf = (cause: string): "prompt" | "resume" =>
64 cause.split(/\s+/)[0] === "resume" ? "resume" : "prompt";
65
66const given = (v: unknown): boolean =>
67 typeof v === "string" ? v.trim() !== "" : v !== undefined && v !== null;
68
69/**
70 * Whether a task call that succeeded settles one cause of the triage gate.
71 * A user message is settled by a task of its own (task_create,
72 * task_assign_human) or by context added to the task in progress
73 * (task_update with a description); a resumed session by a status set
74 * (task_update with a status). Reading or steering the list (task_list,
75 * task_epic, task_focus) settles nothing. Only the main loop: a subagent
76 * works for a task the main agent already filed. A cause this version does
77 * not know is read as a prompt.
78 */
79export function liftsTriage(
80 tool: ToolName,
81 args: Record<string, unknown>,
82 agentId: unknown,
83 cause: string,
84): boolean {
85 if (!mainLoop(agentId)) return false;
86 if (kindOf(cause) === "resume")
87 return tool === "task_update" && given(args.status);
88 return (
89 tool === "task_create" ||
90 tool === "task_assign_human" ||
91 (tool === "task_update" && given(args.description))
92 );
93}
94
95/**
96 * The causes left once a call that succeeded is counted: one gesture files
97 * one message, so it drops the oldest line of each kind it answers, never
98 * two of the same kind. The other messages wait for their own gesture.
99 */
100export function settleCauses(
101 causes: readonly string[],
102 tool: ToolName,
103 args: Record<string, unknown>,
104 agentId: unknown,
105): string[] {
106 const left = [...causes];
107 for (const kind of ["prompt", "resume"] as const) {
108 if (!liftsTriage(tool, args, agentId, kind)) continue;
109 const at = left.findIndex((c) => kindOf(c) === kind);
110 if (at >= 0) left.splice(at, 1);
111 }
112 return left;
113}
114
115/** The payload on-mod.sh logs for a task call, shaped like a PostToolUse. */
116export function logPayload(
117 session: string,
118 cwd: string,
119 e: Record<string, unknown>,
120 answer: { result: string } | { deny: string },
121): Record<string, unknown> {
122 const base: Record<string, unknown> = {
123 hook_event_name: "deny" in answer ? "PostToolUseFailure" : "PostToolUse",
124 session_id: session,
125 cwd,
126 tool_use_id: e.tool_use_id,
127 tool_name: e.tool,
128 tool_input: argsOf(e),
129 };
130 if (typeof e.agentId === "string" && e.agentId) base.agent_id = e.agentId;
131 if ("deny" in answer) base.error = answer.deny;
132 else base.tool_response = { ok: true };
133 return base;
134}
135hooks/mod/paths.ts 51 lines1// paths.ts — where the mod keeps a session's state. Pure.
2//
3// <config>/kodflow/sessions/<session>/ is shared with the shell hooks and the
4// status line: tasks.json (the task list), agents.json (on-agent.sh),
5// triage-pending (the triage gate) and mod-beat (the proof the mod is loaded).
6
7/**
8 * The Claude configuration directory: CLAUDE_CONFIG_DIR, else ~/.claude.
9 * Always absolute: `$.fs` resolves a relative path against the session's
10 * working directory and never expands `~`, so anything else is refused.
11 */
12export function configDir(
13 claudeConfigDir: string | undefined,
14 home: string | undefined,
15): string | undefined {
16 const pick =
17 claudeConfigDir || (home ? `${home.replace(/\/+$/, "")}/.claude` : "");
18 if (!pick.startsWith("/")) return undefined;
19 return pick.length > 1 ? pick.replace(/\/+$/, "") : pick;
20}
21
22/** Keep an identifier usable as a single path component (tasks.py's safe). */
23export function safe(value: unknown): string {
24 const chars = [...String(value)].map((c) =>
25 /^[\p{L}\p{N}_-]$/u.test(c) ? c : "-",
26 );
27 return chars.slice(0, 128).join("") || "default";
28}
29
30export const sessionDir = (config: string, session: string): string =>
31 `${config}/kodflow/sessions/${safe(session)}`;
32
33export const tasksFile = (dir: string): string => `${dir}/tasks.json`;
34export const agentsFile = (dir: string): string => `${dir}/agents.json`;
35export const triageFile = (dir: string): string => `${dir}/triage-pending`;
36export const beatFile = (dir: string): string => `${dir}/mod-beat`;
37
38/** The beat's content: the time in whole seconds, as the shell compares it. */
39export const beatText = (nowMs: number): string =>
40 String(Math.floor(nowMs / 1000));
41
42/**
43 * When the user last typed at the prompt, in whole seconds: written on every
44 * composer prompt, read by on-stop.sh before it proposes agent feedback.
45 */
46export const composerFile = (dir: string): string => `${dir}/composer-at`;
47
48/** The merge gate's token for one call (L5): the head SHA it was agreed on. */
49export const mergeTokenFile = (dir: string, toolUseId: unknown): string =>
50 `${dir}/merge-ok/${safe(toolUseId)}`;
51hooks/mod/machine-guards.ts 249 lines1// machine-guards.ts — modwall, collision-guard and zsh-safe, over a port.
2//
3// The engine follows `$` and `on` only inside register.ts, so register.ts
4// builds a GuardPort from `$` and hooks the events; what each hook does is
5// here, and the decisions themselves in modwall.ts, collision.ts and zsh.ts.
6//
7// Every guard fails open, as every kodflow hook does: a judge that throws
8// lets the module load, a collision check that throws lets the edit through,
9// a zsh setup that throws leaves the shell as it was.
10import { safe, sessionDir, beatFile } from "./paths.ts";
11import { decision, remember, report, type Decision } from "./modwall.ts";
12import {
13 CANCEL,
14 CONTINUE,
15 WORKTREE,
16 answer,
17 editsFile,
18 excluded,
19 isLive,
20 newest,
21 present,
22 question,
23 record,
24 targetOf,
25} from "./collision.ts";
26import { zdotdirOf, zdotdirPlan } from "./zsh.ts";
27
28const STORE_KEY = "modwall";
29
30export type GuardPort = {
31 /** KODFLOW_MOD=off */
32 off: () => Promise<boolean>;
33 /** The absolute Claude configuration directory, when there is one. */
34 config: () => Promise<string | undefined>;
35 read: (path: string) => Promise<string | undefined>;
36 write: (path: string, text: string) => Promise<void>;
37 list: (path: string) => Promise<{ name: string; kind: string }[]>;
38 session: () => Promise<string>;
39 cwd: () => Promise<string>;
40 ask: (question: string, options: string[]) => Promise<string>;
41 storeGet: (key: string) => Promise<unknown>;
42 storeSet: (key: string, value: unknown) => Promise<void>;
43 log: (text: string) => void;
44 toast: (text: string) => void;
45 /** Declares /kodflow-mods. */
46 registerMods: (description: string) => Promise<unknown>;
47 zdotdir: () => Promise<string | undefined>;
48 userZdotdir: () => Promise<string | undefined>;
49 setZdotdir: (value: string) => Promise<void>;
50 setUserZdotdir: (value: string) => Promise<void>;
51 pluginRoot: string;
52 now: () => number;
53};
54
55const sec = (p: GuardPort) => Math.floor(p.now() / 1000);
56
57// The modules judged since this one loaded, for /kodflow-mods
58const judged: Decision[] = [];
59
60// Refusals made before the session bound (a toast then is dropped), shown
61// at session.start
62const unshown: string[] = [];
63
64// When the user last typed a prompt at the terminal (origin `composer`)
65let lastComposerMs: number | undefined;
66
67// What that prompt said, until a merge took it as its written consent
68let lastComposerText: string | undefined;
69
70/**
71 * prompt.submit: remember a prompt the user typed, for the presence rule
72 * and the merge gate's written consent. Any other origin (a notification, a
73 * subagent's message) changes nothing.
74 */
75export function notePrompt(
76 origin: unknown,
77 nowMs: number,
78 text?: unknown,
79): void {
80 if ((origin as { kind?: unknown } | undefined)?.kind !== "composer") return;
81 lastComposerMs = nowMs;
82 lastComposerText = typeof text === "string" ? text : undefined;
83}
84
85/** When the user last typed a prompt at the terminal, for any presence rule. */
86export const lastComposerAt = (): number | undefined => lastComposerMs;
87
88/** The last prompt typed at the terminal, unless a merge already used it. */
89export const lastComposerPrompt = (): string | undefined => lastComposerText;
90
91/** A merge used the last prompt as its consent: it serves no other. */
92export const consumeComposerPrompt = (): void => {
93 lastComposerText = undefined;
94};
95
96// A collision the user chose to go past: path -> the foreign edit's time.
97// A newer edit by the other session asks again.
98const acknowledged = new Map<string, number>();
99
100// One write of edits.tsv at a time: the main loop and its subagents share
101// this process and this session's file.
102let writing: Promise<unknown> = Promise.resolve();
103
104/**
105 * modwall, on plugin.register: every module that joins after this one
106 * passes here; only the user tier is judged, and only *@kodflow admitted.
107 */
108export async function admit(
109 p: GuardPort,
110 e: { name: string; tier: string; provenance: string },
111): Promise<{ refuse: string } | undefined> {
112 if (await p.off()) return undefined;
113 const d = decision(e, sec(p));
114 judged.push(d);
115 try {
116 if (d.reason) {
117 p.log(d.reason);
118 unshown.push(`modwall refused ${d.provenance}`);
119 }
120 await p.storeSet(STORE_KEY, remember(await p.storeGet(STORE_KEY), d));
121 } catch {
122 // the journal is best effort; the verdict stands
123 }
124 return d.reason ? { refuse: d.reason } : undefined;
125}
126
127/** What /kodflow-mods prints. */
128export async function modsReport(p: GuardPort): Promise<string> {
129 const history = await p.storeGet(STORE_KEY).catch(() => undefined);
130 return report(judged, Array.isArray(history) ? history : []);
131}
132
133/** session.start's share: /kodflow-mods, and zsh-safe's ZDOTDIR. */
134export async function startGuards(p: GuardPort): Promise<void> {
135 if (await p.off()) return;
136 for (const text of unshown.splice(0)) p.toast(text);
137 await p
138 .registerMods(
139 "kodflow: the mods judged at load and the ones modwall refused",
140 )
141 .catch(() => undefined);
142 try {
143 const plan = zdotdirPlan(
144 await p.zdotdir(),
145 await p.userZdotdir(),
146 zdotdirOf(p.pluginRoot),
147 );
148 if (plan) {
149 await p.setUserZdotdir(plan.user);
150 await p.setZdotdir(plan.zdotdir);
151 }
152 } catch {
153 // the shell stays as the user has it
154 }
155}
156
157async function others(
158 p: GuardPort,
159 cfg: string,
160 self: string,
161): Promise<{ session: string; text: string | undefined }[]> {
162 const root = `${cfg}/kodflow/sessions`;
163 const entries = await p.list(root).catch(() => []);
164 const now = sec(p);
165 const found = await Promise.all(
166 entries
167 .filter((d) => d.kind === "dir" && d.name !== self)
168 .map(async (d) => {
169 const dir = `${root}/${d.name}`;
170 const text = await p.read(editsFile(dir));
171 if (!text) return undefined;
172 return isLive(await p.read(beatFile(dir)), now)
173 ? { session: d.name, text }
174 : undefined;
175 }),
176 );
177 return found.filter((x) => x !== undefined);
178}
179
180async function noteEdit(p: GuardPort, dir: string, path: string) {
181 const step = writing.then(async () => {
182 const file = editsFile(dir);
183 await p.write(file, record(await p.read(file), path, sec(p)));
184 });
185 writing = step.catch(() => undefined);
186 await step;
187}
188
189/**
190 * collision-guard, on tool.call of a writing tool: has another live session
191 * of this machine written this file in the last 30 minutes? Ask; then, once
192 * the write went through, note it in this session's edits.tsv.
193 */
194export async function guardWrite<R>(
195 p: GuardPort,
196 e: Record<string, unknown>,
197 next: () => Promise<R>,
198): Promise<R | { deny: string }> {
199 if (await p.off()) return next();
200 const path = targetOf(e.tool, e, await p.cwd());
201 const cfg = await p.config();
202 if (path === undefined || !cfg || excluded(path, cfg)) return next();
203 const self = safe(await p.session());
204 const now = sec(p);
205 const hit = await others(p, cfg, self).then(
206 (o) => newest(path, o, now),
207 () => undefined,
208 );
209 if (
210 hit &&
211 hit.at > (acknowledged.get(path) ?? 0) &&
212 !present(lastComposerMs, p.now())
213 ) {
214 // Nobody typed a prompt lately: asking would hold the edit until someone
215 // comes back. Journaled, and the edit goes on.
216 p.log(
217 `collision-guard: ${path} edited by session ${hit.session} at ${hit.at}; no prompt typed in 10 min, not asked, the edit goes on`,
218 );
219 } else if (hit && hit.at > (acknowledged.get(path) ?? 0)) {
220 // The dialog dismissed, or no one to ask after all: the edit goes on
221 const chosen = await p
222 .ask(question(path, hit, now), [CONTINUE, WORKTREE, CANCEL])
223 .catch(() => undefined);
224 p.log(
225 `collision-guard: ${path} edited by session ${hit.session} at ${hit.at}; answer: ${chosen ?? "none, the edit goes on"}`,
226 );
227 if (chosen !== undefined) {
228 const verdict = answer(chosen, path);
229 if ("deny" in verdict) return verdict;
230 acknowledged.set(path, hit.at);
231 }
232 }
233 const r: any = await next();
234 if (!r?.deny && !r?.isError)
235 await noteEdit(p, sessionDir(cfg, self), path).catch(() => undefined);
236 return r;
237}
238
239/**
240 * session.end: this session's edits stop counting at once, rather than when
241 * its beat ages out of the window.
242 */
243export async function endGuards(p: GuardPort): Promise<void> {
244 const cfg = await p.config();
245 if (!cfg) return;
246 const file = editsFile(sessionDir(cfg, safe(await p.session())));
247 if ((await p.read(file)) !== undefined) await p.write(file, "");
248}
249hooks/mod/shell.ts 620 lines1// shell.ts — what a Bash line will actually run. Pure.
2//
3// A port of the shell reader of hooks/scripts/on-tool.sh (_p_scan, _p_paren,
4// _p_argv, _p_expand, _p_parse, _p_file), step for step, so the mod and the
5// shell hook read a line the same way: the bash-guard corpus
6// (tests/fixtures/bash-guard.jsonl) is replayed against both.
7//
8// It does the shell's own first pass: cuts the line into simple commands,
9// removes quotes and backslashes, joins `\`+newline, sets heredoc bodies
10// aside as data, and queues for another pass every text the shell would
11// itself run ($( ), backticks, <( ), bash -c, sh -c, eval, su/sg -c, and a
12// body or a pipe fed to a shell). It never expands a variable or an alias.
13
14/** One simple command: its words, its stdin, its redirection targets. */
15export type Command = {
16 words: string[];
17 /** What it reads on stdin: heredoc bodies and here-strings. */
18 stdin: string;
19 /** Redirection targets (`> f`, `< f`, `&> f`). */
20 redirs: string[];
21 /** True when a pipe feeds it. */
22 pipe: boolean;
23 /** Nesting depth of the text it was read from (0: the line itself). */
24 depth: number;
25};
26
27export type Parsed = { commands: Command[]; nested: boolean };
28
29const QUEUE_MAX = 64;
30const DEPTH_MAX = 6;
31const WORD = /[A-Za-z0-9_./:@%+,=-]/;
32
33/** The content of a parenthesis opened just before `start`, and the index past `)`. */
34function paren(t: string, start: number): { cap: string; end: number } {
35 let k = start;
36 let dep = 1;
37 const m = t.length;
38 while (k < m) {
39 const ch = t[k]!;
40 if (ch === "\\") {
41 k += 2;
42 continue;
43 }
44 if (ch === "'") {
45 const close = t.indexOf("'", k + 1);
46 const r = close < 0 ? t.slice(k + 1) : t.slice(k + 1, close);
47 k += r.length + 2;
48 continue;
49 }
50 if (ch === '"') {
51 k++;
52 while (k < m) {
53 const c = t[k]!;
54 if (c === "\\") {
55 k += 2;
56 continue;
57 }
58 if (c === '"') break;
59 k++;
60 }
61 } else if (ch === "(") dep++;
62 else if (ch === ")") {
63 dep--;
64 if (dep === 0) break;
65 }
66 k++;
67 }
68 return { cap: t.slice(start, k), end: k + 1 };
69}
70
71/** bash's printf %b on the body of a $'…' string. */
72export function ansiC(raw: string): string {
73 const simple: Record<string, string> = {
74 a: "\x07",
75 b: "\b",
76 e: "\x1b",
77 E: "\x1b",
78 f: "\f",
79 n: "\n",
80 r: "\r",
81 t: "\t",
82 v: "\v",
83 "\\": "\\",
84 "'": "'",
85 '"': '"',
86 };
87 let out = "";
88 for (let i = 0; i < raw.length; i++) {
89 const c = raw[i]!;
90 if (c !== "\\" || i + 1 >= raw.length) {
91 out += c;
92 continue;
93 }
94 const n = raw[i + 1]!;
95 if (n in simple) {
96 out += simple[n];
97 i++;
98 } else if (n === "x") {
99 const hex = /^[0-9A-Fa-f]{1,2}/.exec(raw.slice(i + 2));
100 if (hex) {
101 out += String.fromCharCode(parseInt(hex[0], 16));
102 i += 1 + hex[0].length;
103 } else out += c;
104 } else if (n === "u" || n === "U") {
105 const hex = (n === "u" ? /^[0-9A-Fa-f]{1,4}/ : /^[0-9A-Fa-f]{1,8}/).exec(
106 raw.slice(i + 2),
107 );
108 if (hex) {
109 out += String.fromCodePoint(parseInt(hex[0], 16));
110 i += 1 + hex[0].length;
111 } else out += c;
112 } else if (n === "0") {
113 const oct = /^[0-7]{0,3}/.exec(raw.slice(i + 2))![0];
114 out += String.fromCharCode(parseInt(oct || "0", 8) & 0xff);
115 i += 1 + oct.length;
116 } else if (/[1-7]/.test(n)) {
117 const oct = /^[0-7]{1,3}/.exec(raw.slice(i + 1))![0];
118 out += String.fromCharCode(parseInt(oct, 8) & 0xff);
119 i += oct.length;
120 } else if (n === "c") break;
121 else out += c;
122 }
123 return out;
124}
125
126class Reader {
127 readonly cmds: Command[] = [];
128 /** stdin gathered for an index before (or after) its command is closed. */
129 private readonly pendingIn = new Map<number, string>();
130 readonly queue: { text: string; depth: number }[] = [];
131
132 push(text: string, depth: number): void {
133 if (this.queue.length < QUEUE_MAX && depth <= DEPTH_MAX)
134 this.queue.push({ text, depth });
135 }
136
137 private addIn(index: number, text: string): void {
138 const cmd = this.cmds[index];
139 if (cmd) cmd.stdin += text;
140 else this.pendingIn.set(index, (this.pendingIn.get(index) ?? "") + text);
141 }
142
143 scan(s: string, d: number): void {
144 const n = s.length;
145 let i = 0;
146 let w = "";
147 let wa = false;
148 let rn = 0;
149 let cw: string[] = [];
150 let cr: string[] = [];
151 let pipe = false;
152 let hq: { idx: number; strip: boolean; dl: string }[] = [];
153
154 const pw = () => {
155 if (wa) {
156 if (rn === 1) cr.push(w);
157 else if (rn === 2) this.addIn(this.cmds.length, `${w}\n`);
158 else cw.push(w);
159 rn = 0;
160 }
161 w = "";
162 wa = false;
163 };
164 const pc = (nextPipe = false) => {
165 pw();
166 const idx = this.cmds.length;
167 const pending = this.pendingIn.get(idx) ?? "";
168 if (cw.length || cr.length || pending) {
169 this.pendingIn.delete(idx);
170 this.cmds.push({
171 words: cw,
172 stdin: pending,
173 redirs: cr,
174 pipe,
175 depth: d,
176 });
177 }
178 cw = [];
179 cr = [];
180 pipe = nextPipe;
181 };
182 const dollar = () => {
183 const p = paren(s, i + 2);
184 if (s[i + 2] !== "(") this.push(p.cap, d + 1);
185 w += `$(${p.cap})`;
186 wa = true;
187 i = p.end;
188 };
189 const tick = () => {
190 let k = i + 1;
191 while (k < n) {
192 const ch = s[k]!;
193 if (ch === "\\") {
194 k += 2;
195 continue;
196 }
197 if (ch === "`") break;
198 k++;
199 }
200 this.push(s.slice(i + 1, k), d + 1);
201 w += s.slice(i, k + 1);
202 wa = true;
203 i = k + 1;
204 };
205
206 while (i < n) {
207 const c = s[i]!;
208 if (WORD.test(c)) {
209 w += c;
210 wa = true;
211 i++;
212 continue;
213 }
214 switch (c) {
215 case " ":
216 case "\t":
217 pw();
218 break;
219 case "\n":
220 pc();
221 if (hq.length) {
222 i++;
223 for (const q of hq) {
224 let body = "";
225 while (i < n) {
226 const nl = s.indexOf("\n", i);
227 let line = nl < 0 ? s.slice(i) : s.slice(i, nl);
228 i += line.length + 1;
229 if (q.strip) line = line.replace(/^\t+/, "");
230 if (line === q.dl) break;
231 body += `${line}\n`;
232 }
233 this.addIn(q.idx, body);
234 }
235 hq = [];
236 continue;
237 }
238 break;
239 case ";":
240 case "(":
241 case ")":
242 pc();
243 break;
244 case "&":
245 if (s[i + 1] === ">") {
246 pw();
247 i += 2;
248 if (s[i] === ">") i++;
249 rn = 1;
250 continue;
251 }
252 if (s[i + 1] === "&") i++;
253 pc();
254 break;
255 case "|":
256 if (s[i + 1] === "|") {
257 i++;
258 pc();
259 } else if (s[i + 1] === "&") {
260 i++;
261 pc(true);
262 } else pc(true);
263 break;
264 case "<":
265 case ">": {
266 if (wa) {
267 if (/[^0-9]/.test(w)) pw();
268 else {
269 w = "";
270 wa = false;
271 }
272 }
273 if (c === "<" && s.slice(i + 1, i + 3) === "<<") {
274 i += 3;
275 rn = 2;
276 continue;
277 }
278 if (c === "<" && s[i + 1] === "<") {
279 i += 2;
280 let strip = false;
281 if (s[i] === "-") {
282 strip = true;
283 i++;
284 }
285 while (s[i] === " " || s[i] === "\t") i++;
286 let dl = "";
287 while (i < n) {
288 const ch = s[i]!;
289 if (" \t\n;&|<>()".includes(ch)) break;
290 if (ch === "'" || ch === '"') {
291 const close = s.indexOf(ch, i + 1);
292 const r = close < 0 ? s.slice(i + 1) : s.slice(i + 1, close);
293 dl += r;
294 i += r.length + 2;
295 continue;
296 }
297 if (ch === "\\") {
298 dl += s[i + 1] ?? "";
299 i += 2;
300 continue;
301 }
302 dl += ch;
303 i++;
304 }
305 hq.push({ idx: this.cmds.length, strip, dl });
306 continue;
307 }
308 if (s[i + 1] === "(") {
309 const p = paren(s, i + 2);
310 this.push(p.cap, d + 1);
311 i = p.end;
312 continue;
313 }
314 i++;
315 const nx = s[i];
316 if (nx === ">" || nx === "|") i++;
317 else if (nx === "&") {
318 i++;
319 while (i < n && /[0-9-]/.test(s[i]!)) i++;
320 continue;
321 }
322 if (c === "<" && s[i] === ">") i++;
323 rn = 1;
324 continue;
325 }
326 case "#":
327 if (!wa) {
328 const nl = s.indexOf("\n", i);
329 i = nl < 0 ? n : nl;
330 continue;
331 }
332 w += c;
333 break;
334 case "\\":
335 if (s[i + 1] === "\n") {
336 i += 2;
337 continue;
338 }
339 w += s[i + 1] ?? "";
340 wa = true;
341 i += 2;
342 continue;
343 case "'": {
344 const close = s.indexOf("'", i + 1);
345 const r = close < 0 ? s.slice(i + 1) : s.slice(i + 1, close);
346 w += r;
347 wa = true;
348 i += r.length + 2;
349 continue;
350 }
351 case '"': {
352 i++;
353 wa = true;
354 while (i < n) {
355 const ch = s[i]!;
356 if (ch === '"') break;
357 if (ch === "\\") {
358 const nx = s[i + 1];
359 if (nx === "\n") {
360 i += 2;
361 continue;
362 }
363 if (nx === '"' || nx === "\\" || nx === "$" || nx === "`") {
364 w += nx;
365 i += 2;
366 continue;
367 }
368 } else if (ch === "$" && s[i + 1] === "(") {
369 dollar();
370 continue;
371 } else if (ch === "`") {
372 tick();
373 continue;
374 }
375 w += ch;
376 i++;
377 }
378 break;
379 }
380 case "$":
381 if (s[i + 1] === "(") {
382 dollar();
383 continue;
384 }
385 if (s[i + 1] === "'") {
386 i += 2;
387 let r = "";
388 while (i < n) {
389 const ch = s[i]!;
390 if (ch === "'") break;
391 if (ch === "\\") {
392 r += s.slice(i, i + 2);
393 i += 2;
394 continue;
395 }
396 r += ch;
397 i++;
398 }
399 w += ansiC(r);
400 wa = true;
401 } else {
402 w += c;
403 wa = true;
404 }
405 break;
406 case "`":
407 tick();
408 continue;
409 default:
410 w += c;
411 wa = true;
412 }
413 i++;
414 }
415 pc();
416 }
417}
418
419/** Every simple command the line runs, nested ones included. */
420export function parse(line: string): Parsed {
421 const r = new Reader();
422 r.push(line, 0);
423 for (let qi = 0; qi < r.queue.length; qi++) {
424 const { text, depth } = r.queue[qi]!;
425 const from = r.cmds.length;
426 r.scan(text, depth);
427 for (let k = from; k < r.cmds.length; k++) expand(r, k, depth);
428 }
429 return { commands: r.cmds, nested: r.queue.length > 1 };
430}
431
432export type Argv = {
433 /** argv from the executable on, wrappers and assignments walked past. */
434 argv: string[];
435 /** The executable's basename (`lookup` for `command -v`). */
436 name: string;
437 /** NAME=value words before it, wrappers' own included. */
438 assigns: Record<string, string>;
439};
440
441const RESERVED = new Set([
442 "!",
443 "{",
444 "}",
445 "if",
446 "then",
447 "else",
448 "elif",
449 "do",
450 "while",
451 "until",
452]);
453const isAssign = (t: string) => /^[A-Za-z_][^=]*=/.test(t);
454const validName = (t: string) => /^[A-Za-z0-9_]*$/.test(t.split("=")[0]!);
455const base = (t: string) => {
456 const s = t.replace(/^=/, "");
457 return s.slice(s.lastIndexOf("/") + 1);
458};
459
460/** The command's argv, as on-tool.sh's _p_argv reads it. */
461export function argvOf(cmd: Command): Argv {
462 const W = cmd.words;
463 const nw = W.length;
464 const assigns: Record<string, string> = {};
465 let x = 0;
466 while (x < nw) {
467 const t = W[x]!;
468 if (RESERVED.has(t)) {
469 x++;
470 continue;
471 }
472 if (isAssign(t) && validName(t)) {
473 const eq = t.indexOf("=");
474 assigns[t.slice(0, eq)] = t.slice(eq + 1);
475 x++;
476 continue;
477 }
478 const name = base(t);
479 if (name === "env") {
480 x++;
481 while (x < nw) {
482 const v = W[x]!;
483 if (["-u", "-C", "-S", "--unset", "--chdir"].includes(v)) x += 2;
484 else if (v.startsWith("-")) x++;
485 else if (isAssign(v)) {
486 const eq = v.indexOf("=");
487 assigns[v.slice(0, eq)] = v.slice(eq + 1);
488 x++;
489 } else break;
490 }
491 } else if (
492 [
493 "command",
494 "builtin",
495 "nohup",
496 "chronic",
497 "exec",
498 "time",
499 "caffeinate",
500 ].includes(name)
501 ) {
502 x++;
503 while (x < nw) {
504 const v = W[x]!;
505 if (name === "command" && (v === "-v" || v === "-V"))
506 return { argv: [], name: "lookup", assigns };
507 if (name === "exec" && v === "-a") x += 2;
508 else if (v.startsWith("-")) x++;
509 else break;
510 }
511 } else if (
512 ["nice", "timeout", "ionice", "stdbuf", "doas", "sudo", "xargs"].includes(
513 name,
514 )
515 ) {
516 x++;
517 while (x < nw) {
518 const v = W[x]!;
519 if (v === "--") {
520 x++;
521 break;
522 }
523 if (
524 (name === "sudo" && /^-[ugChpUrtDR]$/.test(v)) ||
525 (name === "doas" && /^-[uC]$/.test(v)) ||
526 (name === "nice" && v === "-n") ||
527 (name === "xargs" && /^-[InPLsdEa]$/.test(v)) ||
528 (name === "timeout" && /^-[sk]$/.test(v)) ||
529 (name === "ionice" && /^-[cnp]$/.test(v))
530 )
531 x += 2;
532 else if (v.startsWith("-")) x++;
533 else break;
534 }
535 if (name === "timeout") x++;
536 } else if (name === "rtk") {
537 x++;
538 if (W[x] === "proxy") x++;
539 } else break;
540 }
541 const argv = W.slice(x);
542 return { argv, name: argv[0] === undefined ? "" : base(argv[0]), assigns };
543}
544
545const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "ash", "mksh"]);
546
547/** Queue what command k hands to a shell. */
548function expand(r: Reader, k: number, depth: number): void {
549 const cmd = r.cmds[k]!;
550 const { argv: A, name } = argvOf(cmd);
551 if (SHELLS.has(name)) {
552 let y = 1;
553 while (y < A.length) {
554 const v = A[y]!;
555 if (v === "-o" || v === "+o") {
556 y += 2;
557 continue;
558 }
559 if (v.startsWith("--")) {
560 /* a long option */
561 } else if (v.startsWith("-") && v.includes("c")) {
562 r.push(A[y + 1] ?? "", depth + 1);
563 return;
564 } else if (v.startsWith("-") || v.startsWith("+")) {
565 /* an option */
566 } else return; // a script file: not ours to read
567 y++;
568 }
569 if (cmd.stdin) r.push(cmd.stdin, depth + 1);
570 if (cmd.pipe && k > 0) {
571 const prev = r.cmds[k - 1]!;
572 if (prev.stdin) r.push(prev.stdin, depth + 1);
573 const p = argvOf(prev);
574 if (p.name === "echo" || p.name === "printf")
575 r.push(p.argv.slice(1).join(" "), depth + 1);
576 }
577 } else if (name === "eval") {
578 if (A.length > 1) r.push(A.slice(1).join(" "), depth + 1);
579 } else if (name === "su" || name === "sg") {
580 for (let y = 1; y < A.length; y++)
581 if (A[y] === "-c") {
582 r.push(A[y + 1] ?? "", depth + 1);
583 return;
584 }
585 }
586}
587
588/**
589 * The text a file argument of command k stands for: `-` its stdin (and what a
590 * pipe feeds it), a file on disk through `read`, else what an earlier
591 * redirection on the same line wrote there.
592 */
593export async function fileText(
594 p: Parsed,
595 k: number,
596 f: string,
597 read: (path: string) => Promise<string | undefined>,
598): Promise<string> {
599 const cmds = p.commands;
600 const fed = (j: number) => {
601 const a = argvOf(cmds[j]!);
602 return a.name === "echo" || a.name === "printf"
603 ? `${a.argv.slice(1).join(" ")}\n`
604 : "";
605 };
606 if (f === "-") {
607 let out = cmds[k]?.stdin ?? "";
608 if (cmds[k]?.pipe && k > 0) out += (cmds[k - 1]!.stdin ?? "") + fed(k - 1);
609 return out;
610 }
611 if (!f) return "";
612 const disk = await read(f).catch(() => undefined);
613 if (disk !== undefined) return disk.slice(0, 200_000);
614 let out = "";
615 cmds.forEach((c, j) => {
616 if (c.redirs.includes(f)) out += c.stdin + fed(j);
617 });
618 return out;
619}
620hooks/mod/guards.ts 925 lines1// guards.ts — the shell's Bash guards, doubled in the mod (L4). No `$`.
2//
3// on-tool.sh stays the authority (D1); this is a second copy that only ever
4// refuses, for a session where the shell hook is missing, edited or slow.
5// Same reader (shell.ts), same order of blocks, same motifs, so the
6// bash-guard corpus decides both the same way (tests/mod/guards.corpus.ts).
7//
8// Not doubled: the staged-secret scan (BLOCK 3), which reads the index blob
9// by blob and stays the shell's alone. The mcp.json refusal of the same block
10// is doubled: it reads the index once, by name and status only.
11import { argvOf, fileText, parse, type Parsed } from "./shell.ts";
12import { scan, type Hit, type Kind, type Rules } from "./patterns.ts";
13
14export type GuardCtx = {
15 /** Set when a subagent makes the call. */
16 agentId?: string;
17 /** The delegation gate applies: main thread, KODFLOW_ROOT not off. */
18 rootMain: boolean;
19 /** The session's directory, against which relative paths resolve. */
20 projectDir: string;
21 home?: string;
22 /** <config> (CLAUDE_CONFIG_DIR, else ~/.claude). */
23 configDir?: string;
24 /** A file's text, undefined when absent; relative to projectDir. */
25 read(path: string): Promise<string | undefined>;
26 /** `git -C dir …`'s stdout, undefined on failure. */
27 git(dir: string, args: string[]): Promise<string | undefined>;
28 /** Main thread only: the merge holds a token (L5) or an exemption. */
29 mergeAllowed(): Promise<boolean>;
30 /** The vendored §5 rules (hooks/patterns), undefined when unreadable. */
31 patterns(): Promise<Rules | undefined>;
32};
33
34export type Findings = {
35 root?: string;
36 noVerify?: string;
37 attr?: Hit;
38 commit: boolean;
39 force: boolean;
40 merge?: string;
41 defaultPush?: string;
42 modWrite?: string;
43 plugin?: string;
44 /** An mcp.json added or committed, and how. */
45 mcpJson?: string;
46};
47
48export type Refusal = { motif: string; title: string; lines: string[] };
49
50const isNoVerify = (v: string) =>
51 v === "--no-veri" || v === "--no-verif" || v === "--no-verify";
52
53export const MERGE_RE =
54 /(^|\/)pulls\/[^/\s]+\/merge\/?$|merge_requests\/[^/\s]+\/merge\/?$/;
55export const MERGE_WORDS =
56 /mergePullRequest|enablePullRequestAutoMerge|merge_when_pipeline_succeeds/;
57
58/** mcp.json or .mcp.json, any case, at any path: it may carry tokens. */
59export const isMcpJson = (path: string) => /(^|\/)\.?mcp\.json$/i.test(path);
60
61/**
62 * `git diff --name-status -z` → the paths it adds or changes; a deletion is
63 * left out, and a rename or a copy gives its new path.
64 */
65export function changedPaths(out: string): string[] {
66 const w = out.split("\0");
67 const paths: string[] = [];
68 for (let i = 0; i + 1 < w.length; ) {
69 const st = w[i]!;
70 if (!st) break;
71 const two = st[0] === "R" || st[0] === "C";
72 const path = w[i + (two ? 2 : 1)] ?? "";
73 if (st[0] !== "D") paths.push(path);
74 i += two ? 3 : 2;
75 }
76 return paths;
77}
78
79/** The refusal of an mcp.json added or committed (BLOCK 3 of on-tool.sh). */
80export function mcpJsonRefusal(what: string): Refusal {
81 return {
82 motif: "mcp-json",
83 title: `BLOCKED — an mcp.json file is never committed (${what})`,
84 lines: [
85 "mcp.json and .mcp.json may carry tokens: neither is added nor committed, at any path.",
86 "Deleting one stays allowed: git rm --cached <path>, then commit the deletion.",
87 "A plugin declares its MCP servers in plugin.json (mcpServers), not in a .mcp.json.",
88 ],
89 };
90}
91
92type SpecMode = "add" | "add-f" | "update" | "commit";
93
94/**
95 * The first mcp.json a set of pathspecs would take in, a deletion left out.
96 * git status resolves them as git add and git commit do ("XY path", the old
97 * path after a rename; Y is the working tree). add-f lists ignored files too.
98 */
99async function mcpJsonSpecs(
100 ctx: Pick<GuardCtx, "git">,
101 dir: string,
102 mode: SpecMode,
103 specs: string[],
104): Promise<string | undefined> {
105 const opt =
106 mode === "update" || mode === "commit"
107 ? ["--untracked-files=no"]
108 : mode === "add-f"
109 ? ["--untracked-files=all", "--ignored"]
110 : ["--untracked-files=all"];
111 const w = (
112 (await ctx.git(dir, ["status", "--porcelain", "-z", ...opt, "--", ...specs])) ?? ""
113 ).split("\0");
114 for (let i = 0; i < w.length; i++) {
115 const e = w[i]!;
116 if (e.length < 4) continue;
117 const x = e[0]!;
118 const y = e[1]!;
119 const f = e.slice(3);
120 if (x === "R" || x === "C") i++;
121 if (!isMcpJson(f) || y === "D") continue;
122 // staged already: only a commit takes it
123 if (y === " " && (mode !== "commit" || x === "D" || x === " ")) continue;
124 return f;
125 }
126 return undefined;
127}
128
129/** A --pathspec-from-file's pathspecs, one a line. */
130async function specsFrom(
131 ctx: GuardCtx,
132 p: Parsed,
133 k: number,
134 file: string,
135 gdir: string,
136): Promise<string[]> {
137 const f = file === "-" || file.startsWith("/") ? file : `${gdir}/${file}`;
138 return (await fileText(p, k, f, ctx.read)).split("\n").filter(Boolean);
139}
140
141/** A path a skills directory must never receive (plugin machinery). */
142export function modPath(
143 path: string,
144 home: string | undefined,
145 configDir: string | undefined,
146): boolean {
147 let f = path;
148 const cfg = (configDir || `${home ?? ""}/.claude`).replace(/\/+$/, "");
149 if (f.startsWith("~/")) f = `${home ?? ""}/${f.slice(2)}`;
150 f = `/${f.replace(/^\//, "")}`;
151 if (!(f.includes("/.claude/skills/") || f.startsWith(`${cfg}/skills/`)))
152 return false;
153 return (
154 f.includes("/.claude-plugin/") ||
155 f.endsWith("/.claude-plugin") ||
156 f.endsWith("/hooks/hooks.json")
157 );
158}
159
160type State = {
161 f: Findings;
162 gdir: string;
163 hooks: boolean;
164 amend: boolean;
165 /** git commit -a: the tracked changes are staged first. */
166 all: boolean;
167 /** The repository the commit runs in. */
168 cgdir: string;
169 scan: (kind: Kind, text: string) => Hit | undefined;
170};
171
172function hit(st: State, h: Hit | undefined): void {
173 if (!st.f.attr && h) st.f.attr = h;
174}
175
176async function defaults(ctx: GuardCtx, dir: string): Promise<Set<string>> {
177 const set = new Set(["main", "master"]);
178 const h = await ctx.git(dir, [
179 "symbolic-ref",
180 "--short",
181 "refs/remotes/origin/HEAD",
182 ]);
183 if (h) set.add(h.trim().replace(/^origin\//, ""));
184 return set;
185}
186
187async function guardPush(
188 ctx: GuardCtx,
189 st: State,
190 A: string[],
191 y0: number,
192 gdir: string,
193): Promise<void> {
194 let pos = 0;
195 const refs: string[] = [];
196 let force = false;
197 let all = false;
198 for (let y = y0; y < A.length; y++) {
199 const v = A[y]!;
200 if (
201 v.startsWith("--force-with-lease") ||
202 v === "--force-if-includes" ||
203 v.startsWith("--no-force-")
204 ) {
205 /* safe */
206 } else if (v === "--force") force = true;
207 else if (v === "--mirror" || v === "--all" || v === "--branches")
208 all = true;
209 else if (
210 ["--repo", "--receive-pack", "--exec", "--push-option", "-o"].includes(v)
211 )
212 y++;
213 else if (v.startsWith("--")) {
214 if (isNoVerify(v)) st.f.noVerify = "--no-verify on git push";
215 } else if (v.startsWith("-")) {
216 if (v.includes("f")) force = true;
217 } else {
218 pos++;
219 if (pos >= 2) refs.push(v);
220 if (v.startsWith("+") && pos >= 2) force = true;
221 }
222 }
223 if (force) st.f.force = true;
224 if (!ctx.agentId) return;
225 const defs = await defaults(ctx, gdir);
226 if (all) {
227 st.f.defaultPush = "git push --all/--mirror includes the default branch";
228 return;
229 }
230 const current = async () =>
231 (await ctx.git(gdir, ["symbolic-ref", "--short", "HEAD"]))?.trim() ?? "";
232 if (!refs.length) {
233 const cur = await current();
234 if (cur && defs.has(cur))
235 st.f.defaultPush = `git push from ${cur}, the default branch`;
236 return;
237 }
238 for (let r of refs) {
239 r = r.replace(/^\+/, "");
240 let dst = r.includes(":") ? r.slice(r.indexOf(":") + 1) : r;
241 if (dst === "HEAD" || r === "HEAD") dst = await current();
242 dst = dst.replace(/^refs\/heads\//, "");
243 if (dst && defs.has(dst))
244 st.f.defaultPush = `git push to ${dst}, the default branch`;
245 }
246}
247
248async function guardGit(
249 ctx: GuardCtx,
250 st: State,
251 p: Parsed,
252 k: number,
253 A: string[],
254): Promise<void> {
255 let y = 1;
256 let gdir = st.gdir;
257 st.hooks = false;
258 while (y < A.length) {
259 const v = A[y]!;
260 if (v === "-C") {
261 // joined onto the cd directory, as git does
262 const c = A[y + 1] ?? "";
263 gdir = c.startsWith("/") || !gdir ? c : `${gdir}/${c}`;
264 y += 2;
265 continue;
266 }
267 if (v === "-c" || v === "--config-env") {
268 if ((A[y + 1] ?? "").toLowerCase().startsWith("core.hookspath="))
269 st.hooks = true;
270 y += 2;
271 continue;
272 }
273 if (v.startsWith("--config-env=")) {
274 if (v.toLowerCase().includes("=core.hookspath=")) st.hooks = true;
275 } else if (
276 ["--git-dir", "--work-tree", "--namespace", "--super-prefix"].includes(v)
277 ) {
278 y += 2;
279 continue;
280 } else if (!v.startsWith("-")) break;
281 y++;
282 }
283 if (!gdir) gdir = ctx.projectDir;
284 else if (!gdir.startsWith("/")) gdir = `${ctx.projectDir}/${gdir}`;
285 const sub = A[y] ?? "";
286 y++;
287 const rest = A.slice(y);
288 if (
289 [
290 "commit",
291 "push",
292 "rebase",
293 "cherry-pick",
294 "merge",
295 "am",
296 "apply",
297 "revert",
298 ].includes(sub)
299 )
300 st.f.root = `git ${sub} from the main thread produces into a repository.`;
301 else if (sub === "worktree" && A[y] === "add")
302 st.f.root = "git worktree from the main thread produces into a repository.";
303 else if (sub === "reset" && rest.includes("--hard"))
304 st.f.root = "git reset from the main thread produces into a repository.";
305 if (
306 st.hooks &&
307 [
308 "commit",
309 "push",
310 "merge",
311 "am",
312 "rebase",
313 "cherry-pick",
314 "revert",
315 "pull",
316 ].includes(sub)
317 )
318 st.f.noVerify = `git -c core.hooksPath on git ${sub}`;
319
320 if (sub === "add" || sub === "stage") {
321 // Every word but an option's names a pathspec, -f or not; then git
322 // status says what `.`, a directory, a glob or -A would take in
323 let mode: SpecMode = "add";
324 let all = false;
325 const specs: string[] = [];
326 while (y < A.length) {
327 const v = A[y]!;
328 if (v === "--pathspec-from-file") {
329 specs.push(...(await specsFrom(ctx, p, k, A[y + 1] ?? "", gdir)));
330 y += 2;
331 continue;
332 }
333 if (v.startsWith("--pathspec-from-file="))
334 specs.push(...(await specsFrom(ctx, p, k, v.slice(21), gdir)));
335 else if (v === "--all" || v === "--no-ignore-removal") all = true;
336 else if (v === "--update") {
337 all = true;
338 if (mode === "add") mode = "update";
339 } else if (v === "--force") mode = "add-f";
340 else if (v.startsWith("--")) {
341 /* another option */
342 } else if (v.startsWith("-")) {
343 if (v.includes("f")) mode = "add-f";
344 if (v.includes("A")) all = true;
345 if (v.includes("u")) {
346 all = true;
347 if (mode === "add") mode = "update";
348 }
349 } else {
350 if (isMcpJson(v)) st.f.mcpJson = `git ${sub} ${v}`;
351 specs.push(v);
352 }
353 y++;
354 }
355 if (!st.f.mcpJson && (specs.length || all)) {
356 const f = await mcpJsonSpecs(ctx, gdir, mode, specs);
357 if (f) st.f.mcpJson = `git ${sub} would add ${f}`;
358 }
359 } else if (sub === "commit") {
360 st.f.commit = true;
361 st.cgdir = gdir;
362 const specs: string[] = [];
363 let msg = "";
364 let farg = "";
365 let given = false;
366 while (y < A.length) {
367 const v = A[y]!;
368 if (v === "--") {
369 for (const w of A.slice(y + 1)) {
370 if (isMcpJson(w)) st.f.mcpJson = `git commit ${w}`;
371 specs.push(w);
372 }
373 break;
374 }
375 if (v === "--pathspec-from-file") {
376 specs.push(...(await specsFrom(ctx, p, k, A[y + 1] ?? "", gdir)));
377 y += 2;
378 continue;
379 }
380 if (v.startsWith("--pathspec-from-file=")) {
381 specs.push(...(await specsFrom(ctx, p, k, v.slice(21), gdir)));
382 y++;
383 continue;
384 }
385 if (v === "-m" || v === "--message" || v === "--trailer") {
386 msg += `\n${A[y + 1] ?? ""}`;
387 given = true;
388 y += 2;
389 continue;
390 }
391 if (v.startsWith("--message=") || v.startsWith("--trailer=")) {
392 msg += `\n${v.slice(v.indexOf("=") + 1)}`;
393 given = true;
394 } else if (v === "-F" || v === "--file") {
395 farg = A[y + 1] ?? "";
396 y += 2;
397 continue;
398 } else if (v.startsWith("--file=")) farg = v.slice(7);
399 else if (
400 [
401 "-C",
402 "-c",
403 "-t",
404 "--reuse-message",
405 "--reedit-message",
406 "--author",
407 "--date",
408 "--cleanup",
409 "--template",
410 "--fixup",
411 "--squash",
412 ].includes(v)
413 ) {
414 y += 2;
415 continue;
416 } else if (v === "--amend") st.amend = true;
417 else if (v === "--all") st.all = true;
418 else if (v.startsWith("--")) {
419 if (isNoVerify(v)) st.f.noVerify = "--no-verify on git commit";
420 } else if (v.startsWith("-")) {
421 let cl = v.slice(1);
422 while (cl) {
423 const ch = cl[0]!;
424 let r = cl.slice(1);
425 if (ch === "n") st.f.noVerify = "-n (--no-verify) on git commit";
426 else if (ch === "a") st.all = true;
427 else if (ch === "u" || ch === "S") break;
428 else if ("mFCct".includes(ch)) {
429 if (!r) {
430 r = A[y + 1] ?? "";
431 y++;
432 }
433 if (ch === "m") {
434 msg += `\n${r}`;
435 given = true;
436 } else if (ch === "F") farg = r;
437 break;
438 }
439 cl = r;
440 }
441 } else {
442 // a pathspec
443 if (isMcpJson(v)) st.f.mcpJson = `git commit ${v}`;
444 specs.push(v);
445 }
446 y++;
447 }
448 // A pathspec (`.`, a directory) takes the working tree's copy in
449 if (!st.f.mcpJson && specs.length) {
450 const f = await mcpJsonSpecs(ctx, gdir, "commit", specs);
451 if (f) st.f.mcpJson = `git commit ${specs.join(" ")} takes in ${f}`;
452 }
453 let hay = msg;
454 if (farg) hay += `\n${await fileText(p, k, farg, ctx.read)}`;
455 if (st.amend && !given && !farg)
456 hay += `\n${(await ctx.git(gdir, ["log", "-1", "--pretty=%B"])) ?? ""}`;
457 hit(st, st.scan("commit", hay));
458 } else if (sub === "merge") {
459 while (y < A.length) {
460 const v = A[y]!;
461 if (v === "-m") {
462 hit(st, st.scan("commit", A[y + 1] ?? ""));
463 y += 2;
464 continue;
465 }
466 if (v === "-F" || v === "--file") {
467 hit(st, st.scan("commit", await fileText(p, k, A[y + 1] ?? "", ctx.read)));
468 y += 2;
469 continue;
470 }
471 if (v.startsWith("--") && isNoVerify(v))
472 st.f.noVerify = "--no-verify on git merge";
473 y++;
474 }
475 } else if (sub === "rebase" || sub === "cherry-pick") {
476 for (const v of rest)
477 if (isNoVerify(v)) st.f.noVerify = `--no-verify on git ${sub}`;
478 hit(
479 st,
480 st.scan("commit", (await ctx.git(gdir, ["log", "-5", "--pretty=%B"])) ?? ""),
481 );
482 } else if (sub === "am" || sub === "revert" || sub === "pull") {
483 for (const v of rest)
484 if (isNoVerify(v)) st.f.noVerify = `--no-verify on git ${sub}`;
485 } else if (sub === "push") await guardPush(ctx, st, A, y, gdir);
486}
487
488async function guardGh(
489 ctx: GuardCtx,
490 st: State,
491 p: Parsed,
492 k: number,
493 A: string[],
494): Promise<void> {
495 let y = 1;
496 while (y < A.length) {
497 const v = A[y]!;
498 if (v === "-R" || v === "--repo" || v === "--hostname") y += 2;
499 else if (v.startsWith("-")) y++;
500 else break;
501 }
502 const sub = A[y] ?? "";
503 const act = A[y + 1] ?? "";
504 let hay = "";
505 if (sub === "pr" && (act === "create" || act === "edit")) {
506 if (act === "create")
507 st.f.root =
508 "gh pr create from the main thread: opening the PR is the subagent's delivery.";
509 y += 2;
510 while (y < A.length) {
511 const v = A[y]!;
512 if (["-t", "--title", "-b", "--body"].includes(v)) {
513 hay += `\n${A[y + 1] ?? ""}`;
514 y += 2;
515 continue;
516 }
517 if (v.startsWith("--title=") || v.startsWith("--body="))
518 hay += `\n${v.slice(v.indexOf("=") + 1)}`;
519 else if (v === "-F" || v === "--body-file") {
520 hay += `\n${await fileText(p, k, A[y + 1] ?? "", ctx.read)}`;
521 y += 2;
522 continue;
523 } else if (v.startsWith("--body-file="))
524 hay += `\n${await fileText(p, k, v.slice(12), ctx.read)}`;
525 else if (
526 [
527 "-R",
528 "--repo",
529 "-B",
530 "--base",
531 "-H",
532 "--head",
533 "-a",
534 "--assignee",
535 "-l",
536 "--label",
537 "-m",
538 "--milestone",
539 "-p",
540 "--project",
541 "-r",
542 "--reviewer",
543 "-T",
544 "--template",
545 ].includes(v) ||
546 v.startsWith("--add-") ||
547 v.startsWith("--remove-")
548 ) {
549 y += 2;
550 continue;
551 }
552 y++;
553 }
554 hit(st, st.scan("pr", hay));
555 } else if (sub === "pr" && act === "merge") st.f.merge = "gh pr merge";
556 else if (sub === "api") {
557 let ep = "";
558 y++;
559 while (y < A.length) {
560 const v = A[y]!;
561 if (["-f", "--raw-field", "-F", "--field"].includes(v)) {
562 const f = A[y + 1] ?? "";
563 const at = f.indexOf("=@");
564 hay +=
565 at >= 0
566 ? `\n${await fileText(p, k, f.slice(at + 2), ctx.read)}`
567 : `\n${f.includes("=") ? f.slice(f.indexOf("=") + 1) : f}`;
568 y += 2;
569 continue;
570 }
571 if (v === "--input") {
572 hay += `\n${await fileText(p, k, A[y + 1] ?? "", ctx.read)}`;
573 y += 2;
574 continue;
575 }
576 if (
577 [
578 "-X",
579 "--method",
580 "-H",
581 "--header",
582 "-q",
583 "--jq",
584 "-t",
585 "--template",
586 "-p",
587 "--preview",
588 "--hostname",
589 "--cache",
590 ].includes(v)
591 ) {
592 y += 2;
593 continue;
594 }
595 if (!v.startsWith("-") && !ep) ep = v;
596 y++;
597 }
598 if (MERGE_RE.test(ep)) st.f.merge = `gh api ${ep}`;
599 else if (ep === "graphql") {
600 const m = MERGE_WORDS.exec(hay);
601 if (m) st.f.merge = `gh api graphql ${m[0]}`;
602 if (/(create|update)PullRequest/.test(hay)) hit(st, st.scan("pr", hay));
603 } else if (/(^|\/)pulls(\/[^/]+)?\/?$/.test(ep)) hit(st, st.scan("pr", hay));
604 }
605}
606
607function guardGlab(st: State, A: string[]): void {
608 let y = 1;
609 while (y < A.length) {
610 const v = A[y]!;
611 if (v === "-R" || v === "--repo") y += 2;
612 else if (v.startsWith("-")) y++;
613 else break;
614 }
615 const sub = A[y] ?? "";
616 const act = A[y + 1] ?? "";
617 const words = MERGE_WORDS.exec(A.join(" "));
618 if (words) st.f.merge = `glab ${words[0]}`;
619 if (sub === "mr" && (act === "create" || act === "update")) {
620 if (act === "create")
621 st.f.root =
622 "glab mr create from the main thread: opening the PR is the subagent's delivery.";
623 let hay = "";
624 y += 2;
625 while (y < A.length) {
626 const v = A[y]!;
627 if (["-t", "--title", "-d", "--description"].includes(v)) {
628 hay += `\n${A[y + 1] ?? ""}`;
629 y += 2;
630 continue;
631 }
632 if (v.startsWith("--title=") || v.startsWith("--description="))
633 hay += `\n${v.slice(v.indexOf("=") + 1)}`;
634 y++;
635 }
636 hit(st, st.scan("pr", hay));
637 } else if (sub === "mr" && act === "merge") st.f.merge = "glab mr merge";
638 else if (sub === "api") {
639 y++;
640 while (y < A.length) {
641 const v = A[y]!;
642 if (
643 [
644 "-X",
645 "--method",
646 "-H",
647 "--header",
648 "-f",
649 "--raw-field",
650 "-F",
651 "--field",
652 "--hostname",
653 "--input",
654 ].includes(v)
655 ) {
656 y += 2;
657 continue;
658 }
659 if (!v.startsWith("-")) {
660 if (MERGE_RE.test(v)) st.f.merge = `glab api ${v}`;
661 break;
662 }
663 y++;
664 }
665 }
666}
667
668function guardHttp(st: State, name: string, A: string[]): void {
669 for (const v of A.slice(1)) {
670 const m = MERGE_RE.exec(v) ?? MERGE_WORDS.exec(v);
671 if (m) {
672 st.f.merge = `${name} ${m[0]}`;
673 return;
674 }
675 if (/\/pulls\/[^/\s]+\/merge([/?\s]|$)/.test(v)) {
676 st.f.merge = `${name} …/merge`;
677 return;
678 }
679 }
680}
681
682function guardClaude(st: State, A: string[]): void {
683 if (A[1] !== "plugin" && A[1] !== "plugins") return;
684 let y: number;
685 if (A[2] === "install" || A[2] === "i") y = 3;
686 else if (A[2] === "marketplace" && A[3] === "add") y = 4;
687 else return;
688 let spec = "";
689 while (y < A.length) {
690 const v = A[y]!;
691 if (v === "-s" || v === "--scope") {
692 y += 2;
693 continue;
694 }
695 if (!v.startsWith("-")) {
696 spec = v;
697 break;
698 }
699 y++;
700 }
701 if (!spec) return;
702 const ok =
703 A[2] === "marketplace"
704 ? /kodflow\/claude-marketplace(\.git|\/)?$/.test(spec)
705 : spec.endsWith("@kodflow");
706 if (!ok)
707 st.f.plugin = `claude plugin ${A[2]}${A[2] === "marketplace" ? ` ${A[3]}` : ""} ${spec}`;
708}
709
710/** The cheap gate of pre_bash: a line naming no verb we know leaves at once. */
711export function gate(line: string): boolean {
712 const t = /["'\\]/.test(line) ? line.replace(/["'\\]/g, "") : line;
713 return (
714 /git|gh|glab|curl|wget|http|skills|claude/.test(t) || line.includes("$'")
715 );
716}
717
718/** What a Bash line does that a guard looks at. */
719export async function inspect(
720 line: string,
721 ctx: GuardCtx,
722): Promise<Findings | undefined> {
723 if (!line || !gate(line)) return undefined;
724 const p = parse(line);
725 const rules = await ctx.patterns();
726 const st: State = {
727 f: { commit: false, force: false },
728 gdir: "",
729 hooks: false,
730 amend: false,
731 all: false,
732 cgdir: "",
733 scan: (kind, text) => scan(kind, text, rules),
734 };
735 for (let k = 0; k < p.commands.length; k++) {
736 const cmd = p.commands[k]!;
737 for (const r of cmd.redirs)
738 if (modPath(r, ctx.home, ctx.configDir)) st.f.modWrite = r;
739 const { argv: A, name } = argvOf(cmd);
740 st.hooks = false;
741 if (name === "claude") guardClaude(st, A);
742 else if (name === "git") await guardGit(ctx, st, p, k, A);
743 else if (name === "gh") await guardGh(ctx, st, p, k, A);
744 else if (name === "glab") guardGlab(st, A);
745 else if (["curl", "wget", "http", "https", "xh"].includes(name))
746 guardHttp(st, name, A);
747 else if (name === "cd") {
748 const d = A[1] ?? "";
749 st.gdir = d.startsWith("/") || !d || !st.gdir ? d : `${st.gdir}/${d}`;
750 }
751 }
752 if (st.f.commit && !st.f.mcpJson) {
753 const dir = st.cgdir || ctx.projectDir;
754 const staged = changedPaths(
755 (await ctx.git(dir, ["diff", "--cached", "--name-status", "-z"])) ?? "",
756 ).find(isMcpJson);
757 if (staged) st.f.mcpJson = `${staged} is staged`;
758 else if (st.all) {
759 const changed = changedPaths(
760 (await ctx.git(dir, ["diff", "--name-status", "-z"])) ?? "",
761 ).find(isMcpJson);
762 if (changed)
763 st.f.mcpJson = `${changed} is modified and git commit -a stages it`;
764 }
765 }
766 return st.f;
767}
768
769/**
770 * A forge tool that writes an mcp.json: a path a write tool names (a read
771 * and a deletion are allowed), else, for a GitKraken add or commit naming no file, what the
772 * repository would take in. pre_forge of on-tool.sh, doubled.
773 */
774export async function forgeMcpJson(
775 tool: string,
776 input: Record<string, any>,
777 ctx: Pick<GuardCtx, "projectDir" | "git">,
778): Promise<Refusal | undefined> {
779 // A read or a deletion of one stays allowed
780 if (
781 !/^mcp__(github|gitlab)__.*(push_files|create_or_update_file|create_commit)$|^mcp__GitKraken__git_(add|commit)$/.test(
782 tool,
783 )
784 )
785 return undefined;
786 const paths: unknown[] = [input.path, input.file_path];
787 for (const f of Array.isArray(input.files) ? input.files : [])
788 paths.push(typeof f === "string" ? f : (f?.path ?? f?.file_path));
789 for (const a of Array.isArray(input.actions) ? input.actions : [])
790 if (a?.action !== "delete") paths.push(a?.file_path);
791 const named = paths.find((p) => typeof p === "string" && isMcpJson(p));
792 if (named) return mcpJsonRefusal(`${tool}: ${named}`);
793 const gk = /GitKraken__git_(add|commit)$/.test(tool);
794 if (!gk || (Array.isArray(input.files) && input.files.length)) return undefined;
795 let dir = typeof input.directory === "string" ? input.directory : ".";
796 if (!dir.startsWith("/")) dir = `${ctx.projectDir}/${dir}`;
797 if (tool.endsWith("git_add")) {
798 // "XY path", the old path after a rename; Y is the working tree
799 const w = (
800 (await ctx.git(dir, ["status", "--porcelain", "-z", "--untracked-files=all"])) ?? ""
801 ).split("\0");
802 for (let i = 0; i < w.length; i++) {
803 const e = w[i]!;
804 if (e.length < 4) continue;
805 if (e[0] === "R" || e[0] === "C") i++;
806 if (e[1] === " " || e[1] === "D") continue;
807 if (isMcpJson(e.slice(3)))
808 return mcpJsonRefusal(`${tool}: ${e.slice(3)}, added with every file`);
809 }
810 return undefined;
811 }
812 const staged = changedPaths(
813 (await ctx.git(dir, ["diff", "--cached", "--name-status", "-z"])) ?? "",
814 ).find(isMcpJson);
815 return staged ? mcpJsonRefusal(`${tool}: ${staged} is staged`) : undefined;
816}
817
818const ROOT_OK =
819 /^([A-Za-z_][A-Za-z0-9_]*=[^ \t\n\r\f\v]*[ \t\n\r\f\v]+)*ROOT_OK=1([ \t\n\r\f\v]|$)/;
820
821/** The command to run instead of a forced push (BLOCK 4). */
822export function withLease(line: string): string {
823 let fixed = line
824 .replace(/(^|\s)(--force|-f)(?=\s|$)/g, "$1--force-with-lease")
825 .replace(/(\s)\+(\S+)/g, "$1$2");
826 if (!fixed.includes("--force-with-lease")) fixed += " --force-with-lease";
827 return fixed;
828}
829
830/** The first block on-tool.sh's pre_bash would raise, in its order. */
831export async function verdict(
832 line: string,
833 f: Findings,
834 ctx: GuardCtx,
835): Promise<Refusal | undefined> {
836 if (f.root && ctx.rootMain && !ROOT_OK.test(line))
837 return {
838 motif: "root",
839 title: "BLOCKED — code production in a repository is delegated",
840 lines: [
841 f.root,
842 "Dispatch the epic's subagent (task_focus the epic first), or SendMessage it if it is running;",
843 "it works in its own worktree and delivers through a PR.",
844 "A genuine exception: prefix the Bash line with ROOT_OK=1 (KODFLOW_ROOT=off turns the gate off).",
845 ],
846 };
847 if (f.noVerify)
848 return {
849 motif: "no-verify",
850 title: `COMMAND BLOCKED — skipping git hooks is forbidden (${f.noVerify})`,
851 lines: [
852 "Local git hooks are the second layer of the attribution and secret guards.",
853 "If a hook is wrong, fix the hook; do not skip it.",
854 ],
855 };
856 if (f.attr)
857 return {
858 motif: f.attr.motif,
859 title: `${f.attr.kind === "pr" ? "PULL REQUEST" : "COMMIT"} BLOCKED — AI reference detected`,
860 lines: [
861 `Forbidden pattern: ${f.attr.pattern}`,
862 "Remove AI attribution (co-authored-by, generated by, the robot emoji) and, in a commit, workflow leaks (.claude/ paths, Plan: footers).",
863 ],
864 };
865 if (f.mcpJson) return mcpJsonRefusal(f.mcpJson);
866 if (f.modWrite)
867 return {
868 motif: "mod-write",
869 title: `COMMAND BLOCKED — plugin machinery written under a skills directory (${f.modWrite})`,
870 lines: ["Ship it as a plugin of the kodflow marketplace instead."],
871 };
872 if (f.plugin)
873 return {
874 motif: "plugin-source",
875 title: `COMMAND BLOCKED — plugin from outside the kodflow marketplace (${f.plugin})`,
876 lines: ["Ask the user to install it themselves if it is wanted."],
877 };
878 if (f.force)
879 return {
880 motif: "force",
881 title: "COMMAND BLOCKED — forced push",
882 lines: [
883 "--force, -f and a +refspec overwrite the remote without checking what is there.",
884 `Run instead: ${withLease(line)}`,
885 ],
886 };
887 if (ctx.agentId) {
888 if (f.merge)
889 return {
890 motif: "merge",
891 title: `COMMAND BLOCKED — a subagent does not merge (${f.merge})`,
892 lines: [
893 "Merging is the main thread's decision, after the user's explicit OK.",
894 "Report the PR and its CI state; stop there.",
895 ],
896 };
897 if (f.defaultPush)
898 return {
899 motif: "push-default",
900 title: `COMMAND BLOCKED — a subagent does not push to the default branch (${f.defaultPush})`,
901 lines: ["Push your feature branch and open a PR."],
902 };
903 } else if (f.merge && !(await ctx.mergeAllowed()))
904 return {
905 motif: "merge",
906 title: `COMMAND BLOCKED — merge without the user's OK (${f.merge})`,
907 lines: [
908 "A merge from the main thread needs green CI and the user's answer to the kodflow merge question.",
909 ],
910 };
911 return undefined;
912}
913
914/** A refusal as the model reads it, laid out like the shell's _block. */
915export function refusalText(r: Refusal): string {
916 const bar = "═══════════════════════════════════════════════";
917 return [
918 bar,
919 ` ❌ ${r.title}`,
920 bar,
921 ...r.lines.map((l) => ` ${l}`),
922 ` [motif:${r.motif}]`,
923 ].join("\n");
924}
925hooks/mod/patterns.ts 146 lines1// patterns.ts — §5 of the user's rules, as on-tool.sh's _scan applies them. Pure.
2//
3// The rules are kodflow/post-commit's patterns.txt and patterns-strict.txt,
4// vendored at a pinned commit in hooks/patterns (SOURCE names it). Both
5// readers apply the same format: one POSIX ERE a line, `#` and blank lines
6// skipped, `# @scope: <list>` for what follows, `commit` before the first.
7// A commit message meets every pattern, strict included; a pull or merge
8// request title or body meets the `pr` scope only.
9//
10// The shell feeds each text to `grep -qiE`, which matches line by line: `^`
11// anchors a line, and neither `.` nor a bracket expression crosses a newline.
12// toRegExp keeps that: the multiline flag, `[ \t\r\f\v]` for POSIX
13// `[[:space:]]`, and a newline added to every negated bracket.
14
15export type Kind = "commit" | "pr";
16export type Hit = { motif: string; pattern: string; kind: Kind };
17export type Rule = { motif: string; re: RegExp };
18/** attr: the commit,pr scope. commit: the commit-only rest, leaks first. */
19export type Rules = { attr: readonly Rule[]; commit: readonly Rule[] };
20
21export const PATTERN_FILES = ["patterns.txt", "patterns-strict.txt"] as const;
22
23const CLASSES: Record<string, string> = {
24 space: " \\t\\r\\f\\v",
25 blank: " \\t",
26 digit: "0-9",
27 lower: "a-z",
28 upper: "A-Z",
29 alpha: "a-zA-Z",
30 alnum: "a-zA-Z0-9",
31};
32
33/** A POSIX ERE as grep -iE reads it, line by line, as a JavaScript RegExp. */
34export function toRegExp(ere: string): RegExp {
35 let out = "";
36 for (let i = 0; i < ere.length; i++) {
37 const c = ere[i]!;
38 if (c === "\\") {
39 out += ere.slice(i, i + 2);
40 i++;
41 } else if (c === "[") {
42 let j = i + 1;
43 const neg = ere[j] === "^";
44 if (neg) j++;
45 let body = "";
46 if (ere[j] === "]") {
47 body += "\\]";
48 j++;
49 }
50 while (j < ere.length && ere[j] !== "]") {
51 const m = /^\[:([a-z]+):\]/.exec(ere.slice(j));
52 if (m) {
53 const cls = CLASSES[m[1]!];
54 if (cls === undefined)
55 throw new Error(`unsupported class [:${m[1]}:]`);
56 body += cls;
57 j += m[0].length;
58 } else {
59 body += ere[j] === "\\" || ere[j] === "[" ? `\\${ere[j]}` : ere[j];
60 j++;
61 }
62 }
63 if (j >= ere.length) throw new Error(`unclosed bracket in ${ere}`);
64 out += `[${neg ? "^" : ""}${body}${neg ? "\\n" : ""}]`;
65 i = j;
66 } else out += c;
67 }
68 return new RegExp(out, "imu");
69}
70
71/**
72 * The rules of the two files' texts, in order; undefined when one is missing,
73 * a directive is malformed or a pattern does not translate, which scan
74 * refuses rather than read as clean. A commit-only pattern naming a workflow
75 * artefact (a .claude path, the Plan: footer) is a leak and is tried before
76 * the vendor keywords, so `\bclaude\b` does not report `.claude/plans/x.md`
77 * as an attribution.
78 */
79export function parseRules(
80 texts: readonly (string | undefined)[],
81): Rules | undefined {
82 const attr: Rule[] = [];
83 const leak: Rule[] = [];
84 const kw: Rule[] = [];
85 for (const text of texts) {
86 if (text === undefined) return undefined;
87 let scope = ["commit"];
88 for (const line of text.split("\n")) {
89 const d = /^#\s*@scope:(.*)$/.exec(line);
90 if (d) {
91 const list = d[1]!.replace(/\s/g, "");
92 if (!/^[a-z]+(,[a-z]+)*$/.test(list)) return undefined;
93 scope = list.split(",");
94 if (scope.some((s) => s !== "commit" && s !== "pr")) return undefined;
95 continue;
96 }
97 if (line === "" || line.startsWith("#")) continue;
98 let re: RegExp;
99 try {
100 re = toRegExp(line);
101 } catch {
102 return undefined;
103 }
104 if (scope.includes("pr")) attr.push({ motif: "attribution", re });
105 else if (/\\\.claude|plan:/.test(line)) leak.push({ motif: "leak", re });
106 else kw.push({ motif: "attribution", re });
107 }
108 }
109 const commit = [...leak, ...kw];
110 return attr.length && commit.length ? { attr, commit } : undefined;
111}
112
113/** The rules read from the vendored directory through `read`. */
114export async function loadRules(
115 read: (path: string) => Promise<string | undefined>,
116 dir: string,
117): Promise<Rules | undefined> {
118 return parseRules(
119 await Promise.all(
120 PATTERN_FILES.map((f) => read(`${dir}/${f}`).catch(() => undefined)),
121 ),
122 );
123}
124
125/** The first §5 pattern `text` meets for `kind`, undefined for none. */
126export function scan(
127 kind: Kind,
128 text: string,
129 rules: Rules | undefined,
130): Hit | undefined {
131 const hay = text.toLowerCase();
132 if (!/\S/.test(hay)) return undefined;
133 if (!rules)
134 return {
135 motif: "attribution",
136 pattern: `(hooks/patterns unreadable or malformed: nothing to check ${kind} text against)`,
137 kind,
138 };
139 for (const p of rules.attr)
140 if (p.re.test(hay)) return { motif: p.motif, pattern: p.re.source, kind };
141 if (kind !== "commit") return undefined;
142 for (const p of rules.commit)
143 if (p.re.test(hay)) return { motif: p.motif, pattern: p.re.source, kind };
144 return undefined;
145}
146hooks/mod/merge.ts 777 lines1// merge.ts — the merge gate (L5, D4). No `$`: the host is a MergePort.
2//
3// A merge leaves the main thread only pinned to the head it was shown, with
4// its CI green and the user's yes to `$.ui.ask`; a subagent never merges.
5// Nothing is rewritten: an unpinned merge is refused with the exact command
6// to run instead, and the second call, pinned, is the one asked about. A yes
7// writes a token, <config>/kodflow/sessions/<sid>/merge-ok/<tool_use_id>,
8// holding the SHA; on-tool.sh refuses a main-thread merge without one.
9//
10// Every failure refuses: an error, a timeout or a rejection reading the
11// forge, a dismissed question.
12import { argvOf, parse, type Parsed } from "./shell.ts";
13import { MERGE_RE, MERGE_WORDS } from "./guards.ts";
14
15export type Host = "github" | "gitlab";
16
17export type Target = {
18 host: Host;
19 /** owner/repo, or the GitLab project path or id. */
20 repo: string;
21 number: string;
22 /** The SHA the call pins, when it pins one. */
23 pinned?: string;
24 /** The exact call to make instead, pinned to `sha`. */
25 fix(sha: string): string;
26 /** How the call is shown in the question. */
27 label: string;
28};
29
30export type Classified =
31 | { kind: "none" }
32 | { kind: "merge"; label: string; target?: Target; ambiguous?: string };
33
34const NONE: Classified = { kind: "none" };
35
36/** POSIX single quotes, only where a word needs them. */
37export const quote = (w: string): string =>
38 /^[A-Za-z0-9_./:@%+,=-]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`;
39const show = (argv: string[]) => argv.map(quote).join(" ");
40
41/** owner/repo of a remote URL (https, ssh, scp form), and its host. */
42export function remoteRepo(
43 url: string,
44): { host: Host | undefined; repo: string } | undefined {
45 const m =
46 /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[:/](.+?)(?:\.git)?\/?$/.exec(
47 url.trim(),
48 );
49 if (!m) return undefined;
50 const hostName = m[1]!.toLowerCase();
51 const host: Host | undefined = hostName.includes("github")
52 ? "github"
53 : hostName.includes("gitlab")
54 ? "gitlab"
55 : undefined;
56 return { host, repo: m[2]!.replace(/^\/+/, "") };
57}
58
59/** OWNER/REPO, HOST/OWNER/REPO or a URL → the repository path. */
60function repoArg(v: string, host: Host): string {
61 if (/^https?:\/\//.test(v)) return remoteRepo(v)?.repo ?? v;
62 const parts = v.split("/").filter(Boolean);
63 if (host === "github" && parts.length > 2) return parts.slice(-2).join("/");
64 return parts.join("/");
65}
66
67function optValue(A: string[], names: string[]): string | undefined {
68 for (let y = 0; y < A.length; y++) {
69 const v = A[y]!;
70 for (const n of names) {
71 if (v === n) return A[y + 1];
72 if (n.startsWith("--") && v.startsWith(`${n}=`))
73 return v.slice(n.length + 1);
74 }
75 }
76 return undefined;
77}
78
79/** `-f sha=…`, `-F sha=…`, `--field sha=…`, `--raw-field sha=…`. */
80function fieldValue(A: string[], key: string): string | undefined {
81 for (let y = 0; y < A.length; y++)
82 if (["-f", "-F", "--field", "--raw-field"].includes(A[y]!)) {
83 const f = A[y + 1] ?? "";
84 if (f.startsWith(`${key}=`)) return f.slice(key.length + 1);
85 }
86 return undefined;
87}
88
89/** argv without `name value` and `name=value`. */
90function dropOpt(A: string[], name: string): string[] {
91 const out: string[] = [];
92 for (let i = 0; i < A.length; i++) {
93 if (A[i] === name) {
94 i++;
95 continue;
96 }
97 if (!A[i]!.startsWith(`${name}=`)) out.push(A[i]!);
98 }
99 return out;
100}
101
102/** argv without the API field `key` (`-f key=…` and its kin). */
103function dropField(A: string[], key: string): string[] {
104 const out: string[] = [];
105 for (let i = 0; i < A.length; i++) {
106 if (
107 ["-f", "-F", "--field", "--raw-field"].includes(A[i]!) &&
108 (A[i + 1] ?? "").startsWith(`${key}=`)
109 ) {
110 i++;
111 continue;
112 }
113 out.push(A[i]!);
114 }
115 return out;
116}
117
118type Found = { label: string; target?: Target; ambiguous?: string };
119
120export type BashEnv = {
121 /** GH_REPO of the session's environment. */
122 ghRepo?: string;
123 /** The session directory's origin remote, read on demand. */
124 origin(): Promise<string | undefined>;
125};
126
127const GH_PR_MERGE_VALUED = [
128 "-b",
129 "--body",
130 "-F",
131 "--body-file",
132 "-t",
133 "--subject",
134 "-A",
135 "--author-email",
136 "-R",
137 "--repo",
138 "--match-head-commit",
139];
140
141async function ghPrMerge(
142 A: string[],
143 assigns: Record<string, string>,
144 env: BashEnv,
145 movesDir: boolean,
146): Promise<Found> {
147 const label = "gh pr merge";
148 // A after "gh … pr merge"
149 const at = A.indexOf("merge");
150 const args = A.slice(at + 1);
151 let positional: string | undefined;
152 for (let y = 0; y < args.length; y++) {
153 const v = args[y]!;
154 if (GH_PR_MERGE_VALUED.includes(v)) {
155 y++;
156 continue;
157 }
158 if (!v.startsWith("-")) {
159 positional = v;
160 break;
161 }
162 }
163 let repo = optValue(A, ["-R", "--repo"]);
164 let number: string | undefined;
165 if (positional && /^https?:\/\//.test(positional)) {
166 const m = /^https?:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(positional);
167 if (!m)
168 return { label, ambiguous: `${positional} is not a pull request URL` };
169 repo = repo ?? m[1];
170 number = m[2];
171 } else if (positional && /^#?\d+$/.test(positional))
172 number = positional.replace(/^#/, "");
173 if (!number)
174 return {
175 label,
176 ambiguous: positional
177 ? `the pull request is named by a branch (${positional}), not a number`
178 : "no pull request number: gh would pick the current branch's",
179 };
180 const explicitRepo = repo !== undefined;
181 repo = repo ?? assigns.GH_REPO ?? env.ghRepo;
182 if (!repo) {
183 if (movesDir)
184 return { label, ambiguous: "no -R and the line changes directory" };
185 const o = await env.origin();
186 const r = o ? remoteRepo(o) : undefined;
187 if (!r || r.host === "gitlab")
188 return { label, ambiguous: "no -R, no GH_REPO and no GitHub origin" };
189 repo = r.repo;
190 }
191 const full = repoArg(repo, "github");
192 const pinned = optValue(A, ["--match-head-commit"]);
193 return {
194 label,
195 target: {
196 host: "github",
197 repo: full,
198 number,
199 pinned,
200 label: `${full}#${number}`,
201 fix: (sha) =>
202 `${show(dropOpt(A, "--match-head-commit"))}${explicitRepo ? "" : ` -R ${full}`} --match-head-commit ${sha}`,
203 },
204 };
205}
206
207async function glabMrMerge(
208 A: string[],
209 env: BashEnv,
210 movesDir: boolean,
211): Promise<Found> {
212 const label = "glab mr merge";
213 const at = A.indexOf("merge");
214 const args = A.slice(at + 1);
215 let positional: string | undefined;
216 for (let y = 0; y < args.length; y++) {
217 const v = args[y]!;
218 if (
219 ["-R", "--repo", "--sha", "-m", "--message", "--squash-message"].includes(
220 v,
221 )
222 ) {
223 y++;
224 continue;
225 }
226 if (!v.startsWith("-")) {
227 positional = v;
228 break;
229 }
230 }
231 let repo = optValue(A, ["-R", "--repo"]);
232 let number: string | undefined;
233 if (positional && /^https?:\/\//.test(positional)) {
234 const m = /^https?:\/\/[^/]+\/(.+?)\/-\/merge_requests\/(\d+)/.exec(
235 positional,
236 );
237 if (!m)
238 return { label, ambiguous: `${positional} is not a merge request URL` };
239 repo = repo ?? m[1];
240 number = m[2];
241 } else if (positional && /^!?\d+$/.test(positional))
242 number = positional.replace(/^!/, "");
243 if (!number) return { label, ambiguous: "no merge request number" };
244 const explicitRepo = repo !== undefined;
245 if (!repo) {
246 if (movesDir)
247 return { label, ambiguous: "no -R and the line changes directory" };
248 const o = await env.origin();
249 const r = o ? remoteRepo(o) : undefined;
250 if (!r || r.host === "github")
251 return { label, ambiguous: "no -R and no GitLab origin" };
252 repo = r.repo;
253 }
254 const full = repoArg(repo, "gitlab");
255 const pinned = optValue(A, ["--sha"]);
256 return {
257 label,
258 target: {
259 host: "gitlab",
260 repo: full,
261 number,
262 pinned,
263 label: `${full}!${number}`,
264 fix: (sha) =>
265 `${show(dropOpt(A, "--sha"))}${explicitRepo ? "" : ` -R ${full}`} --sha ${sha}`,
266 },
267 };
268}
269
270function apiMerge(tool: "gh" | "glab", A: string[], ep: string): Found {
271 const label = `${tool} api ${ep}`;
272 const pinned = fieldValue(A, "sha");
273 const fix = (sha: string) => `${show(dropField(A, "sha"))} -f sha=${sha}`;
274 if (tool === "gh") {
275 const m = /^\/?repos\/([^/]+\/[^/]+)\/pulls\/(\d+)\/merge\/?$/.exec(ep);
276 if (!m)
277 return {
278 label,
279 ambiguous: `${ep} does not name owner/repo and a number`,
280 };
281 return {
282 label,
283 target: {
284 host: "github",
285 repo: m[1]!,
286 number: m[2]!,
287 pinned,
288 label: `${m[1]}#${m[2]}`,
289 fix,
290 },
291 };
292 }
293 const m = /^\/?projects\/([^/]+)\/merge_requests\/(\d+)\/merge\/?$/.exec(ep);
294 if (!m)
295 return { label, ambiguous: `${ep} does not name a project and a number` };
296 const repo = decodeURIComponent(m[1]!);
297 return {
298 label,
299 target: {
300 host: "gitlab",
301 repo,
302 number: m[2]!,
303 pinned,
304 label: `${repo}!${m[2]}`,
305 fix,
306 },
307 };
308}
309
310function apiEndpoint(A: string[]): string {
311 for (let y = 2; y < A.length; y++) {
312 const v = A[y]!;
313 if (
314 [
315 "-X",
316 "--method",
317 "-H",
318 "--header",
319 "-q",
320 "--jq",
321 "-t",
322 "--template",
323 "-p",
324 "--preview",
325 "--hostname",
326 "--cache",
327 "-f",
328 "--raw-field",
329 "-F",
330 "--field",
331 "--input",
332 ].includes(v)
333 ) {
334 y++;
335 continue;
336 }
337 if (!v.startsWith("-")) return v;
338 }
339 return "";
340}
341
342/** The merges a Bash line makes, and which one, when it can be told. */
343export async function classifyBash(
344 line: string,
345 env: BashEnv,
346 parsed?: Parsed,
347): Promise<Classified> {
348 if (!/merge|Merge/.test(line)) return NONE;
349 const p = parsed ?? parse(line);
350 const found: Found[] = [];
351 let movesDir = false;
352 for (const cmd of p.commands) {
353 const { argv: A, name, assigns } = argvOf(cmd);
354 if (name === "cd" || name === "pushd") movesDir = true;
355 if (name === "gh") {
356 let y = 1;
357 while (y < A.length) {
358 const v = A[y]!;
359 if (v === "-R" || v === "--repo" || v === "--hostname") y += 2;
360 else if (v.startsWith("-")) y++;
361 else break;
362 }
363 const sub = A[y];
364 const act = A[y + 1];
365 if (sub === "pr" && act === "merge")
366 found.push(await ghPrMerge(A, assigns, env, movesDir));
367 else if (sub === "api") {
368 const ep = apiEndpoint(A);
369 const joined = A.join(" ");
370 if (MERGE_RE.test(ep)) found.push(apiMerge("gh", A, ep));
371 else if (ep === "graphql" && MERGE_WORDS.test(joined))
372 found.push({
373 label: "gh api graphql",
374 ambiguous:
375 "a GraphQL merge names the pull request by node id; use gh pr merge <number> -R owner/repo",
376 });
377 }
378 } else if (name === "glab") {
379 let y = 1;
380 while (y < A.length) {
381 const v = A[y]!;
382 if (v === "-R" || v === "--repo") y += 2;
383 else if (v.startsWith("-")) y++;
384 else break;
385 }
386 const sub = A[y];
387 const act = A[y + 1];
388 const ep = sub === "api" ? apiEndpoint(A) : "";
389 if (sub === "mr" && act === "merge")
390 found.push(await glabMrMerge(A, env, movesDir));
391 else if (sub === "api" && MERGE_RE.test(ep))
392 found.push(apiMerge("glab", A, ep));
393 else if (MERGE_WORDS.test(A.join(" ")))
394 found.push({
395 label: "glab merge_when_pipeline_succeeds",
396 ambiguous:
397 "an automatic merge is not pinned to a head; use glab mr merge <number> -R <project> --sha <sha>",
398 });
399 } else if (["curl", "wget", "http", "https", "xh"].includes(name)) {
400 if (
401 A.slice(1).some(
402 (v) =>
403 MERGE_RE.test(v) ||
404 MERGE_WORDS.test(v) ||
405 /\/pulls\/[^/\s]+\/merge([/?\s]|$)/.test(v),
406 )
407 )
408 found.push({
409 label: `${name} merge`,
410 ambiguous:
411 "a raw HTTP merge is not read; use gh pr merge or glab mr merge",
412 });
413 }
414 }
415 if (!found.length) return NONE;
416 if (found.length > 1)
417 return {
418 kind: "merge",
419 label: found.map((f) => f.label).join(", "),
420 ambiguous: "several merges on one line",
421 };
422 return { kind: "merge", ...found[0]! };
423}
424
425const MCP_MERGE =
426 /^mcp__(github__merge_pull_request|gitlab__merge_merge_request)$/;
427
428/** The merge an MCP forge call makes, if it makes one. */
429export function classifyMcp(
430 tool: string,
431 input: Record<string, unknown>,
432): Classified {
433 const auto =
434 input.merge_when_pipeline_succeeds === true || input.auto_merge === true;
435 if (!MCP_MERGE.test(tool)) {
436 if (auto && /^mcp__(github|gitlab|GitKraken)__/.test(tool))
437 return {
438 kind: "merge",
439 label: tool,
440 ambiguous:
441 "an automatic merge is not pinned to a head; merge with merge_merge_request and sha once CI is green",
442 };
443 return NONE;
444 }
445 const str = (v: unknown) =>
446 typeof v === "string" || typeof v === "number" ? String(v) : undefined;
447 const pinned = str(input.sha);
448 const fix = (sha: string) => `call ${tool} again with "sha": "${sha}"`;
449 if (tool === "mcp__github__merge_pull_request") {
450 const owner = str(input.owner);
451 const repo = str(input.repo);
452 const number = str(input.pullNumber);
453 if (!owner || !repo || !number)
454 return {
455 kind: "merge",
456 label: tool,
457 ambiguous: "owner, repo and pullNumber are required",
458 };
459 return {
460 kind: "merge",
461 label: tool,
462 target: {
463 host: "github",
464 repo: `${owner}/${repo}`,
465 number,
466 pinned,
467 label: `${owner}/${repo}#${number}`,
468 fix,
469 },
470 };
471 }
472 const project = str(input.project_id);
473 const number = str(input.merge_request_iid);
474 if (!project || !number)
475 return {
476 kind: "merge",
477 label: tool,
478 ambiguous: "project_id and merge_request_iid are required",
479 };
480 return {
481 kind: "merge",
482 label: tool,
483 target: {
484 host: "gitlab",
485 repo: project,
486 number,
487 pinned,
488 label: `${project}!${number}`,
489 fix,
490 },
491 };
492}
493
494export type RunResult = { exitCode: number; stdout: string; stderr: string };
495
496export type MergePort = {
497 /** Runs argv; rejects on a timeout. */
498 run(argv: string[], timeoutMs: number): Promise<RunResult>;
499 /** $.ui.ask; rejects when dismissed or with no one to ask. */
500 ask(question: string, options: string[]): Promise<string>;
501 /** Writes the token file (directories created). */
502 writeToken(text: string): Promise<void>;
503 now(): number;
504 /** When the user last pressed Enter at the prompt (ms), if ever. */
505 lastComposerAt(): number | undefined;
506 /** The versioned exemptions (merge-exemptions.txt), one repository a line. */
507 exemptions(): Promise<string | undefined>;
508 /** Journal an exemption or a written consent used. */
509 note(text: string): void;
510 /** The last prompt typed at the terminal, until a merge consumed it. */
511 lastPrompt(): string | undefined;
512 /** That prompt served one merge: it serves no other. */
513 consume(): void;
514 /** KODFLOW_MERGE_CONSENT=off: the question every time. */
515 consentOff(): Promise<boolean>;
516};
517
518/** Never exempt, whatever the file says: the guards' own repository. */
519export const NEVER_EXEMPT = ["kodflow/claude-marketplace"];
520
521/** Whether `repo` is listed in the exemptions text (comments and blanks skipped). */
522export function isExempt(text: string | undefined, repo: string): boolean {
523 const want = repo.toLowerCase().replace(/\.git$/, "");
524 if (NEVER_EXEMPT.includes(want)) return false;
525 for (const raw of (text ?? "").split("\n")) {
526 const line = raw.replace(/#.*/, "").trim().toLowerCase();
527 if (line && line === want) return true;
528 }
529 return false;
530}
531
532export type Head = {
533 sha: string;
534 ci: "green" | "pending" | "failing" | "none";
535 detail: string;
536};
537
538export const FORGE_TIMEOUT_MS = 15_000;
539export const PRESENCE_MS = 10 * 60_000;
540export const APPROVAL_MS = 5 * 60_000;
541export const YES = "Merge";
542export const NO = "Do not merge";
543
544/** The rollup of `gh pr view --json statusCheckRollup`. */
545export function githubCi(rollup: unknown): Pick<Head, "ci" | "detail"> {
546 if (!Array.isArray(rollup) || rollup.length === 0)
547 return { ci: "none", detail: "no check reported" };
548 const bad: string[] = [];
549 const waiting: string[] = [];
550 for (const c of rollup as Record<string, unknown>[]) {
551 const name = String(c.name ?? c.context ?? "?");
552 if (c.__typename === "StatusContext" || "state" in c) {
553 const s = String(c.state ?? "");
554 if (s === "SUCCESS") continue;
555 if (s === "PENDING" || s === "EXPECTED") waiting.push(name);
556 else bad.push(`${name} ${s.toLowerCase()}`);
557 continue;
558 }
559 if (c.status !== "COMPLETED") {
560 waiting.push(name);
561 continue;
562 }
563 const k = String(c.conclusion ?? "");
564 if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(k))
565 bad.push(`${name} ${k.toLowerCase()}`);
566 }
567 if (bad.length) return { ci: "failing", detail: bad.join(", ") };
568 if (waiting.length) return { ci: "pending", detail: waiting.join(", ") };
569 return { ci: "green", detail: `${rollup.length} checks green` };
570}
571
572/** The head SHA and the CI state, from gh or glab. */
573export async function readHead(port: MergePort, t: Target): Promise<Head> {
574 if (t.host === "github") {
575 const r = await port.run(
576 [
577 "gh",
578 "pr",
579 "view",
580 t.number,
581 "-R",
582 t.repo,
583 "--json",
584 "headRefOid,state,statusCheckRollup",
585 ],
586 FORGE_TIMEOUT_MS,
587 );
588 if (r.exitCode !== 0)
589 throw new Error(`gh pr view failed: ${r.stderr.trim().slice(0, 200)}`);
590 const j = JSON.parse(r.stdout);
591 if (
592 typeof j.headRefOid !== "string" ||
593 !/^[0-9a-f]{40}$/.test(j.headRefOid)
594 )
595 throw new Error("gh pr view returned no head SHA");
596 if (j.state && j.state !== "OPEN")
597 throw new Error(`the pull request is ${String(j.state).toLowerCase()}`);
598 return { sha: j.headRefOid, ...githubCi(j.statusCheckRollup) };
599 }
600 const r = await port.run(
601 ["glab", "mr", "view", t.number, "-R", t.repo, "-F", "json"],
602 FORGE_TIMEOUT_MS,
603 );
604 if (r.exitCode !== 0)
605 throw new Error(`glab mr view failed: ${r.stderr.trim().slice(0, 200)}`);
606 const j = JSON.parse(r.stdout);
607 const sha = j.sha ?? j.diff_refs?.head_sha;
608 if (typeof sha !== "string" || !/^[0-9a-f]{40}$/.test(sha))
609 throw new Error("glab mr view returned no head SHA");
610 const status = j.head_pipeline?.status ?? j.pipeline?.status;
611 const ci: Head["ci"] =
612 status === "success"
613 ? "green"
614 : status === undefined || status === null
615 ? "none"
616 : [
617 "running",
618 "pending",
619 "created",
620 "preparing",
621 "waiting_for_resource",
622 "scheduled",
623 ].includes(status)
624 ? "pending"
625 : "failing";
626 return { sha, ci, detail: status ? `pipeline ${status}` : "no pipeline" };
627}
628
629// A word that merges, standing alone (not "mergé", "mergeable", "merge-ok")
630const CONSENT_WORD =
631 /(?<![\p{L}\p{N}_-])(merge|merger|fusionne|fusionner|fusionnes)(?![\p{L}\p{N}_]|-(?!(?:la|le|les)(?![\p{L}\p{N}_])))/iu;
632// Anything that holds the merge back: a negation, a wait, a later
633const HOLD_WORD =
634 /(?<![\p{L}\p{N}_])(ne|n'|n’|pas|jamais|sans|aucun|aucune|plus tard|tard|attends|attendez|attendre|attend|don't|don’t|dont|do not|not|never|no|wait|hold|stop|later|before|avant|after|après|tomorrow|demain)(?![\p{L}\p{N}_])/iu;
635const CONSENT_EXCERPT = 80;
636
637/**
638 * The user's written consent in their last prompt, as the journal shows it,
639 * or undefined. Only the words they typed count: a pasted block, a paste
640 * placeholder or a `code span` is dropped first. A question, a negation or a
641 * wait anywhere is no consent; when in doubt, the gate asks.
642 */
643export function writtenConsent(text: string | undefined): string | undefined {
644 if (!text) return undefined;
645 const own = text
646 .replace(/<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content>/gi, " ")
647 .replace(/\[Pasted text[^\]]*\]/gi, " ")
648 .replace(/`[^`]*`/g, " ");
649 if (/[?¿]/.test(own) || HOLD_WORD.test(own) || !CONSENT_WORD.test(own))
650 return undefined;
651 return own.replace(/\s+/g, " ").trim().slice(0, CONSENT_EXCERPT);
652}
653
654const approvals = new Map<string, number>();
655const approvalKey = (t: Target, sha: string) =>
656 `${t.host}:${t.repo}#${t.number}@${sha}`;
657
658/** Forget every remembered approval (tests). */
659export const resetApprovals = (): void => approvals.clear();
660
661export type Gate = { deny: string } | { allow: false; sha: string };
662
663const refuse = (title: string, ...lines: string[]): { deny: string } => ({
664 deny: [
665 "═══════════════════════════════════════════════",
666 ` ❌ MERGE BLOCKED — ${title}`,
667 "═══════════════════════════════════════════════",
668 ...lines.map((l) => ` ${l}`),
669 " [motif:merge]",
670 ].join("\n"),
671});
672
673/**
674 * The gate's decision on a classified call: `{ deny }`, or the go-ahead
675 * (`allow: false`: the mod never allows; it lets the call go on to the
676 * guards and the shell, with the token written).
677 */
678export async function gate(
679 c: Classified,
680 who: { agentId?: string },
681 port: MergePort,
682): Promise<Gate | undefined> {
683 if (c.kind === "none") return undefined;
684 if (who.agentId)
685 return refuse(
686 `a subagent does not merge (${c.label})`,
687 "Merging is the main thread's decision, after the user's explicit OK.",
688 "Report the PR and its CI state; stop there.",
689 );
690 if (c.ambiguous || !c.target)
691 return refuse(
692 `ambiguous target (${c.label})`,
693 c.ambiguous ?? "the repository and the number cannot be told",
694 "Name the repository and the number explicitly, then pin the head.",
695 );
696 const t = c.target;
697 if (isExempt(await port.exemptions().catch(() => undefined), t.repo)) {
698 port.note(`exempt: merge exemption used for ${t.label}`);
699 return { allow: false, sha: t.pinned ?? "" };
700 }
701 let head: Head;
702 try {
703 head = await readHead(port, t);
704 } catch (err) {
705 return refuse(
706 `cannot read ${t.label}`,
707 String((err as Error)?.message ?? err),
708 "Nothing is merged without its head and its CI.",
709 );
710 }
711 if (!t.pinned)
712 return refuse(
713 `not pinned to a head (${t.label})`,
714 `The head of ${t.label} is ${head.sha}. Run instead:`,
715 t.fix(head.sha),
716 );
717 if (t.pinned.toLowerCase() !== head.sha)
718 return refuse(
719 `the head moved (${t.label})`,
720 `Pinned ${t.pinned}, head now ${head.sha}. Review the new commits, then:`,
721 t.fix(head.sha),
722 );
723 const key = approvalKey(t, head.sha);
724 const held = approvals.get(key);
725 const now = port.now();
726 if (held !== undefined && now - held < APPROVAL_MS) {
727 await port.writeToken(tokenText(t, head.sha, now));
728 return { allow: false, sha: head.sha };
729 }
730 if (head.ci !== "green")
731 return refuse(
732 `CI is not green on ${t.label} (${head.ci})`,
733 head.detail,
734 "Wait for the checks, fix them, then ask again.",
735 );
736 const last = port.lastComposerAt();
737 if (last === undefined || now - last > PRESENCE_MS)
738 return refuse(
739 `merge waiting for the user's OK (${t.label})`,
740 "No message typed at the prompt in the last 10 minutes: no one is there to answer.",
741 "Report the PR, its head and its green CI, and wait for the user.",
742 );
743 // The user already wrote it, in the prompt that is still the last one:
744 // no question. A failed switch read keeps the question.
745 const consent = (await port.consentOff().catch(() => true))
746 ? undefined
747 : writtenConsent(port.lastPrompt());
748 if (consent !== undefined) {
749 await port.writeToken(tokenText(t, head.sha, now));
750 port.consume();
751 approvals.set(key, now);
752 port.note(`written consent: ${consent}`);
753 return { allow: false, sha: head.sha };
754 }
755 let answer: string;
756 try {
757 answer = await port.ask(
758 `Merge ${t.label} at ${head.sha.slice(0, 12)}? CI: ${head.detail}.`,
759 [YES, NO],
760 );
761 } catch {
762 return refuse(
763 `the question was dismissed (${t.label})`,
764 "No answer, no merge.",
765 );
766 }
767 if (answer !== YES)
768 return refuse(`the user said no (${t.label})`, `Answer: ${answer}`);
769 approvals.set(key, now);
770 await port.writeToken(tokenText(t, head.sha, now));
771 return { allow: false, sha: head.sha };
772}
773
774/** The token on-tool.sh reads: the SHA first, then what it was for. */
775export const tokenText = (t: Target, sha: string, now: number): string =>
776 `${sha}\n${t.host}:${t.repo}#${t.number}\n${Math.floor(now / 1000)}\n`;
777hooks/mod/blast.ts 49 lines1// blast.ts — the commands whose damage cannot be undone. Pure.
2//
3// `rm` both recursive and forced, `git reset --hard`, a forced push. The mod
4// puts them to the user through `$.ui.ask`, and only while the user is at
5// the prompt (a message typed in the last 10 minutes); with no one there the
6// call goes on (fail open): the shell's own guards still apply beneath.
7import { argvOf, type Parsed } from "./shell.ts";
8
9/** What on the line would be asked about, one label per command. */
10export function blastRadius(p: Parsed): string[] {
11 const out: string[] = [];
12 for (const cmd of p.commands) {
13 const { argv: A, name } = argvOf(cmd);
14 if (name === "rm") {
15 let r = false;
16 let f = false;
17 for (const v of A.slice(1)) {
18 if (v === "--") break;
19 if (v === "--recursive") r = true;
20 else if (v === "--force") f = true;
21 else if (/^-[^-]/.test(v)) {
22 if (/[rR]/.test(v)) r = true;
23 if (v.includes("f")) f = true;
24 }
25 }
26 if (r && f) out.push(`rm ${A.slice(1).join(" ")}`);
27 } else if (name === "git") {
28 let y = 1;
29 while (y < A.length && A[y]!.startsWith("-"))
30 y += ["-C", "-c", "--git-dir", "--work-tree"].includes(A[y]!) ? 2 : 1;
31 const sub = A[y];
32 const rest = A.slice(y + 1);
33 if (sub === "reset" && rest.includes("--hard"))
34 out.push(`git reset ${rest.join(" ")}`);
35 if (
36 sub === "push" &&
37 rest.some(
38 (v) =>
39 v === "--force" ||
40 (/^-[^-]/.test(v) && v.includes("f")) ||
41 /^\+./.test(v),
42 )
43 )
44 out.push(`git push ${rest.join(" ")}`);
45 }
46 }
47 return out;
48}
49