import { existsSync, readFileSync, renameSync, writeFileSync, readdirSync } from 'node:fs'; import { join, basename } from 'node:path'; import type { WorkflowConfig } from '../types.js'; import { ensureDir } from '../lib/fs.js'; import { findDocById } from '../id-guard.js'; import { generateIndex, generateWikiIndex } from '../generate-index.js'; // --------------------------------------------------------------------------- // Status / directory mapping per doc kind // --------------------------------------------------------------------------- const TASK_STATUS_DIRS: Record = { backlog: 'backlog', 'in-progress': 'inProgress', review: 'review', completed: 'completed', suspended: 'suspended', terminated: 'terminated', archived: 'archived', }; const TASK_VALID_TRANSITIONS: Record = { backlog: ['in-progress'], 'in-progress': ['review', 'suspended'], review: ['completed', 'in-progress'], suspended: ['in-progress'], completed: ['archived'], }; const ADR_STATUS_DIRS: Record = { proposed: 'proposed', accepted: 'accepted', rejected: 'rejected', superseded: 'superseded', }; // ADR is a one-way commit from proposed; once decided we don't auto-revert. const ADR_VALID_TRANSITIONS: Record = { proposed: ['accepted', 'rejected', 'superseded'], accepted: ['superseded'], rejected: [], superseded: [], }; const EPIC_STATUS_DIRS: Record = { active: 'active', done: 'done', suspended: 'suspended', archived: 'archived', }; const EPIC_VALID_TRANSITIONS: Record = { active: ['done', 'suspended'], suspended: ['active'], done: ['archived'], archived: [], }; interface DocKindConfig { prefix: string; baseDir: (config: WorkflowConfig) => string; subdirs: (config: WorkflowConfig) => Record; statusDirs: Record; validTransitions: Record; label: string; } const TASK_KIND: DocKindConfig = { prefix: 'TASK-', baseDir: c => c.tasksDir, subdirs: c => c.taskSubdirs, statusDirs: TASK_STATUS_DIRS, validTransitions: TASK_VALID_TRANSITIONS, label: 'Task', }; const ADR_KIND: DocKindConfig = { prefix: 'ADR-', baseDir: c => c.adrDir, subdirs: c => c.adrSubdirs, statusDirs: ADR_STATUS_DIRS, validTransitions: ADR_VALID_TRANSITIONS, label: 'ADR', }; const EPIC_KIND: DocKindConfig = { prefix: 'EPIC-', baseDir: c => c.epicsDir, subdirs: c => c.epicSubdirs, statusDirs: EPIC_STATUS_DIRS, validTransitions: EPIC_VALID_TRANSITIONS, label: 'Epic', }; // --------------------------------------------------------------------------- // Generic helpers // --------------------------------------------------------------------------- function findDocFile( docId: string, config: WorkflowConfig, kind: DocKindConfig ): { path: string; status: string } | null { const searchId = docId.startsWith(kind.prefix) ? docId : `${kind.prefix}${docId}`; const subdirs = kind.subdirs(config); const baseDir = kind.baseDir(config); for (const [status, subdirKey] of Object.entries(kind.statusDirs)) { const subdirName = subdirs[subdirKey as keyof typeof subdirs]; if (!subdirName) continue; const dir = join(baseDir, subdirName); if (!existsSync(dir)) continue; // Use findDocById for exact prefix matching (CWE-1104: substring collision) const found = findDocById(dir, searchId) if (found) { return { path: found, status } } } return null; } function appendStatusHistory(content: string, status: string, note: string): string { const today = new Date().toISOString().split('T')[0]; const newLine = `| ${status} | ${today} | ${note} |`; const sectionMatch = content.match(/(##\s+Status\s+History\s*\n[\s\S]*?)(\n##\s+|\n#{1,2}\s|$)/i); if (!sectionMatch) { return content + `\n\n| ${status} | ${today} | ${note} |\n`; } const section = sectionMatch[1]; const lines = section.split('\n'); let lastTableRow = -1; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (line.startsWith('|')) { if (/^\|[-\s|]+\|$/.test(line)) continue; lastTableRow = i; break; } } if (lastTableRow === -1) { return content.replace(section, section + '\n' + newLine); } lines.splice(lastTableRow + 1, 0, newLine); const newSection = lines.join('\n'); return content.replace(section, newSection); } interface MoveOptions { note?: string; allowAny?: boolean; } function moveDoc( docId: string, targetStatus: string, config: WorkflowConfig, kind: DocKindConfig, options: MoveOptions = {} ): void { const found = findDocFile(docId, config, kind); if (!found) { const labelLower = kind.label.toLowerCase(); console.error(`❌ ${kind.label} not found: ${docId} Searched all status subdirectories under ${kind.baseDir(config)}. Common causes: • Typo in ID — copy from \`bunx @lythos/project-cortex list\` • ID without \`${kind.prefix}\` prefix — both forms are accepted (e.g. ${kind.prefix}20260509121724330 and 20260509121724330) • ${kind.label} already archived/terminated and ${labelLower} dirs differ between repo checkouts To list all ${labelLower}s by status: bunx @lythos/project-cortex list`); process.exit(1); } const currentStatus = found.status; if (currentStatus === targetStatus) { console.log(`ℹ️ ${kind.label} ${docId} is already in "${currentStatus}" — no-op.`); return; } const allowedTargets = kind.validTransitions[currentStatus] || []; if (!options.allowAny && !allowedTargets.includes(targetStatus)) { const labelLower = kind.label.toLowerCase(); const anyStatusVerbs = kind.label === 'Task' ? " • For any-status close use 'complete' (trailer-driven), 'terminate', or 'archive' (post-completed)\n" : ''; console.error(`❌ Invalid transition for ${kind.label} ${docId}: ${currentStatus} → ${targetStatus} Allowed targets from "${currentStatus}": ${allowedTargets.join(', ') || '(none — terminal status)'} What you can do: ${anyStatusVerbs} • Move ${labelLower} through valid intermediate states first (e.g. backlog → in-progress → review → completed) • If the ${labelLower} truly needs to skip a step, use the corresponding any-status verb (above) — there is no \`--force\` flag.`); process.exit(1); } const subdirKey = kind.statusDirs[targetStatus]; if (!subdirKey) { console.error(`❌ Unknown ${kind.label.toLowerCase()} status: ${targetStatus}`); process.exit(1); } const subdirs = kind.subdirs(config) as Record; const subdirName = subdirs[subdirKey]; if (!subdirName) { console.error(`❌ Config missing subdir for ${kind.label.toLowerCase()} status: ${targetStatus}`); process.exit(1); } const destDir = join(kind.baseDir(config), subdirName); const destPath = join(destDir, basename(found.path)); const content = readFileSync(found.path, 'utf-8'); const note = options.note || targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1); const updatedContent = appendStatusHistory(content, targetStatus, note); ensureDir(destDir); writeFileSync(found.path, updatedContent); renameSync(found.path, destPath); console.log(`✅ Moved: ${currentStatus} → ${targetStatus}`); console.log(` ${destPath}`); generateIndex(config); generateWikiIndex(config); console.log('📝 Regenerated INDEX.md and wiki/INDEX.md'); } // --------------------------------------------------------------------------- // Public per-kind helpers // --------------------------------------------------------------------------- export function moveTask( taskId: string, targetStatus: string, config: WorkflowConfig, options: MoveOptions = {} ): void { moveDoc(taskId, targetStatus, config, TASK_KIND, options); } export function moveAdr( adrId: string, targetStatus: string, config: WorkflowConfig, options: MoveOptions = {} ): void { moveDoc(adrId, targetStatus, config, ADR_KIND, options); } export function moveEpic( epicId: string, targetStatus: string, config: WorkflowConfig, options: MoveOptions = {} ): void { moveDoc(epicId, targetStatus, config, EPIC_KIND, options); }