diff --git a/tools/subagent-prompt-prefix.mjs b/tools/subagent-prompt-prefix.mjs new file mode 100644 index 00000000..b4da60c6 --- /dev/null +++ b/tools/subagent-prompt-prefix.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * PreToolUse hook — git-safety header inject for Task tool. + * + * For каждый Task-инвокейшн контроллера: дописывает в начало tool_input.prompt + * заголовок с cwd / branch / parent SHA / worktree-root + правилами поведения + * (rule 1–5). Это компенсирует класс инцидентов «субагент путает ветку/worktree» + * (Sprint 6, Pravila §15.1). + * + * FAIL-OPEN: любая ошибка / тайм-аут / git-не-в-PATH → {continue: true} без + * модификации; хук НИКОГДА не блокирует Task. + * + * Non-Task tools — pass-through без модификации. + * + * Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md §4 + * Rule: docs/Pravila_raboty_Claude_v1_1.md §15.1 + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); +const GIT_TIMEOUT_MS = 1500; + +/** Read all stdin into a string. */ +async function readStdin() { + let buf = ''; + for await (const chunk of process.stdin) buf += chunk; + return buf; +} + +/** Parse hook input JSON; return null on any parse error. */ +function parseHookInput(raw) { + try { return JSON.parse(raw); } catch { return null; } +} + +/** Run a single fixed-args git command; return trimmed stdout, or null on any error. */ +async function gitCmd(args) { + try { + const { stdout } = await execFileP('git', args, { timeout: GIT_TIMEOUT_MS, encoding: 'utf8' }); + return stdout.trim(); + } catch { + return null; + } +} + +/** Build the safety header block. Returns null if any of the 4 git values can't be resolved. */ +async function buildHeader() { + const cwd = process.cwd(); + const [branch, head, top] = await Promise.all([ + gitCmd(['branch', '--show-current']), + gitCmd(['rev-parse', 'HEAD']), + gitCmd(['rev-parse', '--show-toplevel']), + ]); + if (!branch || !head || !top) return null; + return [ + '=== SUBAGENT GIT-SAFETY HEADER (Pravila §15.1, auto-injected) ===', + `Working directory (cwd): ${cwd}`, + `Branch (git branch --show-current): ${branch}`, + `Parent commit (git rev-parse HEAD): ${head}`, + `Worktree root (git rev-parse --show-toplevel): ${top}`, + '', + 'ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА:', + '1. Все git-операции выполняй ТОЛЬКО внутри cwd выше. Если pwd ≠ cwd — STOP, верни ошибку.', + '2. ЗАПРЕЩЕНО: `git checkout `, `git switch `, `git checkout -b`, `git branch -m`, `git worktree add/remove`, `git push --force`, `git reset --hard`.', + '3. После КАЖДОГО `git commit` — выполни `git rev-parse HEAD` и `git branch --show-current`, выпиши результат в ответ. Это обязательная часть отчёта.', + '4. Если обнаружил, что находишься не в той ветке/worktree — STOP, не правь ничего, верни ошибку с raw output `git status` и `git branch --show-current`.', + '5. Если задача не требует git-операций (только Edit/Read/Grep) — этот блок информационный, follow rule 1.', + '', + '=== END SUBAGENT GIT-SAFETY HEADER ===', + '', + ].join('\n'); +} + +/** Emit fail-open response and exit 0. */ +function failOpen() { + process.stdout.write(JSON.stringify({ continue: true })); + process.exit(0); +} + +async function main() { + const raw = await readStdin(); + const input = parseHookInput(raw); + if (!input) return failOpen(); + + // Non-Task — pass-through + if (input.tool_name !== 'Task') return failOpen(); + + const originalPrompt = input.tool_input?.prompt; + if (typeof originalPrompt !== 'string') return failOpen(); + + const header = await buildHeader(); + if (!header) { + // git unavailable / not in worktree — fail-open per spec §4.5 edge-case 3 + process.stderr.write('[subagent-prompt-prefix] git resolution failed — passing through\n'); + return failOpen(); + } + + const newPrompt = header + originalPrompt; + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + permissionDecisionReason: 'git-safety header injected (Pravila §15.1)', + updatedInput: { prompt: newPrompt }, + }, + })); +} + +main().catch(() => failOpen());