import {readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync} from 'node:fs'; import {dirname, join, relative} from 'node:path'; import process from 'node:process'; const root = process.cwd(); const epicSourceDir = join(root, 'data/scope/epics'); const featureSourceDir = join(root, 'data/scope/features'); const check = process.argv.includes('--check'); const numericId = (id) => Number(id.split('-').at(-1)); const readRecords = (directory, recordPattern) => readdirSync(directory) .filter((name) => recordPattern.test(name)) .map((name) => JSON.parse(readFileSync(join(directory, name), 'utf8'))) .sort((left, right) => numericId(left.id) - numericId(right.id)); const epics = readRecords(epicSourceDir, /^3darch-ep-\d+\.yml$/); const features = readRecords(featureSourceDir, /^3darch-ft-\d+\.yml$/); if (epics.length !== 10) throw new Error(`Expected 10 Epic records, found ${epics.length}`); if (features.length !== 32) throw new Error(`Expected 32 Feature records, found ${features.length}`); const epicIds = new Set(); const featureIds = new Set(); for (const [index, epic] of epics.entries()) { const expectedId = `3DARCH-EP-${index + 1}`; if (epic.id !== expectedId) throw new Error(`Expected ${expectedId}, found ${epic.id}`); if (epicIds.has(epic.id)) throw new Error(`Duplicate Epic ID: ${epic.id}`); epicIds.add(epic.id); if (epic.type !== 'Epic' || epic.status_source !== 'DERIVED') throw new Error(`Invalid governed Epic state: ${epic.id}`); if (epic.approval?.approved_for_solution !== false) throw new Error(`Unexpected solution approval: ${epic.id}`); if (!epic.catalogue_decision?.evidence_url || !epic.last_transition?.evidence_url) throw new Error(`Missing Epic decision evidence: ${epic.id}`); if (!Array.isArray(epic.legacy_evidence) || epic.legacy_evidence.length === 0) throw new Error(`Missing Epic legacy evidence: ${epic.id}`); } for (const [index, feature] of features.entries()) { const expectedId = `3DARCH-FT-${index + 1}`; if (feature.id !== expectedId) throw new Error(`Expected ${expectedId}, found ${feature.id}`); if (featureIds.has(feature.id)) throw new Error(`Duplicate Feature ID: ${feature.id}`); featureIds.add(feature.id); if (feature.type !== 'Feature' || feature.status !== 'IN_BACKLOG' || feature.status_source !== 'EXACT') throw new Error(`Invalid governed Feature state: ${feature.id}`); if (!epicIds.has(feature.epic_id)) throw new Error(`Dangling Epic relationship: ${feature.id} -> ${feature.epic_id}`); if (feature.approval?.approved_for_solution !== false) throw new Error(`Unexpected solution approval: ${feature.id}`); for (const field of ['description', 'user_problem', 'expected_value', 'scope', 'owner']) if (!feature[field]) throw new Error(`Missing ${field}: ${feature.id}`); for (const field of ['exclusions', 'acceptance_outcomes', 'risks', 'legacy_evidence']) if (!Array.isArray(feature[field]) || feature[field].length === 0) throw new Error(`Missing ${field}: ${feature.id}`); if (feature.acceptance_outcomes.length < 3) throw new Error(`Too few acceptance outcomes: ${feature.id}`); if (!Array.isArray(feature.dependencies) || !Array.isArray(feature.required_tasks)) throw new Error(`Invalid relationships: ${feature.id}`); if (!feature.catalogue_decision?.evidence_url || feature.catalogue_decision.decision !== 'Approved for documentation') throw new Error(`Missing Feature documentation decision: ${feature.id}`); if (!feature.last_transition?.evidence_url || feature.last_transition.event !== 'Catalogue registration' || feature.last_transition.to !== feature.status || !feature.last_transition.authority || !feature.last_transition.reason) throw new Error(`Missing distinct Feature lifecycle evidence: ${feature.id}`); } for (const feature of features) { for (const dependency of feature.dependencies) { if (!featureIds.has(dependency)) throw new Error(`Dangling Feature dependency: ${feature.id} -> ${dependency}`); if (dependency === feature.id) throw new Error(`Self dependency: ${feature.id}`); } } const visiting = new Set(); const visited = new Set(); const featureById = new Map(features.map((feature) => [feature.id, feature])); const visit = (id) => { if (visiting.has(id)) throw new Error(`Feature dependency cycle at ${id}`); if (visited.has(id)) return; visiting.add(id); for (const dependency of featureById.get(id).dependencies) visit(dependency); visiting.delete(id); visited.add(id); }; for (const feature of features) visit(feature.id); const featuresByEpic = new Map(epics.map((epic) => [epic.id, features.filter((feature) => feature.epic_id === epic.id)])); for (const epic of epics) { const childIds = featuresByEpic.get(epic.id).map((feature) => feature.id); if (JSON.stringify(epic.required_features) !== JSON.stringify(childIds)) throw new Error(`One-sided or unsorted Feature relationship: ${epic.id}`); const statuses = featuresByEpic.get(epic.id).map((feature) => feature.status); const derived = statuses.every((status) => status === 'DONE') ? 'DONE' : statuses.every((status) => ['TO_BE_RELEASED', 'DONE'].includes(status)) ? 'TO_BE_RELEASED' : statuses.some((status) => ['IN_DELIVERY', 'TO_BE_RELEASED', 'DONE'].includes(status)) ? 'IN_DELIVERY' : statuses.some((status) => status === 'IN_DESIGN') ? 'IN_DESIGN' : 'IN_BACKLOG'; if (epic.status !== derived) throw new Error(`Derived status mismatch: ${epic.id} is ${epic.status}, expected ${derived}`); } const sourceList = (record) => record.sources.map((source) => `- [${source.label}](${source.url})`).join('\n'); const evidenceList = (record) => record.legacy_evidence.map((source) => `- [\`${source.path}\`](${source.url})`).join('\n'); const bulletList = (items) => items.map((item) => `- ${item}`).join('\n'); const featureRoute = (feature) => `/scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}/`; const epicRoute = (epic) => `/scope/epics/${epic.id.toLowerCase()}/`; const relativeFeatureLink = (fromFeature, targetFeature) => { const fromDir = join('scope/epics', fromFeature.epic_id.toLowerCase(), 'features'); const target = join('scope/epics', targetFeature.epic_id.toLowerCase(), 'features', `${targetFeature.id.toLowerCase()}.md`); const result = relative(fromDir, target).replaceAll('\\', '/'); return result.startsWith('.') ? result : `./${result}`; }; const featureLinkList = (epic, prefix) => featuresByEpic.get(epic.id) .map((feature) => `- [${feature.id} — ${feature.title}](${prefix}${feature.id.toLowerCase()}.md)`) .join('\n'); const epicPage = (epic) => `--- title: "${epic.id} — ${epic.title}" description: "Review the governed outcome and child Features for ${epic.id}." --- # ${epic.title} | Epic | Delivery status | Status source | Owner | Approved for Solution | |---|---|---|---|---| | \`${epic.id}\` | \`${epic.status}\` | \`${epic.status_source}\` | ${epic.owner} | No | ## Goal ${epic.goal} ## Benefit ${epic.value} ## Problem or opportunity ${epic.problem} ## Constraints ${bulletList(epic.constraints)} ## Features ${featureLinkList(epic, `./${epic.id.toLowerCase()}/features/`)} These Features are approved for documentation only. Each remains outside Architecture solution work until a separate human **Approved for Solution** decision is recorded. ## Original source ${sourceList(epic)} ### Legacy implementation evidence The catalogue was mined from legacy revision \`${epic.legacy_revision}\`. ${evidenceList(epic)} `; const featuresPage = (epic) => `--- title: "${epic.id} Features" description: "Review the governed Features beneath ${epic.id}." --- # Review ${epic.id} Features These Features decompose the [${epic.id} boundary](../${epic.id.toLowerCase()}.md) into non-overlapping user and operator outcomes grounded in the frozen legacy revision. ${featureLinkList(epic, './features/')} Documentation approval does not grant **Approved for Solution** or implementation approval. `; const featurePage = (feature) => { const epic = epics.find((item) => item.id === feature.epic_id); const dependencyLines = feature.dependencies.length ? feature.dependencies.map((id) => { const target = featureById.get(id); return `- [${target.id} — ${target.title}](${relativeFeatureLink(feature, target)})`; }).join('\n') : '- None recorded.'; return `--- title: "${feature.id} — ${feature.title}" description: "${feature.description.replaceAll('"', '\\"')}" --- # ${feature.title} | Feature | Parent Epic | Delivery status | Status source | Owner | Approved for Solution | |---|---|---|---|---|---| | \`${feature.id}\` | [${epic.id} — ${epic.title}](../../${epic.id.toLowerCase()}.md) | \`${feature.status}\` | \`${feature.status_source}\` | ${feature.owner} | No | ## Description ${feature.description} ## User or operator problem ${feature.user_problem} ## Expected value ${feature.expected_value} ## Scope ${feature.scope} ### Exclusions ${bulletList(feature.exclusions)} ## Acceptance outcomes ${feature.acceptance_outcomes.map((item) => `- [ ] ${item}`).join('\n')} ## Dependencies ${dependencyLines} ## Risks ${bulletList(feature.risks)} ## Human approval Linas approved this Feature for documentation on ${feature.catalogue_decision.decided_at}. It is **not Approved for Solution** and has no implementation approval. - [Documentation decision](${feature.catalogue_decision.evidence_url}) ## Delivery status evidence The Feature was separately registered at \`${feature.last_transition.to}\` as an accepted catalogue boundary. This records backlog retention, not design or delivery progress. - Event: ${feature.last_transition.event} - Actor and authority: ${feature.last_transition.actor} — ${feature.last_transition.authority} - Reason: ${feature.last_transition.reason} - [Status evidence](${feature.last_transition.evidence_url}) ## Architecture traceability - Requirements: none derived. - Readiness: ${feature.architecture_traceability.readiness}. Architecture solution work must not begin until a separate human **Approved for Solution** decision is recorded. ## Tasks [Review the Tasks group](./${feature.id.toLowerCase()}/tasks.md). No canonical implementation Tasks have been defined. ## Original source ${sourceList(feature)} ### Legacy implementation evidence The Feature boundary was mined from legacy revision \`${feature.legacy_revision}\`. ${evidenceList(feature)} `; }; const tasksPage = (feature) => `--- title: "${feature.id} Tasks" description: "Review canonical implementation Tasks governed beneath ${feature.id}." --- # Review ${feature.id} Tasks No canonical implementation Tasks have been defined for [${feature.id}](../${feature.id.toLowerCase()}.md). Architecture must first receive a separate human **Approved for Solution** decision, derive requirements and readiness evidence, and then register any resulting Tasks through Scope governance. `; const epicLinks = epics.map((epic) => `- [${epic.id} — ${epic.title}](./${epic.id.toLowerCase()}.md)`).join('\n'); const epicIndex = `--- title: "Epics" description: "Review the approved experience-led Epic catalogue and its documented Features." --- # Review the approved Epic catalogue These ten Epics define the documented product boundary mined from materially implemented legacy behavior. Their 32 child Features provide the approved documentation-level decomposition without granting solution or implementation approval. ${epicLinks} Planned-only capabilities—including VR entry, whiteboards, extra Archwiz domains, exports, and undo—remain outside this catalogue until separately evidenced and approved. `; const roadmapRows = epics.map((epic) => `| Epic | [${epic.id}](./epics/${epic.id.toLowerCase()}.md) | — | ${epic.title} | \`${epic.status}\` | Current documented catalogue; no delivery forecast | ${epic.owner} | [2026-09-02 evidence](${epic.catalogue_decision.evidence_url}) |`).concat( features.map((feature) => `| Feature | [${feature.id}](./epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}.md) | [${feature.epic_id}](./epics/${feature.epic_id.toLowerCase()}.md) | ${feature.title} | \`${feature.status}\` | Current documented catalogue; no delivery forecast | ${feature.owner} | [2026-09-02 evidence](${feature.catalogue_decision.evidence_url}) |`), ).join('\n'); const roadmap = `--- title: "Roadmap" description: "Distinguish the documented Epic and Feature baseline from future sequencing and delivery forecasts." --- # Review the product roadmap baseline The current roadmap records the documented Epic and Feature catalogue without inventing dates or delivery commitments. All Epics and Features remain in \`IN_BACKLOG\`; Feature dependencies are recorded on each canonical Feature page, while sequencing and horizon decisions require separate evidence. | Type | Record | Parent | Outcome area | Delivery status | Horizon | Owner | Last evidenced update | |---|---|---|---|---|---|---|---| ${roadmapRows} `; const linkFor = (id) => { if (epicIds.has(id)) { const epic = epics.find((item) => item.id === id); return {id, title: epic.title, recordUrl: epicRoute(epic)}; } const feature = featureById.get(id); return {id, title: feature.title, recordUrl: featureRoute(feature)}; }; const issues = [ ...epics.map((epic) => ({ id: epic.id, type: epic.type, title: epic.title, parent: null, status: epic.status, statusSource: epic.status_source, blocked: epic.blocked, blockedReason: epic.blocked_reason, owner: epic.owner, approval: epic.approval.approved_for_solution ? 'Approved for Solution' : 'Not approved for Solution', requiredChildren: epic.required_features.map(linkFor), dependencies: epic.dependencies.map(linkFor), releaseEvidence: epic.release_evidence ?? [], evidenceUrl: epic.last_transition.evidence_url, recordUrl: epicRoute(epic), })), ...features.map((feature) => ({ id: feature.id, type: feature.type, title: feature.title, parent: linkFor(feature.epic_id), status: feature.status, statusSource: feature.status_source, blocked: feature.blocked, blockedReason: feature.blocked_reason, owner: feature.owner, approval: feature.approval.approved_for_solution ? 'Approved for Solution' : 'Not approved for Solution', requiredChildren: feature.required_tasks.map(linkFor), dependencies: feature.dependencies.map(linkFor), releaseEvidence: feature.release_evidence, evidenceUrl: feature.last_transition.evidence_url, recordUrl: featureRoute(feature), })), ]; const ts = `// Generated by scripts/generate-scope.mjs from canonical Scope records. Do not edit.\nexport type ScopeIssueLink = {id:string; title:string; recordUrl:string};\nexport type ScopeIssue = {id:string; type:string; title:string; parent:ScopeIssueLink|null; status:string; statusSource:string; blocked:boolean; blockedReason:string|null; owner:string; approval:string; requiredChildren:ScopeIssueLink[]; dependencies:ScopeIssueLink[]; releaseEvidence:unknown[]; evidenceUrl:string; recordUrl:string};\nexport const scopeIssues: ScopeIssue[] = ${JSON.stringify(issues, null, 2)};\n`; const sidebarItems = epics.map((epic) => ` { type: 'category' as const, label: '${epic.id}', link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}'}, items: [{ type: 'category' as const, label: 'Features', link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features'}, items: [ ${featuresByEpic.get(epic.id).map((feature) => ` { type: 'category' as const, label: '${feature.id}', link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features/${feature.id.toLowerCase()}'}, items: [{type: 'category' as const, label: 'Tasks', link: {type: 'doc' as const, id: 'epics/${epic.id.toLowerCase()}/features/${feature.id.toLowerCase()}/tasks'}, items: []}], }`).join(',\n')} ], }], }`).join(',\n'); const sidebar = `import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; // Generated by scripts/generate-scope.mjs from canonical Scope records. Do not edit. const sidebars: SidebarsConfig = { scopeSidebar: [ 'overview', 'roadmap', {type: 'category', label: 'Ideas', link: {type: 'doc', id: 'ideas'}, items: []}, {type: 'category', label: 'Questions', link: {type: 'doc', id: 'questions'}, items: []}, { type: 'category', label: 'Epics', link: {type: 'doc', id: 'epics/index'}, collapsed: false, items: [ ${sidebarItems} ], }, ], }; export default sidebars; `; const expected = new Map([ [join(root, 'scope/epics/index.md'), epicIndex], [join(root, 'scope/roadmap.md'), roadmap], [join(root, 'src/data/scopeIssues.ts'), ts], [join(root, 'sidebarsScope.ts'), sidebar], ...epics.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}.md`), epicPage(epic)]), ...epics.map((epic) => [join(root, `scope/epics/${epic.id.toLowerCase()}/features.md`), featuresPage(epic)]), ...features.map((feature) => [join(root, `scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}.md`), featurePage(feature)]), ...features.map((feature) => [join(root, `scope/epics/${feature.epic_id.toLowerCase()}/features/${feature.id.toLowerCase()}/tasks.md`), tasksPage(feature)]), ]); let stale = false; for (const [path, content] of expected) { if (check) { if (!existsSync(path) || readFileSync(path, 'utf8') !== content) { console.error(`Stale generated artifact: ${path}`); stale = true; } } else { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, content); } } if (stale) process.exit(1); console.log(`${check ? 'Verified' : 'Generated'} ${expected.size} Scope artifacts from ${epics.length} canonical Epics and ${features.length} canonical Features.`);