import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { SddArtifactStore } from "./sdd-status.ts"; export type { SddArtifactStore }; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ASSETS_DIR = join(PACKAGE_ROOT, "assets"); const MANAGED_ASSETS_MANIFEST = "managed-assets.json"; const MANAGED_ASSETS_SCHEMA_VERSION = 1; const LEGACY_MANAGED_ASSET_MANIFESTS = Object.freeze([ { path: join(ASSETS_DIR, "migrations", "managed-assets-v0.13.json"), version: "0.13.0" }, { path: join(ASSETS_DIR, "migrations", "managed-assets-v0.14.json"), version: "0.14.0" }, ]); function gentlePiAgentHome(): string { return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent"); } export type SddExecutionMode = "interactive" | "auto"; export type SddChainedPrStrategy = | "auto-forecast" | "ask-always" | "single-pr-default" | "force-chained"; export interface SddPreflightPreferences { executionMode: SddExecutionMode; artifactStore: SddArtifactStore; chainedPrStrategy: SddChainedPrStrategy; reviewBudgetLines: number; engramAvailable: boolean; prompted: boolean; } interface SddPreflightCallbacks { pi: ExtensionAPI; installAssets?: (cwd: string) => | { agents: number; chains: number; support: number; skipped: number; } | Promise<{ agents: number; chains: number; support: number; skipped: number; }>; applyModelConfig?: ( cwd: string, ) => | { updated: number; skipped: number; invalidPath?: string } | Promise<{ updated: number; skipped: number; invalidPath?: string }>; } interface ManagedAssetsManifest { schemaVersion: number; assets: Record; } interface LegacyManagedAssetsManifest extends ManagedAssetsManifest { packageVersion: string; } const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = { executionMode: "interactive", artifactStore: "openspec", chainedPrStrategy: "auto-forecast", reviewBudgetLines: 400, engramAvailable: false, prompted: false, }; const sddPreflightBySession = new Map(); const sddPreflightInFlight = new Map>(); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function emptyManagedAssetsManifest(): ManagedAssetsManifest { return { schemaVersion: MANAGED_ASSETS_SCHEMA_VERSION, assets: {}, }; } function readManagedAssetsManifest(path: string): ManagedAssetsManifest { if (!existsSync(path)) return emptyManagedAssetsManifest(); try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if ( !isRecord(parsed) || parsed.schemaVersion !== MANAGED_ASSETS_SCHEMA_VERSION || !isRecord(parsed.assets) ) { return emptyManagedAssetsManifest(); } const assets = Object.fromEntries( Object.entries(parsed.assets).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ); return { schemaVersion: MANAGED_ASSETS_SCHEMA_VERSION, assets }; } catch { return emptyManagedAssetsManifest(); } } function managedAssetHash(content: string): string { return createHash("sha256").update(content).digest("hex"); } function readLegacyManagedAssets( path: string, version: string, ): LegacyManagedAssetsManifest | undefined { try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if ( !isRecord(parsed) || parsed.schemaVersion !== MANAGED_ASSETS_SCHEMA_VERSION || parsed.packageVersion !== version || !isRecord(parsed.assets) ) { return undefined; } const assets = Object.fromEntries( Object.entries(parsed.assets).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ); return { schemaVersion: MANAGED_ASSETS_SCHEMA_VERSION, packageVersion: version, assets, }; } catch { return undefined; } } function readLegacyManagedAssetHashes(): Record { const hashes: Record = {}; for (const manifest of LEGACY_MANAGED_ASSET_MANIFESTS) { const assets = readLegacyManagedAssets(manifest.path, manifest.version)?.assets; if (!assets) continue; for (const [ownershipKey, hash] of Object.entries(assets)) { const known = hashes[ownershipKey] ?? []; if (!known.includes(hash)) known.push(hash); hashes[ownershipKey] = known; } } return hashes; } function updateAgentFrontmatterRouting( content: string, routingLines: readonly string[], ): string { if (!content.startsWith("---\n")) return content; const endIndex = content.indexOf("\n---", 4); if (endIndex === -1) return content; const frontmatter = content.slice(4, endIndex); const body = content.slice(endIndex); const lines = frontmatter .split("\n") .filter((line) => !/^(?:model|thinking):/.test(line)); if (routingLines.length > 0) { const descriptionIndex = lines.findIndex((line) => line.startsWith("description:"), ); const insertIndex = descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length); lines.splice(insertIndex, 0, ...routingLines); } return `---\n${lines.join("\n")}${body}`; } function legacyComparableAssetContent( ownershipKey: string, content: string, ): string { return ownershipKey.startsWith("agents/") ? updateAgentFrontmatterRouting(content, []) : content; } function migrateLegacyAssetContent( ownershipKey: string, installedContent: string, packagedContent: string, ): string { if (!ownershipKey.startsWith("agents/")) return packagedContent; if (!installedContent.startsWith("---\n")) return packagedContent; const endIndex = installedContent.indexOf("\n---", 4); if (endIndex === -1) return packagedContent; const routingLines = installedContent .slice(4, endIndex) .split("\n") .filter((line) => /^(?:model|thinking):/.test(line)); return updateAgentFrontmatterRouting(packagedContent, routingLines); } export function updatePackageManagedSddAgentOwnership( installedPath: string, previousContent: string, nextContent: string, ): boolean { const agentHome = gentlePiAgentHome(); const relativePath = relative(join(agentHome, "agents"), installedPath); if ( relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath) ) { return false; } const ownershipKey = `agents/${relativePath.split(sep).join("/")}`; const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST); const manifest = readManagedAssetsManifest(manifestPath); if (manifest.assets[ownershipKey] !== managedAssetHash(previousContent)) { return false; } try { if (readFileSync(installedPath, "utf8") !== nextContent) return false; manifest.assets[ownershipKey] = managedAssetHash(nextContent); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); return true; } catch { return false; } } export function isPackageManagedSddAsset( installedPath: string, ownershipKey: string, ): boolean { const manifest = readManagedAssetsManifest( join(gentlePiAgentHome(), "gentle-ai", MANAGED_ASSETS_MANIFEST), ); const expectedHash = manifest.assets[ownershipKey]; if (expectedHash === undefined || !existsSync(installedPath)) return false; try { return ( managedAssetHash(readFileSync(installedPath, "utf8")) === expectedHash ); } catch { return false; } } // --------------------------------------------------------------------------- // Durable store — survives restarts, resumed sessions, and non-SDD agent starts // --------------------------------------------------------------------------- export function sddPreflightDiskPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "sdd-preflight.json"); } export function readSddPreflightFromDisk(cwd: string): SddPreflightPreferences | undefined { const path = sddPreflightDiskPath(cwd); if (!existsSync(path)) return undefined; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) return undefined; // Validate required fields to guard against stale/corrupt writes const { executionMode, artifactStore, chainedPrStrategy, reviewBudgetLines, engramAvailable, prompted } = parsed; if ( (executionMode !== "interactive" && executionMode !== "auto") || (artifactStore !== "openspec" && artifactStore !== "engram" && artifactStore !== "both" && artifactStore !== "none") || typeof reviewBudgetLines !== "number" || typeof engramAvailable !== "boolean" || typeof prompted !== "boolean" ) { return undefined; } const normalizedChain: SddChainedPrStrategy = chainedPrStrategy === "ask-always" || chainedPrStrategy === "single-pr-default" || chainedPrStrategy === "force-chained" ? (chainedPrStrategy as SddChainedPrStrategy) : "auto-forecast"; return { executionMode, artifactStore, chainedPrStrategy: normalizedChain, reviewBudgetLines, engramAvailable, prompted, }; } catch { return undefined; } } export function writeSddPreflightToDisk(cwd: string, prefs: SddPreflightPreferences): void { try { const path = sddPreflightDiskPath(cwd); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(prefs, null, 2)); } catch { // Disk write failures are non-fatal; in-memory cache is the primary store } } function copyDirectoryFiles( sourceDir: string, targetDir: string, ownershipPrefix: string, force: boolean, manifest: ManagedAssetsManifest, legacyAssetHashes: Readonly> | undefined, ): { copied: number; skipped: number } { if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 }; mkdirSync(targetDir, { recursive: true }); let copied = 0; let skipped = 0; for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name); const targetPath = join(targetDir, entry.name); const ownershipKey = `${ownershipPrefix}/${entry.name}`; if (entry.isDirectory()) { const child = copyDirectoryFiles( sourcePath, targetPath, ownershipKey, force, manifest, legacyAssetHashes, ); copied += child.copied; skipped += child.skipped; continue; } if (!entry.isFile()) continue; const source = readFileSync(sourcePath, "utf8"); let nextSource = source; if (existsSync(targetPath)) { if (!force) { skipped += 1; continue; } const managedHash = manifest.assets[ownershipKey]; let installedContent: string | undefined; try { installedContent = readFileSync(targetPath, "utf8"); } catch { installedContent = undefined; } const installedHash = installedContent === undefined ? undefined : managedAssetHash(installedContent); if (managedHash === undefined) { const legacyHashes = legacyAssetHashes?.[ownershipKey]; const comparableLegacyHash = installedContent === undefined ? undefined : managedAssetHash( legacyComparableAssetContent(ownershipKey, installedContent), ); if ( legacyHashes === undefined || comparableLegacyHash === undefined || !legacyHashes.includes(comparableLegacyHash) ) { delete manifest.assets[ownershipKey]; skipped += 1; continue; } nextSource = migrateLegacyAssetContent( ownershipKey, installedContent, source, ); } else if (installedHash !== managedHash) { delete manifest.assets[ownershipKey]; skipped += 1; continue; } } writeFileSync(targetPath, nextSource); manifest.assets[ownershipKey] = managedAssetHash(nextSource); copied += 1; } return { copied, skipped }; } export function installSddAssets( _cwd: string, force: boolean, ): { agents: number; chains: number; support: number; skipped: number } { const agentHome = gentlePiAgentHome(); const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST); const legacyAssetHashes = force && !existsSync(manifestPath) ? readLegacyManagedAssetHashes() : undefined; const manifest = readManagedAssetsManifest(manifestPath); const agents = copyDirectoryFiles( join(ASSETS_DIR, "agents"), join(agentHome, "agents"), "agents", force, manifest, legacyAssetHashes, ); const chains = copyDirectoryFiles( join(ASSETS_DIR, "chains"), join(agentHome, "chains"), "chains", force, manifest, legacyAssetHashes, ); const support = copyDirectoryFiles( join(ASSETS_DIR, "support"), join(agentHome, "gentle-ai", "support"), "gentle-ai/support", force, manifest, legacyAssetHashes, ); mkdirSync(dirname(manifestPath), { recursive: true }); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); return { agents: agents.copied, chains: chains.copied, support: support.copied, skipped: agents.skipped + chains.skipped + support.skipped, }; } export function isSddPreflightTrigger(text: string): boolean { const trimmed = text.trim(); if (/^\/sdd(?:[-:][^\s]*)?(?:\s|$)/i.test(trimmed)) return true; if (/[??]\s*$/.test(trimmed)) return false; if ( /\b(?:don't|do\s+not|not\s+use|never\s+use|without\s+using|sin\s+usar|no\s+(?:quiero|queremos|vamos\s+a)?\s*usar)\s+sdd\b/i.test( trimmed, ) ) { return false; } return [ /^(?:please\s+)?(?:use|run|start)\s+(?:the\s+|an?\s+)?sdd(?:\s+(?:flow|process|workflow|plan))?\b/i, /^(?:please\s+)?(?:do|handle|implement)\b.+\b(?:with|using)\s+(?:the\s+|an?\s+)?sdd\b/i, /^(?:por\s+favor[\s,]+)?(?:vamos|vayamos)\s+con\s+(?:el\s+)?sdd\b/i, /^(?:por\s+favor[\s,]+)?(?:usa|usá|usemos|corre|corré|arranca|arrancá|inicia|iniciá|empeza|empezá)\s+(?:el\s+)?sdd\b/i, /^(?:por\s+favor[\s,]+)?(?:hacelo|hazlo|hacerlo)\s+(?:con|usando)\s+(?:el\s+)?sdd\b/i, ].some((pattern) => pattern.test(trimmed)); } export function sddPreflightSessionKey(ctx: ExtensionContext): string { const manager = (ctx as unknown as { sessionManager?: unknown }).sessionManager; if (isRecord(manager)) { const getSessionFile = manager.getSessionFile; if (typeof getSessionFile === "function") { const value = getSessionFile.call(manager); if (typeof value === "string" && value.length > 0) return value; } const getSessionId = manager.getSessionId; if (typeof getSessionId === "function") { const value = getSessionId.call(manager); if (typeof value === "string" && value.length > 0) return value; } } return ctx.cwd; } function hasWritableEngramTool(pi: ExtensionAPI): boolean { try { const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] }) .getActiveTools; if (typeof getActiveTools !== "function") return false; const tools = getActiveTools.call(pi); return tools.some((tool) => { const name = typeof tool === "string" ? tool : isRecord(tool) && typeof tool.name === "string" ? tool.name : ""; return name === "mem_save" || name.endsWith(".mem_save"); }); } catch { return false; } } function normalizeSddReviewBudget(value: string): number { const parsed = Number.parseInt(value.trim(), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 400; } async function collectSddPreflightPreferences( ctx: ExtensionContext, engramAvailable: boolean, ): Promise { if (!ctx.hasUI) return { ...DEFAULT_SDD_PREFLIGHT, engramAvailable }; const executionMode = await ctx.ui.select("SDD execution mode", [ "interactive", "auto", ]); const artifactOptions = engramAvailable ? ["openspec", "engram", "both"] : ["openspec"]; const artifactStore = await ctx.ui.select("SDD artifact store", artifactOptions); const chainedPrStrategy = await ctx.ui.select("SDD PR chaining", [ "auto-forecast", "ask-always", "single-pr-default", "force-chained", ]); const reviewBudgetLines = normalizeSddReviewBudget( (await ctx.ui.input("SDD review budget lines", "400")) ?? "400", ); return { executionMode: executionMode === "auto" ? "auto" : DEFAULT_SDD_PREFLIGHT.executionMode, artifactStore: artifactStore === "engram" || artifactStore === "both" ? artifactStore : DEFAULT_SDD_PREFLIGHT.artifactStore, chainedPrStrategy: chainedPrStrategy === "ask-always" || chainedPrStrategy === "single-pr-default" || chainedPrStrategy === "force-chained" ? chainedPrStrategy : DEFAULT_SDD_PREFLIGHT.chainedPrStrategy, reviewBudgetLines, engramAvailable, prompted: true, }; } export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string { const sourceLine = prefs.prompted ? "The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them." : "No interactive UI was available for SDD preflight, so these default preferences were applied for this Pi session. Ask the user before making delivery decisions that depend on them."; const interactiveRules = prefs.executionMode === "interactive" ? [ "- Interactive phase gate: complete only the current SDD phase. Do not start the next SDD phase unless the current user turn explicitly approves that next phase.", "- In interactive mode, words like `continue`, `dale`, or `go on` approve only the immediate next phase, not all remaining phases.", "- Before writing an SDD proposal in interactive mode, offer the user a proposal question round to improve the PRD/proposal by uncovering business rules, implications, impact, edge cases, product tradeoffs, and decision gaps. Prefer 3–5 concrete product questions per round, then summarize assumptions and ask whether the user wants corrections or a second question round. Do not ask about test commands, PR shape, changed-line budget, or other harness mechanics at proposal time unless the user explicitly asks to discuss delivery.", ] : [ "- Auto mode: phases may run back-to-back only because the user chose speed and trusts the flow.", ]; return [ "## SDD Session Preflight", sourceLine, `- Execution mode: ${prefs.executionMode}`, `- Artifact store: ${prefs.artifactStore}${prefs.engramAvailable ? "" : " (Engram unavailable in this session)"}`, `- Chained PR strategy: ${prefs.chainedPrStrategy}`, `- Review budget: ${prefs.reviewBudgetLines} changed lines`, ...interactiveRules, "- If task/workload forecasts conflict with these preferences, pause before sdd-apply and ask the user for a delivery decision.", ].join("\n"); } export async function ensureSddPreflight( ctx: ExtensionContext, callbacks: SddPreflightCallbacks, ): Promise { const sessionKey = sddPreflightSessionKey(ctx); const existing = sddPreflightBySession.get(sessionKey); if (existing) return existing; const inFlight = sddPreflightInFlight.get(sessionKey); if (inFlight) return inFlight; const promise = (async () => { const engramAvailable = hasWritableEngramTool(callbacks.pi); const prefs = await collectSddPreflightPreferences(ctx, engramAvailable); const result = (await callbacks.installAssets?.(ctx.cwd)) ?? installSddAssets(ctx.cwd, false); const modelResult = (await callbacks.applyModelConfig?.(ctx.cwd)) ?? { updated: 0, skipped: 0, }; if (ctx.hasUI) { const modelRoutingLine = modelResult.invalidPath ? `Model routing skipped: ${modelResult.invalidPath} is invalid JSON or not an object.` : `Model-routed agents updated: ${modelResult.updated}`; ctx.ui.notify( [ "Gentle AI SDD preflight complete.", `Mode: ${prefs.executionMode}`, `Artifacts: ${prefs.artifactStore}`, `PR chaining: ${prefs.chainedPrStrategy}`, `Review budget: ${prefs.reviewBudgetLines} changed lines`, `Preference source: ${prefs.prompted ? "user prompt" : "defaults (no interactive UI available)"}`, `Global SDD assets ready: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`, modelRoutingLine, ].join("\n"), modelResult.invalidPath ? "warning" : "info", ); } sddPreflightBySession.set(sessionKey, prefs); writeSddPreflightToDisk(ctx.cwd, prefs); return prefs; })(); sddPreflightInFlight.set(sessionKey, promise); try { return await promise; } finally { sddPreflightInFlight.delete(sessionKey); } } export function getSddPreflightPreferences( ctx: ExtensionContext, ): SddPreflightPreferences | undefined { const sessionKey = sddPreflightSessionKey(ctx); const cached = sddPreflightBySession.get(sessionKey); if (cached) return cached; // Cache miss: check the durable disk store (survives restarts and non-SDD agent starts) const persisted = readSddPreflightFromDisk(ctx.cwd); if (persisted) { sddPreflightBySession.set(sessionKey, persisted); return persisted; } return undefined; }