2026-05-18 10:17:04 +03:00
#!/usr/bin/env node
/**
2026-05-29 20:15:12 +03:00
* PreToolUse hook — git-safety header inject for Task tool + subagent gate inheritance.
2026-05-18 10:17:04 +03:00
*
* For каждый Task-инвокейшн контроллера: дописывает в начало tool_input.prompt
* заголовок с cwd / branch / parent SHA / worktree-root + правилами поведения
* (rule 1–5). Это компенсирует класс инцидентов «субагент путает ветку/worktree»
* (Sprint 6, Pravila §15.1).
*
2026-05-29 20:15:12 +03:00
* v4 Stream E (§3.2): дополнительно пишет subagent inheritance-файл + 256-bit
* parent sentinel в ~/.claude/runtime/restricted/ и сообщает inheritance env vars
* в инжектируемом заголовке. Всё best-effort — fail-open.
*
2026-05-18 10:17:04 +03:00
* 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
2026-05-29 20:15:12 +03:00
* docs/superpowers/specs/2026-05-29-router-gate-v4-design.md §3.2
2026-05-18 10:17:04 +03:00
* Rule: docs/Pravila_raboty_Claude_v1_1.md §15.1
*/
import { execFile } from 'node:child_process' ;
import { promisify } from 'node:util' ;
2026-05-29 20:15:12 +03:00
import { randomBytes } from 'node:crypto' ;
import { homedir } from 'node:os' ;
import { join } from 'node:path' ;
import { mkdirSync , writeFileSync } from 'node:fs' ;
2026-05-18 10:17:04 +03:00
const execFileP = promisify ( execFile ) ;
const GIT _TIMEOUT _MS = 1500 ;
2026-05-29 20:15:12 +03:00
/** 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 || '' ,
} ;
}
2026-05-30 12:08:33 +03:00
/**
* 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' ) ;
}
2026-05-18 10:17:04 +03:00
/** 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 ( ) ;
2026-05-30 12:08:33 +03:00
const [ branch , head , top , gitDir , gitCommonDir ] = await Promise . all ( [
2026-05-18 10:17:04 +03:00
gitCmd ( [ 'branch' , '--show-current' ] ) ,
gitCmd ( [ 'rev-parse' , 'HEAD' ] ) ,
gitCmd ( [ 'rev-parse' , '--show-toplevel' ] ) ,
2026-05-30 12:08:33 +03:00
gitCmd ( [ 'rev-parse' , '--git-dir' ] ) ,
gitCmd ( [ 'rev-parse' , '--git-common-dir' ] ) ,
2026-05-18 10:17:04 +03:00
] ) ;
if ( ! branch || ! head || ! top ) return null ;
2026-05-30 12:08:33 +03:00
const worktreeInfo = detectWorktreeMode ( { cwd , gitDir , gitCommonDir } ) ;
const setupBlock = buildSetupBlock ( worktreeInfo ) ;
2026-05-18 10:17:04 +03:00
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.' ,
2026-05-30 12:08:33 +03:00
setupBlock ,
2026-05-18 10:17:04 +03:00
'=== END SUBAGENT GIT-SAFETY HEADER ===' ,
'' ,
] . join ( '\n' ) ;
}
2026-05-29 20:15:12 +03:00
/** 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
}
}
2026-05-18 10:17:04 +03:00
/** 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 ( ) ;
}
2026-05-29 20:15:12 +03:00
const inheritanceEnvNote = writeInheritanceArtifacts ( input ) ;
const newPrompt = header + inheritanceEnvNote + originalPrompt ;
2026-05-18 10:17:04 +03:00
process . stdout . write ( JSON . stringify ( {
hookSpecificOutput : {
hookEventName : 'PreToolUse' ,
permissionDecision : 'allow' ,
permissionDecisionReason : 'git-safety header injected (Pravila §15.1)' ,
updatedInput : { prompt : newPrompt } ,
} ,
} ) ) ;
}
2026-05-29 20:15:12 +03:00
const isCli = process . argv [ 1 ] && process . argv [ 1 ] . replace ( /\\/g , '/' ) . endsWith ( '/subagent-prompt-prefix.mjs' ) ;
if ( isCli ) main ( ) . catch ( ( ) => failOpen ( ) ) ;