Spaces:
Runtime error
Runtime error
File size: 5,723 Bytes
a6b96c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | #!/usr/bin/env node
// gsd-hook-version: 1.5.0
// GSD Workflow Guard — PreToolUse hook
// Detects when Claude attempts file edits outside a GSD workflow context
// (no active /gsd- skill or Task subagent) and injects an advisory warning.
//
// This is a SOFT guard — it advises, not blocks. The edit still proceeds.
// The warning nudges Claude to use /gsd:quick or /gsd:fast instead of
// making direct edits that bypass state tracking.
//
// Enable via config: hooks.workflow_guard: true (default: false)
// Only triggers on Write/Edit tool calls to non-.planning/ files.
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { tokenize } = require('./lib/git-cmd.js');
function forceGitAddCwds(command, defaultCwd) {
const tokens = tokenize(command || '');
const separators = new Set(['&&', '||', ';', '|']);
const cwdList = [];
for (let i = 0; i < tokens.length; i++) {
if (path.basename(tokens[i]) !== 'git') continue;
let j = i + 1;
let gitCwd = defaultCwd;
while (j < tokens.length) {
const token = tokens[j];
const flagName = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
if (token === '-C' && tokens[j + 1]) {
gitCwd = path.resolve(gitCwd, tokens[j + 1]);
j += 2;
continue;
}
if (['-C', '--git-dir', '--work-tree'].includes(flagName) && !token.includes('=')) {
j += 2;
continue;
}
if (['--git-dir', '--work-tree', '--no-pager', '-p', '-P'].includes(flagName)) {
j++;
continue;
}
break;
}
if (tokens[j] !== 'add') continue;
for (let k = j + 1; k < tokens.length && !separators.has(tokens[k]); k++) {
if (tokens[k] === '--') break;
if (tokens[k] === '--force' || tokens[k] === '-f' || /^-[A-Za-z]*f[A-Za-z]*$/.test(tokens[k])) {
cwdList.push(gitCwd);
break;
}
}
}
return cwdList;
}
function currentBranch(cwd) {
const result = spawnSync('git', ['branch', '--show-current'], {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
if (result.status !== 0) return '';
return result.stdout.trim();
}
function workflowGuardEnabled(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
if (!fs.existsSync(configPath)) return false;
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return Boolean(config.hooks?.workflow_guard);
} catch (e) {
return false;
}
}
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input);
const toolName = data.tool_name;
const cwd = data.cwd || process.cwd();
const isWorkflowGuardEnabled = workflowGuardEnabled(cwd);
if (toolName === 'Bash') {
if (!isWorkflowGuardEnabled) {
process.exit(0);
}
const command = data.tool_input?.command || '';
for (const gitCwd of forceGitAddCwds(command, cwd)) {
const branch = currentBranch(gitCwd);
if (branch.startsWith('worktree-agent-')) {
process.stdout.write(JSON.stringify({
decision: 'block',
code: 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN',
reason: 'worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
}));
process.exit(2);
}
}
process.exit(0);
}
// Only guard Write, Edit, and MultiEdit tool calls
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) {
process.exit(0);
}
// Check if we're inside a GSD workflow (Task subagent or /gsd- skill)
// Subagents have a session_id that differs from the parent
// and typically have a description field set by the orchestrator
if (data.tool_input?.is_subagent || data.session_type === 'task') {
process.exit(0);
}
// Check the file being edited
const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
// Allow edits to .planning/ files (GSD state management)
if (filePath.includes('.planning/') || filePath.includes('.planning\\')) {
process.exit(0);
}
// Allow edits to common config/docs files that don't need GSD tracking
const allowedPatterns = [
/\.gitignore$/,
/\.env/,
/CLAUDE\.md$/,
/AGENTS\.md$/,
/GEMINI\.md$/,
/settings\.json$/,
];
if (allowedPatterns.some(p => p.test(filePath))) {
process.exit(0);
}
if (!isWorkflowGuardEnabled) {
process.exit(0); // Guard disabled (default) or no GSD project
}
// If we get here: GSD project, guard enabled, file edit outside .planning/,
// not in a subagent context. Inject advisory warning.
const output = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: `⚠️ WORKFLOW ADVISORY: You're editing ${path.basename(filePath)} directly without a GSD command. ` +
'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' +
'Consider using /gsd:fast for trivial fixes or /gsd:quick for larger changes ' +
'to maintain project state tracking. ' +
'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.'
}
};
process.stdout.write(JSON.stringify(output));
} catch (e) {
// Silent fail — never block tool execution
process.exit(0);
}
});
|