Files
brain/tools/enforce-branch-switch.mjs
T

106 lines
3.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Rule #7 — Branch-switch detection before commit / push.
*
* PreToolUse on Bash. Detects `git commit`, `git push`, `git cherry-pick`,
* `git reset --hard`, `git rebase`, `git branch -f/-d`. Reads expected branch
* from sentinel; if missing, defaults to "main". Compares to actual current
* branch via `git branch --show-current`. Mismatch → block unless explicit
* confirmation marker in last assistant text OR override phrase.
*
* Confirmation markers in assistant response (case-sensitive substring):
* - BRANCH-SWITCH-CONFIRMED
* - RECOVERY-INTENT:
* Override phrases: "recovery" (suppresses branch-switch + git-recovery rule keys)
*
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
*/
import {
readStdin,
parseEventJson,
readTranscript,
lastUserPromptText,
lastAssistantText,
findOverride,
logOverride,
exitDecision,
detectGitCommandKind,
readGitBranch,
getExpectedBranch,
} from './enforce-hook-helpers.mjs';
const RULE_KEY = 'branch-switch';
const CONFIRMATION_MARKERS = [
'BRANCH-SWITCH-CONFIRMED',
'RECOVERY-INTENT:',
];
export function decide({
toolName,
command,
expectedBranch,
actualBranch,
assistantText,
override,
}) {
if (toolName !== 'Bash' || typeof command !== 'string') return { block: false };
const kind = detectGitCommandKind(command);
if (!kind) return { block: false };
if (override) return { block: false };
const exp = (expectedBranch || 'main').trim();
const act = (actualBranch || '').trim();
if (!act || act === exp) return { block: false };
for (const marker of CONFIRMATION_MARKERS) {
if (assistantText && assistantText.includes(marker)) return { block: false };
}
return {
block: true,
message: [
`[enforce-branch-switch] About to run \`git ${kind}\` on branch "${act}" but expected "${exp}".`,
`Likely cause: parallel session switched HEAD silently (see Pravila §15.1).`,
``,
`If intentional — write one of these in your next response BEFORE running the command:`,
` BRANCH-SWITCH-CONFIRMED (you intend to commit on ${act})`,
` RECOVERY-INTENT: <one-line reason> (recovery operation, e.g., cherry-pick to main)`,
``,
`Or include the override phrase "recovery" in the user's next prompt.`,
].join('\n'),
};
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
const toolName = event.tool_name || '';
const command = (event.tool_input && event.tool_input.command) || '';
const transcript = readTranscript(event.transcript_path);
const userPrompt = lastUserPromptText(transcript);
const override = findOverride(userPrompt, RULE_KEY);
if (override) logOverride(RULE_KEY, override, event.session_id);
const expected = getExpectedBranch(event.session_id) || 'main';
const actual = readGitBranch();
const assistantText = lastAssistantText(transcript);
const result = decide({
toolName, command,
expectedBranch: expected,
actualBranch: actual,
assistantText,
override,
});
exitDecision(result);
} catch {
exitDecision({ block: false });
}
}
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-branch-switch.mjs');
if (isCli) main();