paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
253 lines (222 loc) • 8.87 kB
text/typescript
import type { PaqadStandDown, PaqadStatus } from './types';
export const MIN_ENGINE = '2.1.301';
const ENV_TRUTHY = new Set(['1', 'true', 'yes', 'on']);
const FALSY = new Set(['false', '0', 'no', 'off']);
export interface StandDownInput {
onboarded: boolean;
env: {
CLAUDE_CODE_REMOTE?: string;
PAQAD_DISABLED?: string;
PAQAD_ENABLE?: string;
PAQAD_HUD_VIEW?: string;
PAQAD_HUD_CHIME?: string;
};
localConfig: string | null;
teamConfigs: ReadonlyArray<{ name: string; text: string }>;
paqadYaml?: string | null;
today?: string;
engine: { version: string; base?: string } | null;
}
export function readConfigKey(text: string | null, key: string): string | undefined {
if (text === null) return undefined;
const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=(.*)$`);
let value: string | undefined;
for (const line of text.split(/\r?\n/)) {
const match = line.match(pattern);
if (match) value = match[1];
}
if (value === undefined) return undefined;
let v = value.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
return v.slice(1, -1);
}
const hash = v.search(/\s#/);
if (hash !== -1) v = v.slice(0, hash).trim();
return v;
}
function indentOf(line: string): number {
return /^[ \t]*/.exec(line)?.[0].length ?? 0;
}
function isContent(line: string): boolean {
const trimmed = line.trim();
return trimmed !== '' && !trimmed.startsWith('#');
}
function keyValue(line: string, key: string): string | null {
const body = line.replace(/^[ \t]+/, '');
if (!body.startsWith(`${key}:`)) return null;
const rest = body.slice(key.length + 1);
if (rest !== '' && !/^[ \t]/.test(rest)) return null;
return rest;
}
function cleanScalar(rest: string): string | undefined {
let value = ` ${rest}`;
const hash = value.search(/[ \t]#/);
if (hash !== -1) value = value.slice(0, hash);
value = value.trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
return value === '' ? undefined : value;
}
export function readYamlScalar(text: string | null | undefined, path: readonly string[]) {
if (typeof text !== 'string' || path.length < 1 || path.length > 2) return undefined;
const lines = text.split(/\r?\n/);
const [top, child] = path as [string, string | undefined];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]!;
if (!isContent(line) || indentOf(line) !== 0) continue;
const rest = keyValue(line, top);
if (rest === null) continue;
if (child === undefined) return cleanScalar(rest);
let childIndent = -1;
for (let j = i + 1; j < lines.length; j += 1) {
const inner = lines[j]!;
if (!isContent(inner)) continue;
const indent = indentOf(inner);
if (indent === 0) return undefined;
if (childIndent === -1) childIndent = indent;
if (indent !== childIndent) continue;
const value = keyValue(inner, child);
if (value !== null) return cleanScalar(value);
}
return undefined;
}
return undefined;
}
const PAQAD_YAML_PATHS: Readonly<Record<string, readonly string[]>> = {
paqad_enable: ['enabled'],
hud_view: ['hud', 'band'],
hud_chime: ['hud', 'chime'],
};
const BOOLEAN_WORDS = new Set([...ENV_TRUTHY, ...FALSY]);
type LayeredInput = Pick<StandDownInput, 'localConfig' | 'teamConfigs' | 'paqadYaml' | 'today'>;
export const LEGACY_TEAM_CONFIG_READ_UNTIL = '2027-02-01';
function legacyReadable(input: LayeredInput): boolean {
return (input.today ?? new Date().toISOString().slice(0, 10)) <= LEGACY_TEAM_CONFIG_READ_UNTIL;
}
function yamlTeamValue(input: LayeredInput, key: string): string | undefined {
const path = PAQAD_YAML_PATHS[key];
if (path === undefined) return undefined;
const raw = readYamlScalar(input.paqadYaml, path);
return raw !== undefined && BOOLEAN_WORDS.has(raw.trim().toLowerCase()) ? raw : undefined;
}
export function teamSwitchProblems(input: LayeredInput): string[] {
const problems: string[] = [];
for (const path of Object.values(PAQAD_YAML_PATHS)) {
const raw = readYamlScalar(input.paqadYaml, path);
if (raw !== undefined && !BOOLEAN_WORDS.has(raw.trim().toLowerCase())) {
problems.push(
`.paqad/configs/paqad.yaml: ${path.join('.')} has a value paqad cannot read (${raw}), so paqad uses its default.`,
);
}
}
return problems;
}
function layeredValue(input: LayeredInput, key: string, fromEnv: string | undefined) {
let value: string | undefined = yamlTeamValue(input, key);
const team = input.teamConfigs
.filter((file) => /^\.config\..+/.test(file.name) && file.name !== '.config.example')
.filter((file) => file.text.split(/\r?\n/, 1)[0]?.trim() !== '# paqad-migrated-stub')
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
if (value === undefined && legacyReadable(input)) {
for (const file of team) {
const fromFile = readConfigKey(file.text, key);
if (fromFile !== undefined) value = fromFile;
}
}
const local = readConfigKey(input.localConfig, key);
if (local !== undefined) value = local;
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') value = fromEnv.trim();
return value;
}
function isFalsy(value: string | undefined): boolean {
return value !== undefined && FALSY.has(value.trim().toLowerCase());
}
export function isPaqadOff(
input: Pick<StandDownInput, 'env' | 'localConfig' | 'teamConfigs' | 'paqadYaml' | 'today'>,
) {
const disabled = input.env.PAQAD_DISABLED;
if (typeof disabled === 'string' && ENV_TRUTHY.has(disabled.trim().toLowerCase())) return true;
return isFalsy(layeredValue(input, 'paqad_enable', input.env.PAQAD_ENABLE));
}
export function isHudViewOff(
input: Pick<StandDownInput, 'env' | 'localConfig' | 'teamConfigs' | 'paqadYaml' | 'today'>,
) {
return isFalsy(layeredValue(input, 'hud_view', input.env.PAQAD_HUD_VIEW));
}
export function isChimeOn(
input: Pick<StandDownInput, 'env' | 'localConfig' | 'teamConfigs' | 'paqadYaml' | 'today'>,
) {
const value = layeredValue(input, 'hud_chime', input.env.PAQAD_HUD_CHIME);
return value !== undefined && ENV_TRUTHY.has(value.trim().toLowerCase());
}
function releaseParts(text: string | undefined): number[] | null {
const match = typeof text === 'string' ? text.match(/^(\d+)\.(\d+)\.(\d+)/) : null;
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
}
export function isEngineNewEnough(engine: StandDownInput['engine']): boolean {
const have = releaseParts(engine?.base) ?? releaseParts(engine?.version);
const need = releaseParts(MIN_ENGINE)!;
if (!have) return false;
for (let i = 0; i < 3; i += 1) {
if (have[i]! !== need[i]!) return have[i]! > need[i]!;
}
return true;
}
export function standDownReason(input: StandDownInput): PaqadStandDown | null {
if (isHudViewOff(input)) return 'view-off';
if (!input.onboarded) return 'not-onboarded';
if (input.env.CLAUDE_CODE_REMOTE?.trim().toLowerCase() === 'true') return 'remote';
if (isPaqadOff(input)) return 'disabled';
if (!isEngineNewEnough(input.engine)) return 'old-engine';
return null;
}
export function decideStatus(input: StandDownInput): PaqadStatus {
const reason = standDownReason(input);
return { on: reason === null, reason };
}
export function registersCommand(status: PaqadStatus): boolean {
return status.on || status.reason === 'not-onboarded';
}
function withoutTrailingSeparator(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '');
return trimmed === '' || /^[A-Za-z]:$/.test(trimmed) ? path : trimmed;
}
export function homeOf(env: { HOME?: string; USERPROFILE?: string }): string | null {
for (const value of [env.HOME, env.USERPROFILE]) {
if (typeof value === 'string' && value.trim() !== '') return withoutTrailingSeparator(value);
}
return null;
}
export function ancestorDirs(root: string, home: string | null = null): string[] {
const stop = home === null ? null : withoutTrailingSeparator(home);
const dirs: string[] = [];
let dir = root;
for (;;) {
if (stop !== null && withoutTrailingSeparator(dir) === stop) return dirs;
dirs.push(dir);
const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'));
if (cut < 0) return dirs;
const parent = cut === 0 || dir[cut - 1] === ':' ? dir.slice(0, cut + 1) : dir.slice(0, cut);
if (parent === dir) return dirs;
dir = parent;
}
}
export async function nextSessionRoot(
opened: boolean,
bound: string | null,
readRoot: () => Promise<string>,
): Promise<string | null> {
if (!opened) return null;
let start: string;
try {
start = await readRoot();
} catch {
return null;
}
return start === bound ? null : start;
}