UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

537 lines (478 loc) • 17.3 kB
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 {} }