import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { LifecycleGitService, type ILifecycleGitService, } from '../lifecycle/gitService'; import type { SddSessionState } from './types'; const SDD_KEYS = { active: 'pumuki.sdd.session.active', change: 'pumuki.sdd.session.change', updatedAt: 'pumuki.sdd.session.updatedAt', expiresAt: 'pumuki.sdd.session.expiresAt', ttlMinutes: 'pumuki.sdd.session.ttlMinutes', } as const; const DEFAULT_TTL_MINUTES = 45; const TRACKING_CANDIDATE_FILES = [ 'docs/RURALGO_SEGUIMIENTO.md', 'RURALGO_SEGUIMIENTO.md', ] as const; const resolveRepoRoot = (cwd: string, git: ILifecycleGitService): string => git.resolveRepoRoot(cwd); const nowIso = (): string => new Date().toISOString(); const addMinutesIso = (minutes: number): string => new Date(Date.now() + minutes * 60_000).toISOString(); const parsePositiveMinutes = (value?: number): number => Number.isFinite(value) && (value as number) > 0 ? Math.floor(value as number) : DEFAULT_TTL_MINUTES; const normalizeChangeId = (value: string): string => value.trim().toLowerCase(); const collectActiveTrackingChangeIds = (markdown: string): ReadonlyArray => { const changeIds: string[] = []; const lines = markdown.split(/\r?\n/u); for (const line of lines) { const boardRowMatch = line.match(/^\|\s*🚧\s*\|\s*([A-Z0-9-]+)\s*\|/u); if (boardRowMatch?.[1]) { changeIds.push(normalizeChangeId(boardRowMatch[1])); continue; } const tableMatch = line.match( /^\|\s*\d+\s*\|\s*`([^`]+)`\s*\|.*\|\s*🚧(?:\s+reported\s+activo|\s+En construcción|\s+En construccion)?\s*\|/u ); if (tableMatch?.[1]) { changeIds.push(normalizeChangeId(tableMatch[1])); continue; } const bulletMatch = line.match(/^- 🚧 (`?(?:P[A-Z0-9.-]*|RGO-\d[0-9.-]*)`?)/u); if (bulletMatch?.[1]) { changeIds.push(normalizeChangeId(bulletMatch[1].replace(/`/gu, ''))); } } return changeIds.filter((changeId) => changeId.length > 0); }; const resolveSingleActiveTrackingChangeId = (repoRoot: string): string | undefined => { for (const candidate of TRACKING_CANDIDATE_FILES) { const candidatePath = resolve(repoRoot, candidate); if (!existsSync(candidatePath)) { continue; } const changeIds = collectActiveTrackingChangeIds(readFileSync(candidatePath, 'utf8')); const uniqueChangeIds = [...new Set(changeIds)]; if (uniqueChangeIds.length === 1) { return uniqueChangeIds[0]; } if (uniqueChangeIds.length > 1) { throw new Error( `Multiple active tracking changes found in ${candidate}: ${uniqueChangeIds.join(', ')}. ` + 'Keep exactly one active task before refreshing the SDD session.' ); } } return undefined; }; export const listActiveOpenSpecChangeIds = (repoRoot: string): ReadonlyArray => { const changesRoot = resolve(repoRoot, 'openspec', 'changes'); if (!existsSync(changesRoot)) { return []; } try { return readdirSync(changesRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => normalizeChangeId(entry.name)) .filter((changeId) => changeId.length > 0 && changeId !== 'archive' && !changeId.startsWith('.')) .sort(); } catch { return []; } }; const resolveAutoChangeId = (repoRoot: string): string => { const activeChanges = listActiveOpenSpecChangeIds(repoRoot); if (activeChanges.length === 1) { return activeChanges[0] ?? ''; } if (activeChanges.length === 0) { throw new Error( 'No active OpenSpec change was found. Create one and run `pumuki sdd session --open --change=`.' ); } throw new Error( `Multiple active OpenSpec changes found (${activeChanges.join(', ')}). ` + 'Run `pumuki sdd session --open --change=` with an explicit change.' ); }; const computeValidity = (expiresAt?: string): { valid: boolean; remainingSeconds?: number; } => { if (!expiresAt) { return { valid: false }; } const target = new Date(expiresAt).getTime(); if (!Number.isFinite(target)) { return { valid: false }; } const remaining = Math.floor((target - Date.now()) / 1000); if (remaining <= 0) { return { valid: false, remainingSeconds: 0 }; } return { valid: true, remainingSeconds: remaining }; }; const readConfig = ( repoRoot: string, git: ILifecycleGitService ): SddSessionState => { const rawActive = git.localConfig(repoRoot, SDD_KEYS.active) === 'true'; const rawChangeId = git.localConfig(repoRoot, SDD_KEYS.change); const changeId = typeof rawChangeId === 'string' && rawChangeId.trim().length > 0 ? normalizeChangeId(rawChangeId) : undefined; const updatedAt = git.localConfig(repoRoot, SDD_KEYS.updatedAt) ?? undefined; const expiresAt = git.localConfig(repoRoot, SDD_KEYS.expiresAt) ?? undefined; const ttlRaw = git.localConfig(repoRoot, SDD_KEYS.ttlMinutes); const ttlMinutes = typeof ttlRaw === 'string' && ttlRaw.trim().length > 0 ? Number.parseInt(ttlRaw, 10) : undefined; const validity = computeValidity(expiresAt); const active = rawActive && !!changeId && validity.valid; return { repoRoot, active, changeId, updatedAt, expiresAt, ttlMinutes, valid: active && !!changeId && validity.valid, remainingSeconds: validity.remainingSeconds, }; }; const ensureChangePath = (repoRoot: string, changeId: string): { exists: boolean; archived: boolean; } => { const activePath = resolve(repoRoot, 'openspec', 'changes', changeId); const archivedPath = resolve(repoRoot, 'openspec', 'changes', 'archive', changeId); return { exists: existsSync(activePath), archived: existsSync(archivedPath), }; }; export const readSddSession = ( cwd = process.cwd(), git: ILifecycleGitService = new LifecycleGitService() ): SddSessionState => { const repoRoot = resolveRepoRoot(cwd, git); return readConfig(repoRoot, git); }; export const openSddSession = (params: { changeId: string; ttlMinutes?: number; cwd?: string; git?: ILifecycleGitService; }): SddSessionState => { const git = params.git ?? new LifecycleGitService(); const repoRoot = resolveRepoRoot(params.cwd ?? process.cwd(), git); const requestedChangeId = normalizeChangeId(params.changeId); if (requestedChangeId.length === 0) { throw new Error('OpenSpec change id is required.'); } const changeId = requestedChangeId === 'auto' ? resolveAutoChangeId(repoRoot) : requestedChangeId; const changeState = ensureChangePath(repoRoot, changeId); if (!changeState.exists) { throw new Error(`OpenSpec change "${changeId}" not found in openspec/changes.`); } if (changeState.archived) { throw new Error(`OpenSpec change "${changeId}" is archived and cannot be used as active SDD session.`); } const ttlMinutes = parsePositiveMinutes(params.ttlMinutes); git.applyLocalConfig(repoRoot, SDD_KEYS.active, 'true'); git.applyLocalConfig(repoRoot, SDD_KEYS.change, changeId); git.applyLocalConfig(repoRoot, SDD_KEYS.updatedAt, nowIso()); git.applyLocalConfig(repoRoot, SDD_KEYS.expiresAt, addMinutesIso(ttlMinutes)); git.applyLocalConfig(repoRoot, SDD_KEYS.ttlMinutes, String(ttlMinutes)); return readConfig(repoRoot, git); }; export const refreshSddSession = (params?: { ttlMinutes?: number; cwd?: string; git?: ILifecycleGitService; }): SddSessionState => { const git = params?.git ?? new LifecycleGitService(); const repoRoot = resolveRepoRoot(params?.cwd ?? process.cwd(), git); const current = readConfig(repoRoot, git); if (!current.changeId) { throw new Error('No active SDD session to refresh. Run `pumuki sdd session --open --change=` first.'); } const trackingChangeId = resolveSingleActiveTrackingChangeId(repoRoot); if (trackingChangeId && trackingChangeId !== current.changeId) { const trackingChangeState = ensureChangePath(repoRoot, trackingChangeId); if (!trackingChangeState.exists) { throw new Error( `Active tracking change "${trackingChangeId}" does not exist in openspec/changes. ` + `Current SDD session points to "${current.changeId}". ` + `Create openspec/changes/${trackingChangeId} or run ` + `\`pumuki sdd session --open --change=${trackingChangeId}\` after creating it.` ); } if (trackingChangeState.archived) { throw new Error( `Active tracking change "${trackingChangeId}" is archived and cannot refresh the SDD session.` ); } git.applyLocalConfig(repoRoot, SDD_KEYS.change, trackingChangeId); } const ttlMinutes = parsePositiveMinutes(params?.ttlMinutes ?? current.ttlMinutes); git.applyLocalConfig(repoRoot, SDD_KEYS.updatedAt, nowIso()); git.applyLocalConfig(repoRoot, SDD_KEYS.expiresAt, addMinutesIso(ttlMinutes)); git.applyLocalConfig(repoRoot, SDD_KEYS.ttlMinutes, String(ttlMinutes)); return readConfig(repoRoot, git); }; export const closeSddSession = ( cwd = process.cwd(), git: ILifecycleGitService = new LifecycleGitService() ): SddSessionState => { const repoRoot = resolveRepoRoot(cwd, git); git.clearLocalConfig(repoRoot, SDD_KEYS.active); git.clearLocalConfig(repoRoot, SDD_KEYS.change); git.clearLocalConfig(repoRoot, SDD_KEYS.updatedAt); git.clearLocalConfig(repoRoot, SDD_KEYS.expiresAt); git.clearLocalConfig(repoRoot, SDD_KEYS.ttlMinutes); return readConfig(repoRoot, git); };