paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
207 lines (186 loc) • 7.13 kB
JavaScript
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
ENABLEMENT_VERIFIED_LINE,
loadSteps,
specPipelineNudge,
} from './lib/agent-entry-directive.mjs';
import { entryFile, sentinelRelative, sentinelState } from './lib/agent-entry-sentinel.mjs';
import { fitHookOutput } from '../scripts/context-seam.mjs';
import { emitContext, sessionIdFromStdin } from './lib/context-seam-emit.mjs';
import { logHookFailure } from './lib/hook-log.mjs';
import {
hookStandsDown,
isPaqadDisabled,
parseHookPayload,
readLayeredKey,
resolveProjectRoot,
} from './lib/paqad-disabled.mjs';
import { quietNarration } from './lib/hud-heartbeat.mjs';
import { refreshHudFromHook } from './lib/hud-snapshot.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ADAPTER = process.argv[2] || undefined;
const TASK_LIST_SENTENCES = {
full: 'paqad task list: Plan, Spec, Build, Review, Checks, Docs. Start each item with that word, in this order. Items before Plan (read the ticket) and after Docs (open the PR) are fine.',
fast: 'paqad task list: Plan, Build, Checks. Start each item with that word, in this order. Items before Plan and after Checks are fine.',
};
const FIRST_GUESS_LINE =
"[paqad] This is the hook's first guess. If you route this message to a different workflow, run paqad-ai route set <workflow> before any other paqad-ai call.";
function taskListSentence(lane) {
return lane === 'fast' ? TASK_LIST_SENTENCES.fast : TASK_LIST_SENTENCES.full;
}
function reasonFor(state, ef, sentinelRel) {
switch (state) {
case 'missing':
return `the per-session sentinel ${sentinelRel} is missing`;
case 'stale:entry-file':
return `${ef} changed mid-session — the sentinel was invalidated`;
case 'stale:framework-path':
return '.paqad/framework-path.txt changed mid-session — the sentinel was invalidated';
case 'stale:docs-instructions':
return 'docs/instructions/ changed mid-session — the sentinel was invalidated';
default:
return `the sentinel is not fresh (${state})`;
}
}
function directive(state, ef, sentinelRel) {
return [
ENABLEMENT_VERIFIED_LINE,
'[paqad] You MUST load the paqad framework before responding.',
`[paqad] Reason: ${reasonFor(state, ef, sentinelRel)}.`,
'[paqad] Required steps, in order, before any other tool call or response:',
...loadSteps(ef, sentinelRel),
'[paqad] Only after the final step may you address the prompt.',
'',
].join('\n');
}
function emitInjection(text) {
if (!text) {
return;
}
if (ADAPTER === 'codex-cli') {
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
})}\n`,
);
return;
}
process.stdout.write(text);
}
function fireContextRefresh(sessionId) {
try {
const refresh = join(HERE, 'context-refresh-trigger.mjs');
const args = sessionId ? [refresh, sessionId] : [refresh];
const child = spawn(process.execPath, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
} catch {}
}
async function isHumanPrompt(stdin, projectRoot) {
try {
const parsed = JSON.parse(stdin);
const prompt = typeof parsed?.prompt === 'string' ? parsed.prompt : '';
if (!prompt.trim()) {
return false;
}
const laneUrl = new URL('../../dist/pipeline/prompt-lane.js', import.meta.url);
const { isSystemNotificationPrompt } = await import(laneUrl.href);
return !isSystemNotificationPrompt(prompt);
} catch (error) {
logHookFailure(projectRoot, 'agent-entry-prompt-gate', error, 'checking the prompt is human');
return false;
}
}
async function refreshForHumanPrompt(stdin, projectRoot, sessionId) {
if (await isHumanPrompt(stdin, projectRoot)) {
fireContextRefresh(sessionId);
}
}
async function emitRoute(stdin, projectRoot, sink, afterNarration = '') {
try {
const parsed = JSON.parse(stdin);
const request = typeof parsed?.prompt === 'string' ? parsed.prompt : '';
if (!request.trim()) {
return null;
}
const sessionId = typeof parsed?.session_id === 'string' ? parsed.session_id : null;
const distUrl = new URL('../../dist/pipeline/prompt-lane.js', import.meta.url);
const { runPromptRouteSeam } = await import(distUrl.href);
const { routed, lane, narration, notification } = await runPromptRouteSeam({
projectRoot,
request,
sessionId,
adapter: ADAPTER ?? 'claude-code',
});
if (notification) {
return null;
}
if (narration) {
sink(`${narration}\n${afterNarration}`);
}
if (sessionId) {
sink(`[paqad] session ${sessionId}: prefix paqad-ai commands with SE_SESSION=${sessionId}\n`);
}
if (routed === 'feature-development') {
sink(`${taskListSentence(lane ?? null)}\n`);
}
return { routed, lane: lane ?? null };
} catch (error) {
logHookFailure(projectRoot, 'agent-entry-prompt-gate', error, 'routing this prompt');
return null;
}
}
async function main(stdin) {
const projectRoot = resolveProjectRoot(process.env, parseHookPayload(stdin));
if (isPaqadDisabled(projectRoot) || hookStandsDown(parseHookPayload(stdin))) {
return 0;
}
const sessionId = sessionIdFromStdin(stdin);
const state = sentinelState(projectRoot, process.env, undefined, sessionId);
if (state !== 'fresh') {
let firstRouteBuf = '';
await emitRoute(stdin, projectRoot, (text) => (firstRouteBuf += text), `${FIRST_GUESS_LINE}\n`);
await refreshForHumanPrompt(stdin, projectRoot, sessionId);
const message = directive(state, entryFile(), sentinelRelative(sessionId));
if ((process.env.PAQAD_AGENT_ENTRY_MODE || 'soft') === 'hard') {
process.stderr.write(message);
return 2;
}
emitInjection(`${firstRouteBuf}${message}`);
return 0;
}
let routeBuf = '';
const route = await emitRoute(stdin, projectRoot, (text) => (routeBuf += text));
await refreshForHumanPrompt(stdin, projectRoot, sessionId);
const routed = route?.routed ?? null;
const stripRules = typeof routed === 'string' && routed !== 'feature-development';
let contextBuf = '';
emitContext(stdin, projectRoot, (text) => (contextBuf += text), { stripRules });
const nudge = specPipelineNudge(readLayeredKey, projectRoot, process.env, route);
const after = `${nudge ? `${nudge}\n` : ''}${quietNarration(projectRoot, sessionId, ADAPTER)}`;
emitInjection(fitHookOutput(routeBuf, contextBuf, after));
return 0;
}
let done = false;
const chunks = [];
const run = () => {
if (done) return;
done = true;
const text = Buffer.concat(chunks).toString('utf8');
main(text)
.then(async (code) => {
await refreshHudFromHook(text, 'agent-entry-prompt-gate');
process.exit(code);
})
.catch(() => process.exit(0));
};
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', run);
process.stdin.on('error', run);
process.stdin.resume();