397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.3 KiB
JavaScript
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();
|