#!/usr/bin/env node /** * PreToolUse hook — git-safety header inject for Task tool + subagent gate inheritance. * * For каждый Task-инвокейшн контроллера: дописывает в начало tool_input.prompt * заголовок с cwd / branch / parent SHA / worktree-root + правилами поведения * (rule 1–5). Это компенсирует класс инцидентов «субагент путает ветку/worktree» * (Sprint 6, Pravila §15.1). * * v4 Stream E (§3.2): дополнительно пишет subagent inheritance-файл + 256-bit * parent sentinel в ~/.claude/runtime/restricted/ и сообщает inheritance env vars * в инжектируемом заголовке. Всё best-effort — fail-open. * * 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 * docs/superpowers/specs/2026-05-29-router-gate-v4-design.md §3.2 * Rule: docs/Pravila_raboty_Claude_v1_1.md §15.1 */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { mkdirSync, writeFileSync } from 'node:fs'; const execFileP = promisify(execFile); const GIT_TIMEOUT_MS = 1500; /** Runtime dir (~/.claude/runtime). */ function runtimeDir() { return join(homedir(), '.claude', 'runtime'); } /** 256-bit random hex id for the parent sentinel (§3.2). */ export function generateParentRandomId() { return randomBytes(32).toString('hex'); } /** Path to the per-Task inheritance file. */ export function inheritanceFilePath(toolUseId) { return join(runtimeDir(), `subagent-inheritance-${toolUseId || 'unknown'}.json`); } /** Path to the parent sentinel (restricted/ — Read+Edit blocked per §3.1). */ export function parentSentinelPath(parentRandomId) { return join(runtimeDir(), 'restricted', `parent-sentinel-${parentRandomId || 'unknown'}.json`); } /** Path to the subagent block-file (restricted/ — S5 side-channel, §3.4). */ export function subagentBlockPath(toolUseId) { return join(runtimeDir(), 'restricted', `subagent-block-${toolUseId || 'unknown'}.json`); } /** Build the subagent inheritance record (schema_version 3, §3.2). */ export function buildInheritanceRecord({ parentSessionId, parentRandomId, allowedActions, nowIso }) { return { schema_version: 3, parent_session_id: parentSessionId || 'unknown', parent_random_id: parentRandomId || '', allowed_actions: Array.isArray(allowedActions) ? allowedActions : [], subagent_constraints: { can_use_askuser: false, can_spawn_task: false, max_parallel: 1, }, created_at: nowIso || new Date().toISOString(), }; } /** Build inheritance env vars passed to the subagent (§3.2 step 2). */ export function buildInheritanceEnv({ parentSessionId, inheritanceFile }) { return { CLAUDE_PARENT_SESSION_ID: parentSessionId || 'unknown', CLAUDE_GATE_INHERIT: 'true', CLAUDE_INHERITANCE_FILE: inheritanceFile || '', }; } /** * Stream H Task 10 — pure helper: detect if cwd is inside a linked worktree. * * Logic: when git's per-worktree dir (`git rev-parse --git-dir`) differs from * the shared common dir (`git rev-parse --git-common-dir`), we're in a * linked worktree. The parent repo root is the parent directory of the * common .git dir (with separators normalized to forward slashes). * * @param {object} args * @param {string} [args.cwd] * @param {string} [args.gitDir] - `git rev-parse --git-dir` output * @param {string} [args.gitCommonDir] - `git rev-parse --git-common-dir` output * @returns {{isWorktree: boolean, parentRepoRoot: string|null}} */ export function detectWorktreeMode({ cwd, gitDir, gitCommonDir } = {}) { const norm = (s) => (typeof s === 'string' ? s.replace(/\\/g, '/') : null); const gd = norm(gitDir); const gcd = norm(gitCommonDir); if (!gd || !gcd || gd === gcd) return { isWorktree: false, parentRepoRoot: null }; // Common dir looks like `/.git` — strip the trailing `/.git` segment. const parent = gcd.replace(/\/\.git\/?$/, ''); if (!parent || parent === gcd) return { isWorktree: false, parentRepoRoot: null }; return { isWorktree: true, parentRepoRoot: parent }; } /** * Stream H Task 10 — pure helper: build SETUP — worktree bootstrap text for * the injected subagent header. Empty string when not in a worktree OR * parentRepoRoot is missing. Per memory `feedback_subagent_worktree_bootstrap.md`. * * @param {object} args * @param {boolean} args.isWorktree * @param {string|null} args.parentRepoRoot * @param {string} [args.platform] - default process.platform; 'win32' uses mklink, others use ln -s * @returns {string} block text (joined with \n) or '' to omit */ export function buildSetupBlock({ isWorktree, parentRepoRoot, platform = process.platform } = {}) { if (!isWorktree || !parentRepoRoot) return ''; const isWin = platform === 'win32'; const symlinkCmd = isWin ? `mklink /D vendor "${parentRepoRoot}/app/vendor"` : `ln -s "${parentRepoRoot}/app/vendor" vendor`; return [ '', 'SETUP — worktree bootstrap (run inside app/ before composer/pest if needed):', ` 1. ${symlinkCmd}`, ' 2. mkdir -p storage/framework/{cache,sessions,views,testing}', ' 3. Re-run pest / composer commands; Eloquent facade and view cache paths now resolve.', '', ].join('\n'); } /** 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, gitDir, gitCommonDir] = await Promise.all([ gitCmd(['branch', '--show-current']), gitCmd(['rev-parse', 'HEAD']), gitCmd(['rev-parse', '--show-toplevel']), gitCmd(['rev-parse', '--git-dir']), gitCmd(['rev-parse', '--git-common-dir']), ]); if (!branch || !head || !top) return null; const worktreeInfo = detectWorktreeMode({ cwd, gitDir, gitCommonDir }); const setupBlock = buildSetupBlock(worktreeInfo); 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.', setupBlock, '=== END SUBAGENT GIT-SAFETY HEADER ===', '', ].join('\n'); } /** Write inheritance file + parent sentinel (best-effort). Returns env note string or ''. */ function writeInheritanceArtifacts(input) { try { const toolUseId = input.tool_use_id || input.toolUseId || `task-${Date.now()}`; const parentSessionId = input.session_id || process.env.CLAUDE_SESSION_ID || 'unknown'; const parentRandomId = generateParentRandomId(); mkdirSync(join(runtimeDir(), 'restricted'), { recursive: true }); const infFile = inheritanceFilePath(toolUseId); const record = buildInheritanceRecord({ parentSessionId, parentRandomId }); writeFileSync(infFile, JSON.stringify(record, null, 2)); writeFileSync(parentSentinelPath(parentRandomId), JSON.stringify({ parent_session_id: parentSessionId, created_at: record.created_at, }, null, 2)); const env = buildInheritanceEnv({ parentSessionId, inheritanceFile: infFile }); return [ '', 'GATE INHERITANCE (router-gate v4 §3.2):', ` CLAUDE_GATE_INHERIT=${env.CLAUDE_GATE_INHERIT}`, ` CLAUDE_PARENT_SESSION_ID=${env.CLAUDE_PARENT_SESSION_ID}`, ` CLAUDE_INHERITANCE_FILE=${env.CLAUDE_INHERITANCE_FILE}`, '', ].join('\n'); } catch { return ''; // fail-open: inheritance write is best-effort } } /** 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 inheritanceEnvNote = writeInheritanceArtifacts(input); const newPrompt = header + inheritanceEnvNote + originalPrompt; process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'git-safety header injected (Pravila §15.1)', updatedInput: { prompt: newPrompt }, }, })); } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/subagent-prompt-prefix.mjs'); if (isCli) main().catch(() => failOpen());