feat(hooks): subagent-prompt-prefix — PreToolUse git-safety inject (TDD green)
Per Pravila §15.1 — инжектит cwd/branch/HEAD/worktree-root + правила поведения в каждый Task-prompt. FAIL-OPEN на любой ошибке (git не в PATH, malformed stdin, non-Task tools). Все 5 тестов из subagent-prompt-prefix.test.mjs PASS. Регистрация в .claude/settings.json — Task 6 плана. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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.',
|
||||
'',
|
||||
'=== 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());
|
||||
Reference in New Issue
Block a user