Mechanically enforces the Interaction axis at turn end (one AskUserQuestion re-prompt), blocks content operations on non-UTF-8 files with an encoding hint…

/plugin install coding-toolbox@kwitsch-plugins
| Skill | What it does |
|---|---|
fresh-branch | Refresh the current branch onto its default base with no arguments (in any context), or cut a new branch off an optional custom base outside a worktree, or refresh onto an explicit base inside a linked git worktree. Auto-stashes/pops uncommitted changes around the operation. |
fresh-pr | Commit pending work, rebase onto an updated base, push, and open or refresh a PR/MR (GitHub and GitLab) — then drive it to CI-green (and, if CodeRabbit participates, all review threads resolved) via bundled ci-watcher/pr-fixer agents. |
finish-pr | Finalize an existing PR/MR for the current branch before merge — abort if none exists, rebase it onto its base and force-push if the base has moved ahead, undraft it, enable GitLab's delete-source-branch-on-merge if off, and reconcile its title/description against the actual diff. |
fresh-work | Classify a unit of work (fix/refactor/feature) from a one-line description, cut a branch via fresh-branch, dispatch to the matching pipeline skill (debugging or feature-development), then finish via fresh-pr. Fully self-contained. |
feature-development | Fresh-work's design-path pipeline for both feature and refactor work (they share an identical pipeline; only the branch prefix differs): design + plan as session temp files (always self-reviewed; consult an advisor on their own judgment call), an AskUserQuestion intent-confirmation checkpoint between them, implement task-by-task via workflow-driven development (Workflow tool, Agent fallback), a combined verified review workflow (correctness angles + per-lens cleanup finders, effort scaled to diff complexity) before returning to fresh-work. |
debugging | Fresh-work's fix-path pipeline: root cause before any fix, a failing test first, a single targeted change, then verify — all on the branch fresh-work already cut. Invoked by fresh-work; assumes a work branch already exists. |
bump-version | Bump a project's semantic version (major/minor/patch) in its detected version file (.claude-plugin/plugin.json — a Claude Code plugin, e.g. inside a plugin-marketplace repo — then package.json, composer.json, pom.xml, or VERSION) and sync the matching lock file (npm/composer) when present. No git operations. |
setup-rules | Install, refresh, or remove coding-toolbox's user-level rules — a golden-rules copy and a tool-routing table for rtk/bun/rg/codebase-memory-mcp — as always-on ~/.claude/rules/coding-toolbox-*.md files (every project on this machine), via one AskUserQuestion call with a single-select question per rule showing its current install state, or non-interactively via a verbatim argument (e.g. /coding-toolbox:setup-rules update tools rule). User-only — not model-invocable. |
refresh-tools-rule | Refresh the tool-routing rule from current PATH detection, but only if it's already installed — never creates or removes it. Model-invocable (unlike setup-rules) precisely because it's non-destructive; memory-enhancement's dream skill uses this to keep the rule current across sessions. |
setup-explore | Install a user-level ~/.claude/agents/explore.md, choosing between a plain and a codebase-memory-mcp-aware variant based on whether that MCP server is detected (command -v codebase-memory-mcp) on this machine. User-only — not model-invocable. |
dispatch-agent | Hand off an independent task to a new, worktree-isolated background Claude Code session (claude --worktree ... --bg, managed via claude agents) started from the repo's default branch on origin (or this session's HEAD when worktree.baseRef is "head"), passing through the given prompt. Accepts optional --model=/--effort= overrides (default sonnet/xhigh); always runs with --permission-mode auto. |
| Agent | Model | Role |
|---|---|---|
ci-watcher | sonnet | read-only: watches CI via bin/ci-watch.sh, collects open CodeRabbit PR threads (including any attached AI-agent fix prompt) |
pr-fixer | opus | verifies CI/CodeRabbit findings against the code, applies justified fixes, commits (never pushes), always annotates skipped findings in code |
Mechanically enforces the Interaction axis on every turn and blocks non-UTF-8 file operations — both automatic, zero-setup. The full "golden behavior rules" document (covering all four axes) is available as an opt-in user-level rule via the setup-rules skill, rather than injected automatically.
The rules document is written in cavemem's compressed-English notation and combines four axes — three sourced from external repos, one (Interaction) plugin-original:
| Axis | Source | Contributes |
|---|---|---|
| Interaction | plugin-original (no external source) | every user interaction via AskUserQuestion; no ask-and-wait |
| Language | cavemem docs/compression.md | write compactly; preserve technical tokens |
| Behavior | andrej-karpathy-skills CLAUDE.md | think → simplify → surgical → verify |
| Mentality | ponytail-lite AGENTS.md | lazy senior dev; YAGNI; prefer deletion |
AskUserQuestion instead (at most once per turn you start).Read, Edit, Write and Bash operations that read or modify file content, detects the target file's encoding and denies the call when the file is not UTF-8 — naming the detected encoding and a concrete iconv alternative. Precision-biased and fail-open: quoted/substituted paths, unknown tools, binary and missing files always pass; encoding-safe tools (iconv, git, mv, …) are never blocked.EnterWorktree creates a new worktree, fetches and rebases it onto the repo's default branch on origin; a fetch failure or an (aborted) rebase conflict is reported to Claude. Toggle: worktree_refresh (default true).All three run in-process as a mods hooks module (no extra processes). The interaction gate and encoding guard need no setup. Run /coding-toolbox:setup-rules once per machine to opt into the full golden-rules document and a tool-routing table as persistent ~/.claude/rules/*.md files, applying to every project you open here.
hooks/register.ts 87 lines1import type { EngineInterface, Register } from "claude-code";
2import {
3 FULL_READ_MAX,
4 GATE_REASON,
5 GIT_TIMEOUT_MS,
6 SAMPLE_BYTES,
7 collectOffenders,
8 decodeBase64,
9 denyReason,
10 isBareQuestion,
11 isGateTurn,
12 isWorktreeRefreshEnabled,
13 refreshWorktree,
14} from "./policy.mjs";
15
16// The main-loop turn the gate's own re-prompt started: never gated again, so at most one re-prompt per user turn.
17// Module state: a hot reload forgets it, which at worst lets one more re-prompt through.
18let gateTurnId: string | undefined;
19// Backstop for the text match: the next main-loop turn after a submit counts as the gate's, so a changed framing
20// can never loop. A user prompt that starts first takes the exemption instead: at worst one missed gate.
21let gatePending = false;
22
23// The engine refuses $.<noun> as a bare value, so same-file helpers take the whole $.
24// The first min(size, SAMPLE_BYTES) bytes of a file: whole-file read up to FULL_READ_MAX, `head -c` beyond.
25const sample = async ($: EngineInterface, path: string, size: number): Promise<Uint8Array> => {
26 if (size <= FULL_READ_MAX) {
27 const read = await $.fs.read(path, { as: "bytes" });
28 if (typeof read === "string") throw new Error("fs.read answered text");
29 return decodeBase64(read.base64).subarray(0, SAMPLE_BYTES);
30 }
31 const r = await $.process.run(["sh", "-c", `head -c ${SAMPLE_BYTES} "$1" | base64`, "sh", path]);
32 if (r.exitCode !== 0) throw new Error(r.stderr);
33 return decodeBase64(r.stdout);
34};
35
36export const register: Register = (on, options) => {
37 // Encoding guard. Fail open: a throwing hook is skipped and the tool runs, so no .catch.
38 on("tool.call", { tool: ["Read", "Edit", "Write", "Bash"] }, async ($, e, next) => {
39 const cwd = await $.session.cwd();
40 const home = await $.env.get("HOME");
41 const offenders = await collectOffenders(e, cwd, home, {
42 stat: (path) => $.fs.stat(path),
43 sample: (path, size) => sample($, path, size),
44 });
45 return offenders.length > 0 ? { deny: denyReason(offenders) } : next(e);
46 });
47
48 // Worktree refresh, after EnterWorktree created a new worktree (a `path` call switches into an existing one).
49 if (isWorktreeRefreshEnabled(options.worktree_refresh)) {
50 on("tool.call", { tool: "EnterWorktree" }, async ($, e, next) => {
51 const r = await next(e);
52 if (r.deny !== undefined || r.isError || ("path" in e && e.path)) return r;
53 const reported = (r.result as { worktreePath?: unknown } | undefined)?.worktreePath;
54 const cwd = typeof reported === "string" && reported !== "" ? reported : await $.session.cwd();
55 const note = await refreshWorktree(cwd, {
56 git: (dir, args) => $.process.run(["git", ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }),
57 realPath: async (path) => {
58 const st = await $.fs.stat(path, { resolve: true });
59 if (st.realPath === undefined) throw new Error(`cannot resolve ${path}`);
60 return st.realPath;
61 },
62 });
63 return note === undefined ? r : { ...r, context: [...(r.context ?? []), note] };
64 });
65 }
66
67 // Interaction gate: Stop has no native block for a user-tier mod, so a main-loop answer ending in a bare
68 // question gets one framed plugin prompt that starts a new turn telling Claude to redo it via AskUserQuestion.
69 on("turn.start", ($, e, next) => {
70 if (e.agentId === undefined) {
71 gateTurnId = gatePending || isGateTurn(e.text) ? e.turnId : undefined;
72 gatePending = false;
73 }
74 return next(e);
75 });
76 on("turn.complete", ($, e, next) => {
77 if (e.agentId === undefined && e.reason === "answer" && e.turnId !== gateTurnId && isBareQuestion(e.answer)) {
78 gatePending = true;
79 // Not awaited: it resolves only once the new turn starts, which waits for this turn to end.
80 $.prompt.submit({ text: GATE_REASON }).catch(() => {
81 gatePending = false;
82 });
83 }
84 return next(e);
85 });
86};
87hooks/policy.mjs 688 lines1// Pure, zero-dep logic behind coding-toolbox's mods hooks module (register.ts).
2// Never references the mods API `$`: register.ts hands every file-system or
3// process effect in through the small `io` objects below, so node:test can drive
4// the same code with node:fs / child_process.
5
6/** @typedef {{path: string, label: string, iconv: string}} Offender */
7/** @typedef {{safe: true} | {safe: false, label: string, iconv: string}} Classification */
8/** @typedef {{kind: string, size: number}} FileStat */
9/**
10 * File access the encoding guard needs. `stat` follows links; `sample` answers
11 * the file's first `min(size, SAMPLE_BYTES)` bytes. Either may throw: the file
12 * then counts as safe (fail open).
13 * @typedef {{stat: (path: string) => Promise<FileStat>, sample: (path: string, size: number) => Promise<Uint8Array>}} GuardIo
14 */
15/** @typedef {{tool: string, file_path?: unknown, command?: unknown}} GuardedCall */
16/** @typedef {{exitCode: number, stdout: string, stderr: string}} RunResult */
17/**
18 * Effects the worktree refresh needs. `git` runs `git <args>` in `cwd` and may
19 * reject (timeout); `realPath` resolves every symbolic link of an absolute path.
20 * @typedef {{git: (cwd: string, args: string[]) => Promise<RunResult>, realPath: (path: string) => Promise<string>}} RefreshIo
21 */
22
23export const SAMPLE_BYTES = 64 * 1024;
24// Files up to this size are read whole (the mods fs read has no range) and sliced;
25// larger ones are sampled through `head -c`, so a huge log never crosses the
26// worker boundary in full.
27export const FULL_READ_MAX = 1024 * 1024;
28const SENTINEL = "\u0001"; // written as an escape: a literal control char is invisible and copy-fragile
29
30// Tools whose file arguments / input redirects consume or transform text
31// content assuming the ambient (UTF-8) encoding. Output-redirect targets are
32// checked regardless of the command word. Everything not listed (iconv,
33// recode, git, mv, cp, rm, ls, cc-tools, ...) passes — unknown tools fail
34// open by contract.
35const CONTENT_TOOLS = new Set([
36 "cat",
37 "head",
38 "tail",
39 "more",
40 "less",
41 "nl",
42 "tac",
43 "rev",
44 "grep",
45 "egrep",
46 "fgrep",
47 "rg",
48 "sed",
49 "awk",
50 "gawk",
51 "mawk",
52 "nawk",
53 "cut",
54 "tr",
55 "sort",
56 "uniq",
57 "paste",
58 "join",
59 "comm",
60 "column",
61 "fold",
62 "fmt",
63 "expand",
64 "unexpand",
65 "strings",
66 "diff",
67 "patch",
68 "tee",
69 "dos2unix",
70 "unix2dos",
71]);
72
73// ---------------------------------------------------------------------------
74// Encoding guard (tool.call on Read|Edit|Write|Bash)
75// ---------------------------------------------------------------------------
76
77/**
78 * Offending files of one guarded tool call: the file a Read/Edit/Write names,
79 * or the literal Bash tokens a content tool reads or a redirect writes, that
80 * classify as non-UTF-8 text. Unknown tools and missing arguments pass.
81 * @param {GuardedCall} call the `tool.call` event (tool arguments spread on it)
82 * @param {string} cwd the session's working directory (absolute)
83 * @param {string | undefined} home `$HOME`, for a leading `~`
84 * @param {GuardIo} io
85 * @returns {Promise<Offender[]>}
86 */
87export async function collectOffenders(call, cwd, home, io) {
88 if (call.tool === "Read" || call.tool === "Edit" || call.tool === "Write") {
89 const fp = call.file_path;
90 if (typeof fp !== "string" || fp === "") return [];
91 const abs = resolvePath(fp, cwd, home);
92 const c = await classifyFile(abs, io);
93 return c.safe ? [] : [{ path: abs, label: c.label, iconv: c.iconv }];
94 }
95 if (call.tool === "Bash") {
96 const cmd = call.command;
97 if (typeof cmd !== "string" || cmd === "") return [];
98 /** @type {Map<string, Offender>} */
99 const found = new Map();
100 for (const tok of bashCandidates(cmd)) {
101 const abs = resolvePath(tok, cwd, home);
102 if (found.has(abs)) continue;
103 const c = await classifyFile(abs, io);
104 if (!c.safe) found.set(abs, { path: abs, label: c.label, iconv: c.iconv });
105 }
106 return [...found.values()];
107 }
108 return [];
109}
110
111/**
112 * Precision-biased Bash command analysis: the clean literal tokens that (a) are
113 * output-redirect targets, or (b) are arguments / input-redirect targets of a
114 * known content tool, in order, duplicates kept (the caller dedups by resolved
115 * path). False negatives are acceptable; false positives are not — non-literal
116 * tokens (quotes, substitutions, variables, globs) and options are dropped and
117 * unknown tools pass (fail open).
118 * @param {string} cmd
119 * @returns {string[]}
120 */
121export function bashCandidates(cmd) {
122 const sanitized = blankNonLiterals(stripHeredocBodies(cmd))
123 .replace(/\d*>&\d*/g, " ") // fd duplication (2>&1) — never a file
124 .replace(/&>>/g, ">>")
125 .replace(/&>/g, ">")
126 .replace(/>\|/g, "> ")
127 .replace(/\|&/g, "|");
128 /** @type {string[]} */
129 const out = [];
130 for (const segment of sanitized.split(/\n|;|\|\||&&|\||&/)) {
131 const { cmdWord, args, outTargets, inTargets } = parseSegment(segment);
132 const candidates = [...outTargets];
133 if (cmdWord !== null && CONTENT_TOOLS.has(cmdWord)) {
134 candidates.push(...args, ...inTargets);
135 }
136 for (const tok of candidates) {
137 if (isLiteralPathToken(tok)) out.push(tok);
138 }
139 }
140 return out;
141}
142
143/**
144 * Remove heredoc body lines (they are data, not commands) while keeping the
145 * operator lines. Supports multiple heredocs queued on one line and `<<-`
146 * tab-stripped closing delimiters; quoted delimiters match their unquoted
147 * form. Here-strings (`<<<`) are not heredocs and are left alone.
148 * @param {string} text
149 * @returns {string}
150 */
151function stripHeredocBodies(text) {
152 /** @type {string[]} */
153 const kept = [];
154 /** @type {{delim: string, stripTabs: boolean}[]} */
155 const queue = [];
156 /** @type {{delim: string, stripTabs: boolean} | null} */
157 let body = null;
158 for (const line of text.split("\n")) {
159 if (body !== null) {
160 const probe = body.stripTabs ? line.replace(/^\t+/, "") : line;
161 if (probe === body.delim) body = queue.shift() ?? null;
162 continue; // body lines and the closing delimiter are dropped
163 }
164 kept.push(line);
165 const re = /(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z0-9_][A-Za-z0-9_-]*)\2/g;
166 let m;
167 while ((m = re.exec(line)) !== null) {
168 queue.push({ delim: m[3], stripTabs: m[1] === "-" });
169 }
170 body = queue.shift() ?? null;
171 }
172 return kept.join("\n");
173}
174
175/**
176 * Blank the contents of quotes, command/process substitutions, backslash
177 * escapes and comments with a sentinel: word boundaries survive, but any
178 * token that contained them is recognizably non-literal and gets skipped.
179 * @param {string} text
180 * @returns {string}
181 */
182function blankNonLiterals(text) {
183 let out = "";
184 let i = 0;
185 const n = text.length;
186 while (i < n) {
187 const c = text[i];
188 const c2 = i + 1 < n ? text[i + 1] : "";
189 if (c === "\\") {
190 out += SENTINEL;
191 i += 2;
192 continue;
193 }
194 if (c === "'") {
195 out += SENTINEL;
196 i = skipPast(text, i + 1, "'");
197 continue;
198 }
199 if (c === "$" && c2 === "'") {
200 out += SENTINEL;
201 i = skipEscaped(text, i + 2, "'");
202 continue;
203 }
204 if (c === '"') {
205 out += SENTINEL;
206 i = skipEscaped(text, i + 1, '"');
207 continue;
208 }
209 if (c === "$" && c2 === "(") {
210 out += SENTINEL;
211 i = skipParens(text, i + 2);
212 continue;
213 }
214 if (c === "`") {
215 out += SENTINEL;
216 i = skipPast(text, i + 1, "`");
217 continue;
218 }
219 if ((c === "<" || c === ">") && c2 === "(") {
220 out += SENTINEL;
221 i = skipParens(text, i + 2);
222 continue;
223 }
224 if (c === "#" && (out === "" || /[\s;|&(]/.test(out[out.length - 1]))) {
225 while (i < n && text[i] !== "\n") i++;
226 continue;
227 }
228 out += c;
229 i += 1;
230 }
231 return out;
232}
233
234/**
235 * @param {string} text
236 * @param {number} from
237 * @param {string} ch
238 * @returns {number} index just past the closing char (or end of string)
239 */
240function skipPast(text, from, ch) {
241 const idx = text.indexOf(ch, from);
242 return idx === -1 ? text.length : idx + 1;
243}
244
245/**
246 * Quote scanner honoring backslash escapes, for "…" and ANSI-C $'…'.
247 * @param {string} text
248 * @param {number} from
249 * @param {string} quote the closing quote character
250 * @returns {number} index just past the closing quote (or end of string)
251 */
252function skipEscaped(text, from, quote) {
253 let i = from;
254 while (i < text.length) {
255 if (text[i] === "\\") {
256 i += 2;
257 continue;
258 }
259 if (text[i] === quote) return i + 1;
260 i += 1;
261 }
262 return i;
263}
264
265/**
266 * Paren scanner with nesting, for $(…), <(…), >(…).
267 * @param {string} text
268 * @param {number} from index just past the opening paren
269 * @returns {number} index just past the matching close (or end of string)
270 */
271function skipParens(text, from) {
272 let depth = 1;
273 let i = from;
274 while (i < text.length && depth > 0) {
275 if (text[i] === "(") depth += 1;
276 else if (text[i] === ")") depth -= 1;
277 i += 1;
278 }
279 return i;
280}
281
282/** @typedef {{cmdWord: string | null, args: string[], outTargets: string[], inTargets: string[]}} ParsedSegment */
283
284/**
285 * Split one pipeline segment into command word (basename, env-assignment
286 * prefixes skipped), plain arguments, and redirect targets. Heredoc (`<<`)
287 * and here-string (`<<<`) operators never produce a file target;
288 * fd-duplication targets (`>&2`) are dropped.
289 * @param {string} segment
290 * @returns {ParsedSegment}
291 */
292function parseSegment(segment) {
293 /** @type {string[]} */ const args = [];
294 /** @type {string[]} */ const outTargets = [];
295 /** @type {string[]} */ const inTargets = [];
296 /** @type {string | null} */ let cmdWord = null;
297 let rtkWrapped = false;
298 /** @type {"out" | "in" | "skip" | null} */ let pending = null;
299 for (const tok of segment.trim().split(/\s+/)) {
300 if (tok === "") continue;
301 if (pending === "out") {
302 outTargets.push(tok);
303 pending = null;
304 continue;
305 }
306 if (pending === "in") {
307 inTargets.push(tok);
308 pending = null;
309 continue;
310 }
311 if (pending === "skip") {
312 pending = null;
313 continue;
314 }
315 const m = tok.match(/^(\d*)(<<<|<<|>>|>|<)(.*)$/);
316 if (m) {
317 const op = m[2];
318 const rest = m[3];
319 if (op === "<<" || op === "<<<") {
320 // heredoc delimiter (<<) or here-string operand (<<<) — never a file
321 // path. Attached operand (rest !== "") is data, dropped here; when
322 // detached (rest === "") the operand is the next token, also skipped.
323 if (rest === "") pending = "skip";
324 continue;
325 }
326 if (rest === "") {
327 pending = op === "<" ? "in" : "out";
328 continue;
329 }
330 if (rest.startsWith("&")) continue; // fd duplication, not a file
331 (op === "<" ? inTargets : outTargets).push(rest);
332 continue;
333 }
334 if (cmdWord === null) {
335 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue; // env prefix
336 const word = tok.slice(tok.lastIndexOf("/") + 1);
337 if (rtkWrapped) {
338 if (word.startsWith("-")) continue; // rtk's own flags before the subcommand
339 cmdWord = word === "read" ? "cat" : word; // `rtk read` is rtk's cat; grep/rg/diff/... keep their names
340 continue;
341 }
342 // rtk (linux-token-efficiency's proxy) wraps the real tool: its mods rewrite may reach this hook first
343 // (`cat X` → `rtk read X`), so the subcommand is the command word.
344 if (word === "rtk") {
345 rtkWrapped = true;
346 continue;
347 }
348 cmdWord = word;
349 continue;
350 }
351 args.push(tok);
352 }
353 return { cmdWord, args, outTargets, inTargets };
354}
355
356/**
357 * A clean literal path token: sentinel residue, `$` variables, options and
358 * glob characters reject it (fail open). Whether it names an existing regular
359 * file is `classifyFile`'s business.
360 * @param {string} tok
361 * @returns {boolean}
362 */
363function isLiteralPathToken(tok) {
364 if (tok === "" || tok.includes(SENTINEL) || tok.includes("$")) return false;
365 if (tok.startsWith("-")) return false; // option
366 return !/[*?[\]{}]/.test(tok); // glob — expansion unknown
367}
368
369/**
370 * POSIX `path.resolve(cwd, p)` with a leading `~` expanded to `home`.
371 * @param {string} p
372 * @param {string} cwd absolute
373 * @param {string | undefined} home
374 * @returns {string}
375 */
376export function resolvePath(p, cwd, home) {
377 let expanded = p;
378 if (home && (p === "~" || p.startsWith("~/"))) expanded = home + p.slice(1);
379 const joined = expanded.startsWith("/") ? expanded : `${cwd}/${expanded}`;
380 /** @type {string[]} */
381 const parts = [];
382 for (const seg of joined.split("/")) {
383 if (seg === "" || seg === ".") continue;
384 if (seg === "..") parts.pop();
385 else parts.push(seg);
386 }
387 return `/${parts.join("/")}`;
388}
389
390/**
391 * Classify a file by its first 64 KiB. Missing files, non-files, empty files
392 * and anything unreadable are safe (fail open).
393 * @param {string} filePath absolute
394 * @param {GuardIo} io
395 * @returns {Promise<Classification>}
396 */
397export async function classifyFile(filePath, io) {
398 try {
399 const st = await io.stat(filePath);
400 if (st.kind !== "file" || st.size === 0) return { safe: true };
401 const buf = await io.sample(filePath, st.size);
402 const got = Math.min(buf.length, SAMPLE_BYTES);
403 return classifyBytes(buf.subarray(0, got), got < st.size);
404 } catch {
405 return { safe: true };
406 }
407}
408
409/**
410 * BOM sniff → NUL analysis (UTF-16 parity heuristic vs. binary) → strict
411 * UTF-8 validation → legacy single-byte fallback. NUL analysis MUST run
412 * before UTF-8 validation: NUL is a *valid* UTF-8 byte, so ASCII-content
413 * UTF-16 without a BOM would otherwise pass as UTF-8 and slip through.
414 * @param {Uint8Array} buf
415 * @param {boolean} truncated true when the sample is a prefix of a larger file
416 * @returns {Classification}
417 */
418export function classifyBytes(buf, truncated) {
419 if (startsWith(buf, [0xff, 0xfe, 0x00, 0x00])) return unsafe("UTF-32LE", "UTF-32LE");
420 if (startsWith(buf, [0x00, 0x00, 0xfe, 0xff])) return unsafe("UTF-32BE", "UTF-32BE");
421 if (startsWith(buf, [0xef, 0xbb, 0xbf])) return { safe: true }; // UTF-8 BOM round-trips
422 if (startsWith(buf, [0xff, 0xfe])) return unsafe("UTF-16LE", "UTF-16LE");
423 if (startsWith(buf, [0xfe, 0xff])) return unsafe("UTF-16BE", "UTF-16BE");
424 let nuls = 0;
425 let evenNuls = 0;
426 for (let i = 0; i < buf.length; i++) {
427 if (buf[i] === 0) {
428 nuls++;
429 if (i % 2 === 0) evenNuls++;
430 }
431 }
432 if (nuls > 0) {
433 // ASCII-heavy UTF-16 puts its NUL high bytes on one parity side:
434 // LE = odd indexes, BE = even indexes. Require a clear skew so
435 // NUL-bearing binary data (images, archives) stays "binary" → safe.
436 const evenRatio = evenNuls / nuls;
437 if (nuls / buf.length >= 0.3 && (evenRatio >= 0.7 || evenRatio <= 0.3)) {
438 return evenRatio >= 0.7 ? unsafe("UTF-16BE (no BOM)", "UTF-16BE") : unsafe("UTF-16LE (no BOM)", "UTF-16LE");
439 }
440 return { safe: true };
441 }
442 if (isValidUtf8(buf, truncated)) return { safe: true };
443 return unsafe("ISO-8859-1/Windows-1252 (legacy single-byte)", "WINDOWS-1252");
444}
445
446/**
447 * @param {string} label
448 * @param {string} iconv
449 * @returns {Classification}
450 */
451function unsafe(label, iconv) {
452 return { safe: false, label, iconv };
453}
454
455/**
456 * @param {Uint8Array} buf
457 * @param {number[]} bytes
458 * @returns {boolean}
459 */
460function startsWith(buf, bytes) {
461 if (buf.length < bytes.length) return false;
462 return bytes.every((b, i) => buf[i] === b);
463}
464
465/**
466 * Strict UTF-8 validation (overlongs, surrogates and >U+10FFFF rejected). A
467 * multibyte sequence cut off at the end of a truncated sample is tolerated —
468 * that is a sampling artifact, not an encoding error.
469 * @param {Uint8Array} buf
470 * @param {boolean} truncated
471 * @returns {boolean}
472 */
473function isValidUtf8(buf, truncated) {
474 const n = buf.length;
475 let i = 0;
476 while (i < n) {
477 const b = buf[i];
478 if (b < 0x80) {
479 i += 1;
480 continue;
481 }
482 let need;
483 if (b >= 0xc2 && b <= 0xdf) need = 1;
484 else if (b >= 0xe0 && b <= 0xef) need = 2;
485 else if (b >= 0xf0 && b <= 0xf4) need = 3;
486 else return false;
487 if (i + need >= n) return truncated; // sequence runs past the sample end
488 const c1 = buf[i + 1];
489 if ((c1 & 0xc0) !== 0x80) return false;
490 if (b === 0xe0 && c1 < 0xa0) return false; // overlong
491 if (b === 0xed && c1 > 0x9f) return false; // surrogate half
492 if (b === 0xf0 && c1 < 0x90) return false; // overlong
493 if (b === 0xf4 && c1 > 0x8f) return false; // above U+10FFFF
494 for (let k = 2; k <= need; k++) {
495 if ((buf[i + k] & 0xc0) !== 0x80) return false;
496 }
497 i += need + 1;
498 }
499 return true;
500}
501
502/**
503 * The deny reason: every offending file with its detected encoding, and one
504 * concrete iconv command line.
505 * @param {Offender[]} offenders non-empty
506 * @returns {string}
507 */
508export function denyReason(offenders) {
509 const list = offenders.map((o) => `${o.path} (${o.label})`).join(", ");
510 const first = offenders[0];
511 return (
512 `encoding-guard: ${list} ${offenders.length === 1 ? "is" : "are"} not UTF-8. ` +
513 "Native Read/Edit/Write and plain shell text tools assume UTF-8 and would misread or corrupt the content. " +
514 `Read safely with: iconv -f ${first.iconv} -t UTF-8 '${first.path}'. ` +
515 `To modify: convert to UTF-8, edit, convert back with iconv -f UTF-8 -t ${first.iconv} — or use another encoding-preserving tool.`
516 );
517}
518
519/**
520 * Decode base64 (whitespace tolerated, as `atob` strips it) to bytes.
521 * @param {string} b64
522 * @returns {Uint8Array}
523 */
524export function decodeBase64(b64) {
525 const bin = atob(b64.replace(/\s+/g, ""));
526 const out = new Uint8Array(bin.length);
527 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
528 return out;
529}
530
531// ---------------------------------------------------------------------------
532// Interaction gate (turn.complete)
533// ---------------------------------------------------------------------------
534
535// The prompt the gate submits. The turn it starts is recognized by this text,
536// so it never re-prompts its own retry (the loop guard).
537export const GATE_REASON =
538 "Interaction rule violation: the final response ends with a plain-text question to the user. Route it through the AskUserQuestion tool instead — no exceptions, not even a casual yes/no offer.";
539
540/**
541 * True when the last non-empty line outside fenced code blocks ends in `?` —
542 * the Interaction axis's "never a bare question to the user" anti-pattern.
543 * A rhetorical trailing `?` is an accepted false positive.
544 * @param {string} answer the turn's final visible text
545 * @returns {boolean}
546 */
547export function isBareQuestion(answer) {
548 const lines = answer
549 .replace(/```[\s\S]*?```/g, "")
550 .split("\n")
551 .map((l) => l.trim())
552 .filter(Boolean);
553 return /\?\s*$/.test(lines[lines.length - 1] ?? "");
554}
555
556/**
557 * True when a turn's prompt is the gate's own re-prompt (framed by the engine
558 * as a plugin message, or bare): such a turn is never gated again.
559 * @param {string} text `turn.start`'s text
560 * @returns {boolean}
561 */
562export function isGateTurn(text) {
563 return text.includes(GATE_REASON);
564}
565
566// ---------------------------------------------------------------------------
567// Worktree refresh (tool.call on EnterWorktree, after the tool ran)
568// ---------------------------------------------------------------------------
569
570export const GIT_TIMEOUT_MS = 30_000;
571
572/**
573 * Fail-open toggle: only a literal false (boolean or string) disables.
574 * @param {unknown} value `options.worktree_refresh`
575 * @returns {boolean}
576 */
577export function isWorktreeRefreshEnabled(value) {
578 return value !== false && value !== "false";
579}
580
581/**
582 * After EnterWorktree created a new worktree at `cwd`: fetch and rebase it onto
583 * the repo's default branch on origin. Answers the note for the model on a
584 * fetch failure or an aborted rebase conflict, undefined on success and on
585 * every non-actionable case (not a linked worktree, no remote / offline).
586 * @param {string} cwd the new worktree (absolute)
587 * @param {RefreshIo} io
588 * @returns {Promise<string | undefined>}
589 */
590export async function refreshWorktree(cwd, io) {
591 if (!(await isLinkedWorktree(cwd, io))) return undefined; // defensive: not (yet) inside a linked worktree
592 const base = await detectDefaultBranch(cwd, io);
593 if (!base) return undefined; // no remote / offline — nothing to refresh against
594
595 const fetchErr = await gitError(cwd, ["fetch", "origin", base], io);
596 if (fetchErr !== undefined) {
597 return `worktree-refresh: fetch of origin/${base} failed — this worktree may not have the latest upstream changes (${fetchErr})`;
598 }
599 const rebaseErr = await gitError(cwd, ["rebase", `origin/${base}`], io);
600 if (rebaseErr !== undefined) {
601 await gitError(cwd, ["rebase", "--abort"], io); // best effort: never leave the worktree mid-rebase
602 // eslint-disable-next-line max-len -- long line is the literal message string
603 return `worktree-refresh: rebase onto origin/${base} conflicted and was aborted — the worktree was left as created (not refreshed). Run 'git rebase origin/${base}' manually in this worktree if you need the latest upstream changes. (${rebaseErr})`;
604 }
605 return undefined; // success — silent, no need to report the common case
606}
607
608/**
609 * Runs git; answers its trimmed stdout, or throws with its one-line diagnostic.
610 * @param {string} cwd
611 * @param {string[]} args
612 * @param {RefreshIo} io
613 * @returns {Promise<string>}
614 */
615async function git(cwd, args, io) {
616 const r = await io.git(cwd, args);
617 if (r.exitCode !== 0) throw new Error(firstLine(r.stderr) || `git ${args[0]} exited ${r.exitCode}`);
618 return r.stdout.trim();
619}
620
621/**
622 * Runs git for its effect; answers undefined on success, else its one-line
623 * diagnostic (a timeout's rejection message included).
624 * @param {string} cwd
625 * @param {string[]} args
626 * @param {RefreshIo} io
627 * @returns {Promise<string | undefined>}
628 */
629async function gitError(cwd, args, io) {
630 try {
631 await git(cwd, args, io);
632 return undefined;
633 } catch (e) {
634 return firstLine(e instanceof Error ? e.message : String(e));
635 }
636}
637
638/**
639 * The first non-blank line, bare CRs (progress output) collapsed.
640 * @param {string} text
641 * @returns {string}
642 */
643export function firstLine(text) {
644 return (text.split("\n").find((l) => l.trim()) ?? "").replace(/\r+/g, " ").trim();
645}
646
647// Same inode-safe comparison as fresh-branch's script (resolved paths, not the
648// raw strings: from inside a worktree --git-dir is absolute while
649// --git-common-dir can be relative to the TARGET cwd, so resolve it there).
650/**
651 * @param {string} cwd
652 * @param {RefreshIo} io
653 * @returns {Promise<boolean>}
654 */
655async function isLinkedWorktree(cwd, io) {
656 try {
657 const gitDir = await io.realPath(resolvePath(await git(cwd, ["rev-parse", "--git-dir"], io), cwd, undefined));
658 const commonDir = await io.realPath(resolvePath(await git(cwd, ["rev-parse", "--git-common-dir"], io), cwd, undefined));
659 return gitDir !== commonDir;
660 } catch {
661 return false;
662 }
663}
664
665// Mirrors fresh-branch's detect_default(): prefer the cached origin/HEAD symlink,
666// refreshed once; fall back to parsing `git remote show origin`.
667/**
668 * @param {string} cwd
669 * @param {RefreshIo} io
670 * @returns {Promise<string | null>}
671 */
672async function detectDefaultBranch(cwd, io) {
673 await gitError(cwd, ["remote", "set-head", "origin", "--auto"], io); // best effort
674 try {
675 const ref = await git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], io);
676 if (ref) return ref.replace(/^origin\//, "");
677 } catch {
678 /* fall through */
679 }
680 try {
681 const m = (await git(cwd, ["remote", "show", "origin"], io)).match(/HEAD branch:\s*(\S+)/);
682 if (m) return m[1];
683 } catch {
684 /* no remote / offline */
685 }
686 return null;
687}
688