paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
537 lines (478 loc) • 17.3 kB
JavaScript
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
statSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { readYamlScalar } from './yaml-scalar.mjs';
const ENV_TRUTHY = new Set(['1', 'true', 'yes', 'on']);
const FALSY = new Set(['false', '0', 'no', 'off']);
export function isEnvDisabled(env = process.env) {
const raw = env.PAQAD_DISABLED;
return typeof raw === 'string' && ENV_TRUTHY.has(raw.trim().toLowerCase());
}
export const MANAGED_BLOCK_LINE = "This rule is enforced by your organization's settings.";
export function isManagedRoute(env = process.env) {
const raw = env.PAQAD_MANAGED;
return typeof raw === 'string' && ENV_TRUTHY.has(raw.trim().toLowerCase());
}
export function parseHookPayload(input) {
if (typeof input !== 'string' || input.trim() === '') {
return undefined;
}
try {
const parsed = JSON.parse(input);
return parsed && typeof parsed === 'object' ? parsed : undefined;
} catch {
return undefined;
}
}
export function findProjectRootFromCwd(startDir) {
let dir = startDir;
for (;;) {
try {
if (statSync(join(dir, '.paqad')).isDirectory()) {
return dir;
}
} catch {}
const parent = dirname(dir);
if (parent === dir) {
return undefined;
}
dir = parent;
}
}
export function resolveProjectRoot(env = process.env, payload = undefined) {
const cwd = payload && typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
if (cwd) {
const found = findProjectRootFromCwd(cwd);
if (found) {
return found;
}
}
return env.CLAUDE_PROJECT_DIR || env.PAQAD_PROJECT_ROOT || process.cwd();
}
export function isPaqadDisabled(projectRoot = resolveProjectRoot(), env = process.env) {
if (isEnvDisabled(env)) {
return true;
}
const resolved = resolvePaqadEnable(projectRoot, env);
return resolved !== undefined && FALSY.has(resolved);
}
export function isProjectOnboarded(projectRoot = resolveProjectRoot()) {
try {
return statSync(join(projectRoot, '.paqad', 'framework-path.txt')).isFile();
} catch {
return false;
}
}
export function pluginStandsDownUnonboarded(payload = undefined, env = process.env) {
if (!env.CLAUDE_PLUGIN_ROOT) {
return false;
}
return !isProjectOnboarded(resolveProjectRoot(env, payload));
}
export function claudeDir(env = process.env) {
return env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
}
export function claudeSettingsPath(env = process.env) {
return join(claudeDir(env), 'settings.json');
}
export const TEAM_LAUNCHER_MARK = '/.paqad/hook.mjs';
export function isTeamLauncherCommand(command) {
return typeof command === 'string' && command.replaceAll('\\', '/').includes(TEAM_LAUNCHER_MARK);
}
export function teamPluginRoot() {
return join(homedir(), '.paqad-ai', 'team-plugin');
}
export function paqadHookScriptPath(command) {
if (typeof command !== 'string' || !command.includes('paqad')) return null;
if (isTeamLauncherCommand(command)) return null;
const m = command.match(/"([^"]+\.mjs)"|(\S+\.mjs)/);
const path = m ? (m[1] ?? m[2]) : null;
return path && path.includes('paqad') ? path : null;
}
export function eachHookCommand(settings) {
const out = [];
const hooks = settings && typeof settings === 'object' ? settings.hooks : undefined;
if (!hooks || typeof hooks !== 'object') return out;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
entries.forEach((entry, ei) => {
const inner = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
inner.forEach((hk, hi) => out.push({ event, ei, hi, command: hk && hk.command }));
});
}
return out;
}
export function hasLiveNpmHookLines(settingsPath = claudeSettingsPath(process.env)) {
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
} catch {
return false;
}
return eachHookCommand(settings).some(({ command }) => {
const script = paqadHookScriptPath(command);
return script !== null && existsSync(script);
});
}
export function projectClaudeSettingsPaths(projectRoot) {
return [
join(projectRoot, '.claude', 'settings.json'),
join(projectRoot, '.claude', 'settings.local.json'),
];
}
export function anyLiveNpmHookLines(settingsPaths) {
return settingsPaths.some((path) => hasLiveNpmHookLines(path));
}
export function pluginActiveMarkerDir(projectRoot) {
return join(projectRoot, '.paqad', 'session', 'plugin-active.d');
}
export function sessionIdFrom(payload, env = process.env) {
const fromPayload =
payload && typeof payload.session_id === 'string' ? payload.session_id.trim() : '';
if (fromPayload) return fromPayload;
const fromEnv = env.CLAUDE_SESSION_ID;
return typeof fromEnv === 'string' ? fromEnv.trim() : '';
}
export function isPluginActiveForSession(payload, env = process.env) {
const sid = sessionIdFrom(payload, env);
if (!sid) return false;
const projectRoot = resolveProjectRoot(env, payload);
try {
return statSync(join(pluginActiveMarkerDir(projectRoot), sid)).isFile();
} catch {
return false;
}
}
export function writePluginActiveMarker(payload, env = process.env) {
const sid = sessionIdFrom(payload, env);
if (!sid) return;
const projectRoot = resolveProjectRoot(env, payload);
const root = typeof env.CLAUDE_PLUGIN_ROOT === 'string' ? env.CLAUDE_PLUGIN_ROOT.trim() : '';
const body = JSON.stringify({ at: new Date().toISOString(), ...(root ? { root } : {}) });
try {
const dir = pluginActiveMarkerDir(projectRoot);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, sid), body, { flag: 'wx' });
} catch {
return;
}
}
export function pluginActiveMarkerRoot(payload, env = process.env) {
const sid = sessionIdFrom(payload, env);
if (!sid) return null;
let parsed;
try {
const path = join(pluginActiveMarkerDir(resolveProjectRoot(env, payload)), sid);
parsed = JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
const root = parsed && typeof parsed === 'object' ? parsed.root : undefined;
return typeof root === 'string' && root.trim() !== '' ? root : null;
}
function canonicalRoot(path) {
let full;
try {
full = realpathSync.native(path);
} catch {
full = resolve(path);
}
const trimmed = full.replace(/[\\/]+$/, '');
return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed;
}
const PAQAD_PLUGIN_ID = 'paqad@eliyce';
export function installedPluginRoots(env = process.env) {
let parsed;
try {
parsed = JSON.parse(
readFileSync(join(claudeDir(env), 'plugins', 'installed_plugins.json'), 'utf8'),
);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const plugins = parsed.plugins && typeof parsed.plugins === 'object' ? parsed.plugins : parsed;
const value = plugins[PAQAD_PLUGIN_ID];
const entries = Array.isArray(value) ? value : [value];
const roots = entries
.map((entry) => (entry && typeof entry === 'object' ? entry.installPath : undefined))
.filter((path) => typeof path === 'string' && path.trim() !== '')
.map((path) => canonicalRoot(path));
return roots.length > 0 ? roots : null;
}
function readJsonFile(path) {
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return undefined;
}
}
export function pluginSettingsLayers(projectRoot, env = process.env) {
return [claudeSettingsPath(env), ...projectClaudeSettingsPaths(projectRoot)];
}
export function pluginEnabledInSettings(projectRoot, env = process.env) {
let enabled;
for (const path of pluginSettingsLayers(projectRoot, env)) {
const map = readJsonFile(path)?.enabledPlugins;
if (map && typeof map === 'object' && typeof map[PAQAD_PLUGIN_ID] === 'boolean') {
enabled = map[PAQAD_PLUGIN_ID];
}
}
return enabled !== false;
}
function entryLoadsHere(entry, projectRoot) {
if (!entry || typeof entry !== 'object') return false;
const path = entry.installPath;
if (typeof path !== 'string' || path.trim() === '' || !existsSync(path)) return false;
const scope = typeof entry.scope === 'string' ? entry.scope : 'user';
if (scope !== 'project' && scope !== 'local') return true;
return (
typeof entry.projectPath === 'string' &&
canonicalRoot(entry.projectPath) === canonicalRoot(projectRoot)
);
}
export function pluginLoadState(projectRoot, env = process.env) {
const parsed = readJsonFile(join(claudeDir(env), 'plugins', 'installed_plugins.json'));
if (!parsed || typeof parsed !== 'object' || parsed.version !== 2) return 'unknown';
if (!parsed.plugins || typeof parsed.plugins !== 'object') return 'unknown';
const value = parsed.plugins[PAQAD_PLUGIN_ID];
const entries = Array.isArray(value) ? value : [value];
if (!entries.some((entry) => entryLoadsHere(entry, projectRoot))) return 'absent';
return pluginEnabledInSettings(projectRoot, env) ? 'live' : 'absent';
}
export function isSyncedPluginRoot(root) {
const path = root.replaceAll('\\', '/');
if (path.includes('/plugins/synced/')) return true;
const sessions = path.indexOf('/Claude/local-agent-mode-sessions/');
return sessions !== -1 && path.indexOf('/rpm/plugin_', sessions) !== -1;
}
function claimedRootIsLive(claimed, env) {
if (isSyncedPluginRoot(claimed)) return true;
if (canonicalRoot(claimed) === canonicalRoot(teamPluginRoot())) return true;
const installed = installedPluginRoots(env);
if (installed === null) return true;
return installed.includes(canonicalRoot(claimed));
}
function reclaimPluginActiveMarker(payload, env, root) {
const sid = sessionIdFrom(payload, env);
try {
writeFileSync(
join(pluginActiveMarkerDir(resolveProjectRoot(env, payload)), sid),
JSON.stringify({ at: new Date().toISOString(), root }),
);
} catch {
return;
}
}
export function pluginRootStandsDown(payload = undefined, env = process.env) {
const mine = typeof env.CLAUDE_PLUGIN_ROOT === 'string' ? env.CLAUDE_PLUGIN_ROOT.trim() : '';
if (!mine) return false;
const claimed = pluginActiveMarkerRoot(payload, env);
if (claimed === null || !existsSync(claimed)) return false;
if (canonicalRoot(claimed) === canonicalRoot(mine)) return false;
if (claimedRootIsLive(claimed, env)) return true;
reclaimPluginActiveMarker(payload, env, mine);
return false;
}
export function npmHookStepsAside(payload, env = process.env) {
return !env.CLAUDE_PLUGIN_ROOT && isPluginActiveForSession(payload, env);
}
export function pluginStandsDownPassive(payload = undefined, env = process.env) {
if (!env.CLAUDE_PLUGIN_ROOT) return false;
if (isPluginActiveForSession(payload, env)) return false;
const projectRoot = resolveProjectRoot(env, payload);
if (!isProjectOnboarded(projectRoot)) return false;
return anyLiveNpmHookLines([claudeSettingsPath(env), ...projectClaudeSettingsPaths(projectRoot)]);
}
export function standDownReason(payload = undefined, env = process.env) {
if (npmHookStepsAside(payload, env)) return 'npm';
if (pluginStandsDownPassive(payload, env)) return 'passive';
if (pluginStandsDownUnonboarded(payload, env)) return 'unonboarded';
if (pluginRootStandsDown(payload, env)) return 'root';
return null;
}
export function hookStandsDown(payload = undefined, env = process.env) {
return standDownReason(payload, env) !== null;
}
function resolvePaqadEnable(projectRoot, env) {
const raw = readLayeredKey(projectRoot, 'paqad_enable', 'PAQAD_ENABLE', env);
return raw === undefined ? undefined : raw.trim().toLowerCase();
}
const BLOCK_TO_STRICT = { block: 'strict' };
const BOOLEAN_WORDS = new Set([...ENV_TRUTHY, ...FALSY]);
export const HOOK_TEAM_HOMES = {
paqad_enable: { file: 'paqad.yaml', path: ['enabled'], boolean: true },
auto_update: { file: 'paqad.yaml', path: ['updates', 'automatic'], boolean: true },
minimum_version: { file: 'paqad.yaml', path: ['updates', 'minimum_version'] },
version_check_interval_hours: { file: 'paqad.yaml', path: ['updates', 'check_every_hours'] },
rag_enabled: { file: 'retrieval.yaml', path: ['enabled'], boolean: true },
lean_rules: {
file: 'feature-development/rules.yaml',
path: ['loading'],
from: { lean: 'true', full: 'false' },
},
rule_compliance: {
file: 'feature-development/rules.yaml',
path: ['enforcement'],
from: BLOCK_TO_STRICT,
},
stages_mode: {
file: 'feature-development/workflow.yaml',
path: ['stage_tracking'],
from: BLOCK_TO_STRICT,
},
skill_orchestrator_enabled: {
file: 'authoring.yaml',
path: ['skills', 'enabled'],
boolean: true,
},
agent_orchestrator_enabled: {
file: 'authoring.yaml',
path: ['agents', 'enabled'],
boolean: true,
},
authoring_dark_window_days: { file: 'authoring.yaml', path: ['skills', 'dark_window_days'] },
};
function fromYamlWord(key, raw) {
if (raw === undefined) return undefined;
const map = HOOK_TEAM_HOMES[key]?.from;
if (map === undefined) return raw;
return map[raw.trim().toLowerCase()] ?? raw;
}
export function readTeamYamlKey(projectRoot, key) {
const home = HOOK_TEAM_HOMES[key];
if (home === undefined) return undefined;
let text;
try {
text = readFileSync(join(projectRoot, '.paqad', 'configs', ...home.file.split('/')), 'utf8');
} catch {
return undefined;
}
const raw = readYamlScalar(text, home.path);
if (raw === undefined) return undefined;
if (home.boolean && !BOOLEAN_WORDS.has(raw.trim().toLowerCase())) {
appendHooksLog(
projectRoot,
`.paqad/configs/${home.file}: ${home.path.join('.')} has a value paqad cannot read (${raw}), so paqad uses its default.`,
);
return undefined;
}
return fromYamlWord(key, raw);
}
export const LEGACY_TEAM_CONFIG_READ_UNTIL = '2027-02-01';
export function legacyTeamConfigReadable(today = new Date().toISOString().slice(0, 10)) {
return today <= LEGACY_TEAM_CONFIG_READ_UNTIL;
}
function readTeamKey(projectRoot, key) {
const fromYaml = readTeamYamlKey(projectRoot, key);
if (fromYaml !== undefined) return fromYaml;
return legacyTeamConfigReadable() ? readKeyFromConfigsDir(projectRoot, key) : undefined;
}
export function readLayeredKey(projectRoot, key, envName, env = process.env) {
let value = readTeamKey(projectRoot, key);
const local = fromYamlWord(key, readKeyFromFile(join(projectRoot, '.paqad', '.config'), key));
if (local !== undefined) {
value = local;
}
const fromEnv = env[envName];
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
value = fromYamlWord(key, fromEnv.trim());
}
return value;
}
export function readFlooredMode(projectRoot, key, envName, order, def, env = process.env) {
const norm = (raw) => {
if (raw === undefined || raw === null) return undefined;
const v = String(raw).trim().toLowerCase();
return order.includes(v) ? v : undefined;
};
const team = norm(readTeamKey(projectRoot, key));
const local = norm(
fromYamlWord(key, readKeyFromFile(join(projectRoot, '.paqad', '.config'), key)),
);
const fromEnv = norm(fromYamlWord(key, env[envName]));
let rank = order.indexOf(team ?? def);
for (const raising of [local, fromEnv]) {
if (raising !== undefined) {
rank = Math.max(rank, order.indexOf(raising));
}
}
return order[rank];
}
export const MIGRATED_STUB_MARKER = '# paqad-migrated-stub';
export function isMigratedStubText(text) {
return typeof text === 'string' && text.split(/\r?\n/, 1)[0].trim() === MIGRATED_STUB_MARKER;
}
function readKeyFromFile(path, key, skipStub = false) {
let raw;
try {
raw = readFileSync(path, 'utf8');
} catch {
return undefined;
}
if (skipStub && isMigratedStubText(raw)) {
return undefined;
}
let value;
const re = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=(.*)$`);
for (const line of raw.split(/\r?\n/)) {
const m = line.match(re);
if (m) {
value = m[1];
}
}
if (value === undefined) {
return undefined;
}
let v = value.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
} else {
const hash = v.search(/\s#/);
if (hash !== -1) {
v = v.slice(0, hash).trim();
}
}
return v;
}
function readKeyFromConfigsDir(projectRoot, key) {
const dir = join(projectRoot, '.paqad', 'configs');
let names;
try {
names = readdirSync(dir);
} catch {
return undefined;
}
let value;
for (const name of names
.filter((n) => /^\.config\..+/.test(n) && n !== '.config.example')
.sort()) {
const fromFile = readKeyFromFile(join(dir, name), key, true);
if (fromFile !== undefined) {
value = fromFile;
}
}
return value;
}
export function isSchemaVersionError(err) {
if (!err || typeof err !== 'object') return false;
const code = err.code;
const name = err.name;
return code === 'SCHEMA_VERSION_INCOMPATIBLE' || name === 'SchemaVersionError';
}
export function appendHooksLog(projectRoot, line) {
try {
const dir = join(projectRoot, '.paqad', 'logs');
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, 'hooks.log'), `${new Date().toISOString()} ${line}\n`);
} catch {}
}