paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
220 lines (202 loc) • 6.8 kB
JavaScript
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
import { isDirectEntry } from './lib/direct-entry.mjs';
import { sentinelState } from './lib/agent-entry-sentinel.mjs';
import { agentIdFromStdin, sessionIdFromStdin } from './lib/context-seam-emit.mjs';
import { editTargets } from './lib/edit-targets.mjs';
import { gateFailureMessage } from './lib/gate-failure.mjs';
import {
appendHooksLog,
hookStandsDown,
isManagedRoute,
isPaqadDisabled,
isSchemaVersionError,
MANAGED_BLOCK_LINE,
parseHookPayload,
readFlooredMode,
resolveProjectRoot,
} from './lib/paqad-disabled.mjs';
import { stopHookActiveFromStdin } from './lib/loop-guard.mjs';
import { refreshHudFromHook } from './lib/hud-snapshot.mjs';
const RULE_SCRIPT_MAP_REL = 'docs/instructions/rules/rule-script-map.yml';
const RULE_COMPLIANCE_MODES = ['off', 'warn', 'strict'];
const STAGES_MODES = ['off', 'warn', 'strict'];
const SEAM = process.argv[2] === 'completion' ? 'completion' : 'pre-mutation';
const ADAPTER = process.argv[3] || undefined;
const ASK_OFF_ROUTE =
'[paqad] You allowed this edit once in the paqad HUD, but only Claude Code can ask you to approve it, so it stays blocked.';
export function preToolUseAsk(result, seam = SEAM, adapter = ADAPTER) {
if (seam !== 'pre-mutation' || adapter !== undefined) return null;
if (typeof result?.ask !== 'string' || result.ask === '') return null;
const output = {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: result.ask,
...(result.context ? { additionalContext: result.context } : {}),
};
return `${JSON.stringify({ hookSpecificOutput: output })}\n`;
}
function seamHasWork(projectRoot, seam) {
if (seam === 'pre-mutation') {
return true;
}
const stagesMode = readFlooredMode(
projectRoot,
'stages_mode',
'PAQAD_STAGES_MODE',
STAGES_MODES,
'strict',
);
if (stagesMode !== 'off') {
return true;
}
if (!existsSync(join(projectRoot, RULE_SCRIPT_MAP_REL))) {
return false;
}
const mode = readFlooredMode(
projectRoot,
'rule_compliance',
'PAQAD_RULE_COMPLIANCE',
RULE_COMPLIANCE_MODES,
'warn',
);
return mode !== 'off';
}
function parsePayload(input) {
try {
const payload = JSON.parse(input);
const targets = editTargets(payload);
return {
toolName: payload?.tool_name,
targetPath: targets[0],
targetPaths: targets.length > 0 ? targets : undefined,
transcriptPath: payload?.transcript_path,
sessionId: payload?.session_id,
adapter: ADAPTER,
toolUseId: typeof payload?.tool_use_id === 'string' ? payload.tool_use_id : undefined,
toolInput: payload?.tool_input,
agentType: typeof payload?.agent_type === 'string' ? payload.agent_type : undefined,
};
} catch {
/* v8 ignore next 2 */
return undefined;
}
}
export function entryLoadedProbe(projectRoot, input) {
return () => {
try {
const state = sentinelState(
projectRoot,
process.env,
agentIdFromStdin(input),
sessionIdFromStdin(input),
);
return state === 'fresh';
} catch {
return false;
}
};
}
async function deferredForBackgroundStage(projectRoot, input) {
const payload = parseHookPayload(input);
const tasks = payload && Array.isArray(payload.background_tasks) ? payload.background_tasks : [];
if (tasks.length === 0) {
return false;
}
try {
const distUrl = new URL('../../dist/stage-evidence/completion-deferred.js', import.meta.url);
const { deferCompletionForBackgroundStage } = await import(distUrl.href);
const result = deferCompletionForBackgroundStage({
projectRoot,
backgroundTasks: tasks,
sessionId: payload?.session_id,
adapter: ADAPTER ?? 'claude-code',
record: false,
});
return result.deferred && result.reason !== 'between stages';
} catch {
return false;
}
}
export async function main(input, seam = SEAM) {
const startedAt = Date.now();
try {
const projectRoot = resolveProjectRoot(process.env, parseHookPayload(input));
const managed = isManagedRoute(process.env);
if (!managed && (isPaqadDisabled(projectRoot) || hookStandsDown(parseHookPayload(input)))) {
return 0;
}
if (seam === 'completion' && (await deferredForBackgroundStage(projectRoot, input))) {
return 0;
}
if (!seamHasWork(projectRoot, seam)) return 0;
const distUrl = new URL('../../dist/kernel/gate.js', import.meta.url);
const { runCapabilityGate } = await import(distUrl.href);
const result = await runCapabilityGate({
projectRoot,
seam,
payload: parsePayload(input),
entryLoaded: entryLoadedProbe(projectRoot, input),
startedAt,
});
if (result.block) {
const message = result.narration ? `${result.narration}\n${result.summary}` : result.summary;
const body = result.context ? `${message}\n${result.context}` : message;
process.stderr.write(managed ? `${body}\n${MANAGED_BLOCK_LINE}\n` : `${body}\n`);
if (seam === 'completion' && stopHookActiveFromStdin(input)) {
process.stderr.write(
'[paqad] already surfaced this turn — not blocking again so the session can end (git/CI remains the hard gate).\n',
);
return 0;
}
return 2;
}
if (result.ask) {
const answer = preToolUseAsk(result, seam, ADAPTER);
if (answer === null) {
process.stderr.write(`${ASK_OFF_ROUTE}\n`);
return 2;
}
process.stdout.write(answer);
return 0;
}
if (seam === 'pre-mutation' && result.context && ADAPTER === undefined) {
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: result.context },
})}\n`,
);
}
return 0;
} catch (error) {
if (isSchemaVersionError(error)) {
try {
appendHooksLog(
resolveProjectRoot(process.env, parseHookPayload(input)),
'capability-gate skipped: .paqad layout written by a newer paqad; run `claude plugin update paqad@eliyce`',
);
} catch {}
return 0;
}
if (seam === 'pre-mutation') {
process.stderr.write(`${gateFailureMessage('capability', error)}\n`);
return 2;
}
return 0;
}
}
if (isDirectEntry(import.meta.url)) {
let input = '';
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
main(input).then(async (code) => {
if (SEAM === 'completion') await refreshHudFromHook(input, 'capability-gate-completion');
process.exit(code);
});
});
process.stdin.resume();
}