SLOPSHOPPER

kodflow-hooks

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…

newguardcommandtoastprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kodflow-hooks
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by kodflow-hooks: ═══════════════════════════════════════════════ ❌ BLOCKED — code production in ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lint-setup ⎿ kodflow-hooks: No go.mod at or above /work/app inside /work/app: not a Go project, so the hooks do not run ⎿ kodflow-hooks: ktn-linter here, and there is nothing to set up. ⎿ kodflow-hooks: Name a file or a directory of a Go module to check it: /lint-setup <path>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

kodflow-hooks

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.

The mod and what is lost without it

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.

EventWhat the mod does
session.startregisters 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: compactadds 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-setupchecks ktn-linter for a Go module (below)
classic.SessionStart, prompt.submit, every tool.callwrites 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 toolanswers it (below); a refusal is { deny } with the text the model reads, an internal failure a text error, never a crash
tool.call on Bashone 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 ransecrets masked in the result (veil.ts), below
prompt.submitremembers 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.registermodwall: 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-modsthe modules judged at this load, and the refusals on record
session.startzsh-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, NotebookEditcollision-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.endempties this session's edits.tsv
agent.spawngated models: passes or refuses a spawn on the model it would run on, never writes one (below)
tool.call on Skillgated models: a skill whose frontmatter names one passes only with the session's grant, asked once like a spawn
classic.PreModelSwitcha /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 subagentcleanup 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 taskcleanup 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 composerwrites <config>/kodflow/sessions/<session>/composer-at (seconds), the presence on-stop.sh reads before proposing agent feedback

The Bash pipeline

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).

#PolicyWhat it doesA failure
1merge gate (merge.ts)belowrefuses
2guards (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 itis logged, the call goes on
3blast 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 onis logged, the call goes on
4the callthe shell's PreToolUse runs beneath, as ever—
5output 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 keptreturns the result untouched

Each decision and failure is one ModPolicy line in the session log.

The merge gate (D4)

A subagent never merges. From the main thread a merge passes only:

  1. its target named: -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;
  2. pinned to the head it was shown: unpinned, it is refused with the exact command to run (--match-head-commit <sha>, --sha <sha>, -f sha=<sha>, "sha"), nothing rewritten; pinned to another head, refused with the new one;
  3. its CI green, read with gh or glab (15 s, a failure refuses);
  4. the user at the prompt in the last 10 minutes, else "waiting for the user's OK"; then $.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.

The secret veil

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 toolsabsent: tasks are not tracked, the status line shows no epic
triage gate, triage directive, epic state in the prompt, session-start reviewoff: nothing could lift a gate the tools cannot answer
built-in TaskCreate/TodoWriteallowed again: they are the only list left
attribution textClaude Code's own (the shell guard still refuses AI attribution in a commit)
what the user is toldonce 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.

One script per event

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:

StageMeaningExit
1 · gatethe cheapest test that lets most calls leave at once0
2 · blockwhat must stop the call2, reason on stderr
3 · transformwhat Claude reads (context, the task identity of a task_* call), emitted as one JSON document; a Bash command is never rewritten0
4 · observelogging and advice, off the critical path0

Every path that is not a deliberate block exits 0. A PreToolUse hook that fails by accident blocks every shell call of the session.

Events → scripts

EventScriptGateBlockTransformObserve
PreToolUse · Bashon-tool.shno 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 exemptionnone: the input is never rewrittenlog; a main-thread merge is tagged root_guard: merge (refused), merge-ok or merge-exempt
PreToolUse · task toolson-tool.shthe mod's task tools never reach it (a call a function hook answers fires no PreToolUse); the old MCP names are logged and passedbuilt-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/Editon-tool.shmain thread writing inside a git work tree (memory and Claude configuration excepted) · no file paththe 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.shno 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/Editon-tool.shfile absent, markdown, .claude/—format (Makefile fmt/format first, then the formatter for the extension) and say so when the bytes changededited-file tracker · risky construct warning once per session · log
PreToolUse · Agent/Taskon-tool.shgated-model fallback first, every caller, no jq; then the main thread onlya 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 · Skillon-tool.shthe mod does not beata skill whose frontmatter names a gated model——
PostToolUse · Task/Agenton-tool.sh———agent-models.tsv (agent id, resolved model, per-call effort) · log tagged root_guard: dispatch
PostToolUse · SubagentHandbackon-tool.sh———the report's agent feedback, as at SubagentStop
PostToolUse · otheron-tool.sh———log
PostToolUseFailureon-tool.sh———remediation hint · log (error redacted)
SessionStarton-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
SessionEndon-session.sh———one log line with the session's event count (1.5 s budget)
PreCompacton-session.sh———log
ConfigChangeon-session.sh———log · bypassPermissions flagged in security-events.jsonl
UserPromptSubmiton-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 goneraise the triage gate (mod beating) and reset the Stop loop counter, except on a turn the user did not type · log
Notificationon-user.sh——bell (terminalSequence) on idle, permission and elicitation promptslog
SubagentStarton-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
SubagentStopon-agent.shstop_hook_active——running-agents registry · its agent feedback in agent-feedback.jsonl · log
TaskCreated · TaskCompleted · TeammateIdleon-agent.sh———log
Stopon-stop.shstop_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 issuebell · log

Main thread = reviewer, code is delegated

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:

ToolRefusedStill allowed
Write/Edit/MultiEdit/NotebookEdita 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 lexicallyeverything outside a repository; <config>/projects/*/memory/*, <config>/settings.json, settings.local.json, <config>/CLAUDE.md and ~/CLAUDE.md even inside one
Bashgit 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 classifiedreads, merges, reviews, comments, issues (decided in the jq-free fast path)

The shell reader

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

Source 20 files
hooks/mod/register.ts 902 lines
1// 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};
902
hooks/mod/tasks.ts 1193 lines
1// 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];
1193
hooks/mod/store.ts 128 lines
1// 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}
128
hooks/mod/lint.ts 340 lines
1// 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}
340
hooks/mod/calls.ts 135 lines
1// 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}
135
hooks/mod/paths.ts 51 lines
1// 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)}`;
51
hooks/mod/machine-guards.ts 249 lines
1// 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}
249
hooks/mod/shell.ts 620 lines
1// 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}
620
hooks/mod/guards.ts 925 lines
1// 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}
925
hooks/mod/patterns.ts 146 lines
1// 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}
146
hooks/mod/merge.ts 777 lines
1// 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`;
777
hooks/mod/blast.ts 49 lines
1// 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