paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
465 lines (423 loc) • 12.9 kB
JavaScript
import {
appendFileSync,
chmodSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { spawn } from 'node:child_process';
import { homedir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import {
anyLiveNpmHookLines,
appendHooksLog,
claudeDir,
claudeSettingsPath,
hasLiveNpmHookLines,
isProjectOnboarded,
paqadHookScriptPath,
parseHookPayload,
pluginRootStandsDown,
pluginStandsDownPassive,
projectClaudeSettingsPaths,
resolveProjectRoot,
sessionIdFrom,
writePluginActiveMarker,
} from './lib/paqad-disabled.mjs';
import { isDirectEntry } from './lib/direct-entry.mjs';
import { maybeSpawnMigrationTick } from './lib/plugin-migration-tick.mjs';
function frameworkHome(env = process.env) {
return env.PAQAD_FRAMEWORK_HOME || join(homedir(), '.paqad-ai', 'current');
}
function symlinkType() {
return process.platform === 'win32' ? 'junction' : undefined;
}
function readPackageVersion(dir) {
try {
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
return typeof parsed.version === 'string' ? parsed.version : null;
} catch {
return null;
}
}
function compareVersions(a, b) {
const pa = String(a)
.split('.')
.map((n) => Number.parseInt(n, 10) || 0);
const pb = String(b)
.split('.')
.map((n) => Number.parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
function lstatSafe(path) {
try {
return lstatSync(path);
} catch {
return undefined;
}
}
function linkTarget(path) {
try {
return readlinkSync(path);
} catch {
return null;
}
}
function isNpmRuntime(target) {
return typeof target === 'string' && target.includes('node_modules');
}
function isPluginCacheRuntime(target) {
return (
typeof target === 'string' && (target.includes('/plugins/') || target.includes('\\plugins\\'))
);
}
function stableRuntimeHome(env = process.env) {
const data = env.CLAUDE_PLUGIN_DATA;
return typeof data === 'string' && data.length > 0 ? join(data, 'runtime') : null;
}
const RUNTIME_COPY_PARTS = ['package.json', 'dist', 'runtime'];
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
const RENAME_MAX_ATTEMPTS = 5;
function renameWithRetrySync(from, to, remove) {
for (let attempt = 1; ; attempt += 1) {
try {
renameSync(from, to);
return;
} catch (error) {
const retryable = process.platform === 'win32' && RENAME_RETRY_CODES.has(error?.code ?? '');
if (attempt >= RENAME_MAX_ATTEMPTS || !retryable) {
remove(from);
throw error;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * 2 ** (attempt - 1));
}
}
}
function runtimeCopyVersion(copyRoot) {
const st = lstatSafe(copyRoot);
if (!st || st.isSymbolicLink() || !st.isDirectory()) {
return null;
}
try {
return readFileSync(join(copyRoot, 'version'), 'utf8').trim();
} catch {
return null;
}
}
function removeTree(path) {
rmSync(path, { recursive: true, force: true });
}
function removeTreeQuietly(path) {
try {
removeTree(path);
} catch {
return;
}
}
function replaceRuntimeCopy(staging, copyRoot) {
const st = lstatSafe(copyRoot);
let retired = null;
if (st && st.isSymbolicLink()) {
rmSync(copyRoot, { force: true, recursive: false });
} else if (st) {
retired = `${copyRoot}.old-${process.pid}`;
removeTree(retired);
renameWithRetrySync(copyRoot, retired, () => undefined);
}
renameWithRetrySync(staging, copyRoot, removeTreeQuietly);
if (retired) {
removeTreeQuietly(retired);
}
}
function ensureStablePluginRuntime(pluginRuntime, env = process.env) {
const copyRoot = stableRuntimeHome(env);
if (!copyRoot) {
return pluginRuntime;
}
const pluginRoot = dirname(pluginRuntime);
const linked = join(copyRoot, 'runtime');
const staging = `${copyRoot}.tmp-${process.pid}`;
try {
const version = readPackageVersion(pluginRoot);
if (!version) {
return pluginRuntime;
}
if (runtimeCopyVersion(copyRoot) === version && existsSync(linked)) {
return linked;
}
removeTree(staging);
mkdirSync(staging, { recursive: true });
for (const part of RUNTIME_COPY_PARTS) {
const from = join(pluginRoot, part);
if (existsSync(from)) {
cpSync(from, join(staging, part), { recursive: true });
}
}
writeFileSync(join(staging, 'version'), `${version}\n`, 'utf8');
replaceRuntimeCopy(staging, copyRoot);
return linked;
} catch {
removeTreeQuietly(staging);
return pluginRuntime;
}
}
function repointFrameworkLink(pluginRuntime, env = process.env) {
const home = frameworkHome(env);
const stat = lstatSafe(home);
if (stat && stat.isDirectory() && !stat.isSymbolicLink()) {
return;
}
const stable = ensureStablePluginRuntime(pluginRuntime, env);
if (stat && stat.isSymbolicLink()) {
const target = linkTarget(home);
if (target) {
if (target === stable) {
return;
}
const targetStat = lstatSafe(target);
if (targetStat && target !== stableRuntimeHome(env)) {
if (isNpmRuntime(target)) {
return;
}
if (!isPluginCacheRuntime(target) && target !== pluginRuntime) {
return;
}
}
}
}
try {
mkdirSync(dirname(home), { recursive: true });
rmSync(home, { force: true, recursive: false });
symlinkSync(stable, home, symlinkType());
} catch {}
}
function ensureBinOnPath(pluginRuntime, env = process.env) {
const binDir = join(pluginRuntime, 'claude-plugin', 'bin');
if (process.platform !== 'win32') {
try {
chmodSync(join(binDir, 'paqad-ai'), 0o755);
} catch {}
}
const envFile = env.CLAUDE_ENV_FILE;
if (typeof envFile === 'string' && envFile.length > 0) {
try {
appendFileSync(envFile, `PATH=${binDir}${delimiter}${env.PATH ?? ''}\n`);
} catch {}
}
}
const HOME_STAGE_AGENTS = [
'paqad-planning.md',
'paqad-specification.md',
'paqad-development.md',
'paqad-review.md',
'paqad-checks.md',
'paqad-documentation-sync.md',
'paqad-expert.md',
'paqad-chief-architect.md',
];
function claudeAgentsDir(env = process.env) {
return join(claudeDir(env), 'agents');
}
function removeDeadNpmHookLines(settingsPath = claudeSettingsPath()) {
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
} catch {
return;
}
const hooks = settings && typeof settings === 'object' ? settings.hooks : undefined;
if (!hooks || typeof hooks !== 'object') return;
let changed = false;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || !Array.isArray(entry.hooks)) continue;
const kept = entry.hooks.filter((hk) => {
const script = paqadHookScriptPath(hk && hk.command);
if (script === null) return true;
return existsSync(script);
});
if (kept.length !== entry.hooks.length) {
entry.hooks = kept;
changed = true;
}
}
hooks[event] = entries.filter(
(entry) => !entry || !Array.isArray(entry.hooks) || entry.hooks.length > 0,
);
if (hooks[event].length !== entries.length) changed = true;
}
if (changed) {
try {
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
} catch {}
}
}
function pluginSeenPath(env = process.env) {
const data = env.PAQAD_FRAMEWORK_HOME
? dirname(env.PAQAD_FRAMEWORK_HOME)
: join(homedir(), '.paqad-ai');
return join(data, 'plugin-seen.json');
}
function writePluginSeen(pluginRoot, env = process.env) {
try {
const path = pluginSeenPath(env);
mkdirSync(dirname(path), { recursive: true });
const payload = {
version: readPackageVersion(pluginRoot),
at: new Date().toISOString(),
root: pluginRoot,
};
writeFileSync(path, JSON.stringify(payload));
} catch {}
}
function removeHomeStageAgents(agentsDir = claudeAgentsDir()) {
for (const name of HOME_STAGE_AGENTS) {
try {
rmSync(join(agentsDir, name), { force: true });
} catch {}
}
}
const UPDATE_THROTTLE_MS = 6 * 60 * 60 * 1000;
function frameworkVersionTxtVersion(projectRoot) {
try {
const txt = readFileSync(join(projectRoot, '.paqad', 'framework-version.txt'), 'utf8');
const m = txt.match(/^version=(.*)$/m);
return m ? m[1].trim() : null;
} catch {
return null;
}
}
function shouldRefreshVersion(pluginVersion, frameworkVersion) {
if (!pluginVersion) return false;
if (!frameworkVersion) return true;
return compareVersions(pluginVersion, frameworkVersion) > 0;
}
/* v8 ignore start -- spawns the real detached `update --silent`; the decision around it is unit
tested through the injected runUpdate seam and the real run is covered by manual sign-off. */
function spawnSilentUpdate(pluginRoot, projectRoot) {
const cli = join(pluginRoot, 'dist', 'cli', 'index.js');
const child = spawn(
process.execPath,
[cli, 'update', '--silent', '--project-root', projectRoot],
{ detached: true, stdio: 'ignore', windowsHide: true },
);
child.unref();
}
/* v8 ignore stop */
function maybeRefreshVersion(pluginRoot, projectRoot, deps = {}) {
const now = deps.now ?? Date.now;
const runUpdate = deps.runUpdate ?? spawnSilentUpdate;
const pluginVersion = readPackageVersion(pluginRoot);
const frameworkVersion = frameworkVersionTxtVersion(projectRoot);
if (!shouldRefreshVersion(pluginVersion, frameworkVersion)) return 'current';
const lock = join(projectRoot, '.paqad', 'locks', 'update.lock');
const existing = lstatSafe(lock);
if (existing && now() - existing.mtimeMs < UPDATE_THROTTLE_MS) return 'throttled';
try {
mkdirSync(dirname(lock), { recursive: true });
writeFileSync(lock, String(process.pid));
} catch {
return 'throttled';
}
runUpdate(pluginRoot, projectRoot);
return 'started';
}
function runtimeVersion(projectRoot, env) {
let target;
try {
target = readFileSync(join(projectRoot, '.paqad', 'framework-path.txt'), 'utf8').trim();
} catch {
return null;
}
if (target === '') return null;
const resolved = target.startsWith('~/') ? join(env.HOME ?? homedir(), target.slice(2)) : target;
return readPackageVersion(resolved) ?? readPackageVersion(dirname(resolved));
}
function reportVersionMismatch(pluginRoot, projectRoot, env, deps) {
const plugin = readPackageVersion(pluginRoot);
const runtime = runtimeVersion(projectRoot, env);
if (!plugin || !runtime || plugin === runtime) return;
const line = `paqad: plugin ${plugin} and runtime ${runtime} differ; run \`paqad-ai update\``;
appendHooksLog(projectRoot, line);
(deps.print ?? ((text) => process.stdout.write(`${text}\n`)))(line);
}
export function runPluginSessionStart(env = process.env, payload = undefined, deps = {}) {
const pluginRoot = env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
return;
}
const pluginRuntime = join(pluginRoot, 'runtime');
writePluginSeen(pluginRoot, env);
(deps.tick ?? maybeSpawnMigrationTick)({
source: 'plugin',
sessionId: sessionIdFrom(payload, env),
cliPath: join(pluginRoot, 'dist', 'cli', 'index.js'),
env,
});
repointFrameworkLink(pluginRuntime, env);
const projectRoot = resolveProjectRoot(env, payload);
if (!isProjectOnboarded(projectRoot)) {
ensureBinOnPath(pluginRuntime, env);
return;
}
if (pluginStandsDownPassive(payload, env)) {
return;
}
writePluginActiveMarker(payload, env);
if (pluginRootStandsDown(payload, env)) {
return;
}
ensureBinOnPath(pluginRuntime, env);
removeDeadNpmHookLines(claudeSettingsPath(env));
removeHomeStageAgents(claudeAgentsDir(env));
maybeRefreshVersion(pluginRoot, projectRoot);
reportVersionMismatch(pluginRoot, projectRoot, env, deps);
}
export const __test = {
compareVersions,
readPackageVersion,
repointFrameworkLink,
ensureStablePluginRuntime,
stableRuntimeHome,
isNpmRuntime,
isPluginCacheRuntime,
ensureBinOnPath,
frameworkHome,
symlinkType,
hasLiveNpmHookLines,
projectClaudeSettingsPaths,
anyLiveNpmHookLines,
pluginSeenPath,
writePluginSeen,
removeDeadNpmHookLines,
removeHomeStageAgents,
paqadHookScriptPath,
claudeDir,
shouldRefreshVersion,
frameworkVersionTxtVersion,
maybeRefreshVersion,
};
if (isDirectEntry(import.meta.url)) {
try {
let payload;
try {
payload = parseHookPayload(readFileSync(0, 'utf8'));
} catch {
payload = undefined;
}
runPluginSessionStart(process.env, payload);
} catch {}
}