Files
brain/tools/subagent-prompt-prefix.mjs
T

257 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 `<parent>/.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 <branch>`, `git switch <branch>`, `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());