@dtlabs/ai-toolkit 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/lib/workflow-control.js +65 -13
  4. package/package.json +1 -1
  5. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  6. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  7. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  8. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  9. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  10. package/src/claude/agents/gaia-generate-work-breakdown.md +94 -100
  11. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  12. package/src/claude/commands/assemble-docs.md +16 -0
  13. package/src/claude/commands/feature-status.md +9 -4
  14. package/src/claude/scripts/ac-task-matrix.js +87 -0
  15. package/src/claude/scripts/assemble-docs.js +66 -0
  16. package/src/claude/scripts/req-assembler.js +127 -0
  17. package/src/claude/scripts/req-graph-checks.js +71 -0
  18. package/src/claude/scripts/req-skeleton-check.js +64 -0
  19. package/src/claude/scripts/tech-spec-assembler.js +188 -0
  20. package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
  21. package/src/claude/scripts/templates/gaia-component-template.md +64 -0
  22. package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
  23. package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
  24. package/src/claude/scripts/templates/gaia-task-template.md +92 -0
  25. package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
  26. package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
  27. package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
  28. package/src/claude/scripts/wb-graph-checks.js +754 -0
  29. package/src/claude/scripts/wb-render.js +6 -11
  30. package/src/claude/scripts/wb-skeleton-check.js +99 -0
  31. package/src/claude/scripts/wb-validate.js +49 -655
  32. package/src/claude/scripts/wbs-assembler.js +368 -0
  33. package/src/claude/skills/implement-feature/SKILL.md +12 -6
  34. package/src/claude/workflows/pm-phase1.js +342 -16
  35. package/src/claude/workflows/pm-phase2.js +277 -14
@@ -0,0 +1,127 @@
1
+ 'use strict'
2
+
3
+ // FTR-021: loader/renderer for the split Requirements format (req/index.md +
4
+ // req/use-cases/{UC-ID}.md). Unlike wbs-assembler.js (JSON -> Markdown rendering),
5
+ // index.md is already Markdown narrative, so assembly here is textual reassembly:
6
+ // splice each use case's already-rendered file into Section 2's slot, in index-table
7
+ // order. Legacy input (a single monolithic file) is returned unchanged.
8
+
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ class AssemblyError extends Error {}
13
+
14
+ // Returns { start, end, lines }: `start` is the index of the "## N. " heading line for
15
+ // `sectionNumber`, `end` is the index of the next "## M. " heading (M > sectionNumber),
16
+ // or lines.length if none follows. Throws if the section is not found at all.
17
+ function findSectionRange(content, sectionNumber) {
18
+ const lines = content.split('\n')
19
+ let start = -1
20
+ let end = lines.length
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const m = lines[i].match(/^## (\d+)\.\s/)
23
+ if (!m) continue
24
+ const n = Number(m[1])
25
+ if (n === sectionNumber) start = i
26
+ else if (start !== -1 && n > sectionNumber) { end = i; break }
27
+ }
28
+ if (start === -1) throw new AssemblyError(`missing section ${sectionNumber}`)
29
+ return { start, end, lines }
30
+ }
31
+
32
+ // Parses the first Markdown table found inside [start, end) into rows, dropping the
33
+ // header row and the "|---|---|" separator row. Returns an array of cell-arrays.
34
+ function parseTableRows(lines, start, end) {
35
+ const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
36
+ if (tableLines.length < 2) return []
37
+ return tableLines.slice(2).map(line =>
38
+ line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
39
+ }
40
+
41
+ // The "## 2. Use Cases" index table: UC ID | Title | Actor | Priority | File.
42
+ // `File` is a Markdown link `[use-cases/UC-01.md](use-cases/UC-01.md)` — the link
43
+ // TARGET (inside the parens) is authoritative, not the link text.
44
+ function parseUseCaseIndexTable(indexContent) {
45
+ const { start, end, lines } = findSectionRange(indexContent, 2)
46
+ return parseTableRows(lines, start, end).map(cells => {
47
+ const [id, title, actor, priority, fileCol] = cells
48
+ const fileMatch = fileCol && fileCol.match(/\(([^)]+)\)/)
49
+ if (!fileMatch) throw new AssemblyError(`malformed File column for UC "${id}" in index.md`)
50
+ return { id, title, actor, priority, file: fileMatch[1] }
51
+ })
52
+ }
53
+
54
+ // The "## 7. Acceptance Criteria" table: ID | Criterion | Related UC | Priority.
55
+ function parseAcTable(indexContent) {
56
+ const { start, end, lines } = findSectionRange(indexContent, 7)
57
+ return parseTableRows(lines, start, end).map(cells => {
58
+ const [id, criterion, relatedUc, priority] = cells
59
+ return { id, criterion, relatedUc, priority }
60
+ })
61
+ }
62
+
63
+ // The "## 3. Business Rules" table: ID | Rule | Applies to.
64
+ function parseBusinessRulesTable(indexContent) {
65
+ const { start, end, lines } = findSectionRange(indexContent, 3)
66
+ return parseTableRows(lines, start, end).map(cells => {
67
+ const [id, rule, appliesTo] = cells
68
+ return { id, rule, appliesTo }
69
+ })
70
+ }
71
+
72
+ // Replaces Section 2's body (everything after its heading, up to but not including the
73
+ // next "## 3." heading) with `section2Body`, keeping the heading line itself and every
74
+ // other section untouched.
75
+ function spliceSection2(indexContent, section2Body) {
76
+ const { start, end, lines } = findSectionRange(indexContent, 2)
77
+ const heading = lines[start]
78
+ const before = lines.slice(0, start).concat([heading]).join('\n')
79
+ const after = lines.slice(end).join('\n')
80
+ return `${before}\n\n${section2Body}\n\n${after}`
81
+ }
82
+
83
+ function loadRequirements(inputPath) {
84
+ const stat = fs.statSync(inputPath, { throwIfNoEntry: false })
85
+ if (!stat) throw new AssemblyError(`path does not exist: ${inputPath}`)
86
+ if (stat.isFile()) return fs.readFileSync(inputPath, 'utf8') // legacy path (AC-07)
87
+
88
+ const indexPath = path.join(inputPath, 'index.md')
89
+ if (!fs.existsSync(indexPath)) throw new AssemblyError(`missing req/index.md at ${indexPath}`)
90
+ const indexContent = fs.readFileSync(indexPath, 'utf8')
91
+
92
+ const ucTable = parseUseCaseIndexTable(indexContent)
93
+ if (ucTable.length === 0) throw new AssemblyError('req/index.md has no rows in its Use Cases index table')
94
+
95
+ const ucBlocks = ucTable.map(row => {
96
+ const ucPath = path.join(inputPath, row.file)
97
+ if (!fs.existsSync(ucPath)) throw new AssemblyError(`use case "${row.id}" has no detail file at ${ucPath}`)
98
+ return fs.readFileSync(ucPath, 'utf8').trimEnd()
99
+ })
100
+
101
+ return spliceSection2(indexContent, ucBlocks.join('\n\n'))
102
+ }
103
+
104
+ if (require.main === module) {
105
+ const inputPath = process.argv[2]
106
+ if (!inputPath) {
107
+ process.stderr.write('Usage: node req-assembler.js <path-to-req-dir-or-legacy-file>\n')
108
+ process.exit(1)
109
+ }
110
+ try {
111
+ process.stdout.write(loadRequirements(path.resolve(inputPath)))
112
+ } catch (err) {
113
+ process.stderr.write(`Error: ${err.message}\n`)
114
+ process.exit(err instanceof AssemblyError ? 1 : 2)
115
+ }
116
+ }
117
+
118
+ module.exports = {
119
+ loadRequirements,
120
+ parseUseCaseIndexTable,
121
+ parseAcTable,
122
+ parseBusinessRulesTable,
123
+ spliceSection2,
124
+ findSectionRange,
125
+ parseTableRows,
126
+ AssemblyError,
127
+ }
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ // FTR-021: 4 pure self-consistency check functions for the split Requirements format
4
+ // (req/index.md). Mirrors wb-graph-checks.js's convention: no file I/O, no process.exit,
5
+ // just plain functions over an already-parsed plain object, returning an array of error
6
+ // objects. The actual Markdown parsing lives in req-assembler.js / req-skeleton-check.js.
7
+
8
+ const UC_ID_RE = /^UC-\d{2,}$/
9
+
10
+ function checkUniqueUcIds({ ucIds }) {
11
+ const seen = new Set()
12
+ const duplicates = new Set()
13
+ for (const id of ucIds) {
14
+ if (seen.has(id)) duplicates.add(id)
15
+ else seen.add(id)
16
+ }
17
+ return [...duplicates].map(id => ({
18
+ category: 'DUPLICATE_UC_ID', severity: 'error', ucId: id,
19
+ message: `Duplicate UC ID "${id}" appears more than once in the use-case index table`,
20
+ }))
21
+ }
22
+
23
+ function checkUcIdFormat({ ucIds }) {
24
+ return ucIds.filter(id => !UC_ID_RE.test(id)).map(id => ({
25
+ category: 'INVALID_UC_ID_FORMAT', severity: 'error', ucId: id,
26
+ message: `UC ID "${id}" does not match the required UC-NN format`,
27
+ }))
28
+ }
29
+
30
+ // Orphan-file direction only: a use-cases/*.md file that no longer corresponds to any row
31
+ // in the current table (e.g. left over after a UC was renamed/removed from a hand-edited
32
+ // index.md). Deliberately NOT bidirectional — this check runs BEFORE per-use-case
33
+ // dispatch, when every row is expected to have no file yet, so "table row without a file"
34
+ // is the normal pre-dispatch state, not an error. That direction is enforced correctly,
35
+ // after generation, by req-assembler.js's own missing-file error at assembly time.
36
+ function checkUcTableFileParity({ ucIds }, existingUcFiles) {
37
+ const idSet = new Set(ucIds)
38
+ return existingUcFiles.filter(id => !idSet.has(id)).map(id => ({
39
+ category: 'UC_FILE_WITHOUT_TABLE_ROW', severity: 'error', ucId: id,
40
+ message: `use-cases/${id}.md exists but has no row in the use-case index table`,
41
+ }))
42
+ }
43
+
44
+ function checkCrossReferencesResolve({ ucIds, acTable, businessRules }) {
45
+ const idSet = new Set(ucIds)
46
+ const errors = []
47
+ const checkRefs = (rows, idField, refField, category) => {
48
+ for (const row of rows) {
49
+ const raw = row[refField]
50
+ if (raw == null || raw.trim() === 'All UCs') continue
51
+ for (const ref of raw.split(',').map(s => s.trim()).filter(Boolean)) {
52
+ if (!idSet.has(ref)) {
53
+ errors.push({
54
+ category, severity: 'error', id: row[idField], ucId: ref,
55
+ message: `${row[idField]}'s "${refField}" references unknown UC "${ref}"`,
56
+ })
57
+ }
58
+ }
59
+ }
60
+ }
61
+ checkRefs(acTable || [], 'id', 'relatedUc', 'DANGLING_AC_UC_REFERENCE')
62
+ checkRefs(businessRules || [], 'id', 'appliesTo', 'DANGLING_BR_UC_REFERENCE')
63
+ return errors
64
+ }
65
+
66
+ module.exports = {
67
+ checkUniqueUcIds,
68
+ checkUcIdFormat,
69
+ checkUcTableFileParity,
70
+ checkCrossReferencesResolve,
71
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-021: deterministic structural pre-check for a Requirements skeleton (req/index.md),
5
+ // run by pm-phase1.js before any per-use-case generation call is dispatched (UC-02).
6
+ //
7
+ // This is a separate script from req-assembler.js's CLI mode, not a reuse of it, because
8
+ // pm-phase1.js's workflow-script runtime has no `require` (lib/workflow-runner.js) — this
9
+ // is invoked as a real Node subprocess via `c.command(['run-asset',
10
+ // 'scripts/req-skeleton-check.js', ...])`, the same pattern already used for
11
+ // wb-skeleton-check.js/wb-validate.js/wb-render.js (FTR-020).
12
+
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const graphChecks = require('./req-graph-checks')
16
+ const { parseUseCaseIndexTable, parseAcTable, parseBusinessRulesTable } = require('./req-assembler')
17
+
18
+ function main() {
19
+ const indexPath = process.argv[2]
20
+ if (!indexPath) {
21
+ process.stderr.write('Usage: node req-skeleton-check.js <path-to-req-index.md>\n')
22
+ process.exit(2)
23
+ }
24
+
25
+ let indexContent
26
+ try {
27
+ indexContent = fs.readFileSync(indexPath, 'utf8')
28
+ } catch (e) {
29
+ console.log(JSON.stringify({ valid: false, errors: [{ message: `cannot read ${indexPath}: ${e.message}` }] }))
30
+ process.exit(2)
31
+ }
32
+
33
+ let ucTable, acTable, businessRules
34
+ try {
35
+ ucTable = parseUseCaseIndexTable(indexContent)
36
+ acTable = parseAcTable(indexContent)
37
+ businessRules = parseBusinessRulesTable(indexContent)
38
+ } catch (e) {
39
+ console.log(JSON.stringify({ valid: false, errors: [{ message: `parse failure: ${e.message}` }] }))
40
+ process.exit(2)
41
+ }
42
+
43
+ const ucIds = ucTable.map(r => r.id)
44
+ const useCasesDir = path.join(path.dirname(indexPath), 'use-cases')
45
+ const existingUcFiles = fs.existsSync(useCasesDir)
46
+ ? fs.readdirSync(useCasesDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
47
+ : []
48
+
49
+ const errors = [
50
+ ...graphChecks.checkUniqueUcIds({ ucIds }),
51
+ ...graphChecks.checkUcIdFormat({ ucIds }),
52
+ ...graphChecks.checkUcTableFileParity({ ucIds }, existingUcFiles),
53
+ ...graphChecks.checkCrossReferencesResolve({
54
+ ucIds,
55
+ acTable: acTable.map(a => ({ id: a.id, relatedUc: a.relatedUc })),
56
+ businessRules: businessRules.map(b => ({ id: b.id, appliesTo: b.appliesTo })),
57
+ }),
58
+ ]
59
+
60
+ console.log(JSON.stringify({ valid: errors.length === 0, errors }, null, 2))
61
+ process.exit(errors.length === 0 ? 0 : 1)
62
+ }
63
+
64
+ main()
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-024: loader/renderer for the split Tech-Spec format (spec/index.md +
5
+ // spec/components/*.md). Reconstructs the exact 12-section monolithic shape
6
+ // gaia-generate-tech-spec already produced in one turn before this feature: Section 9 rebuilt
7
+ // from the skeleton's own Components table (authoritative, restated from each component, never
8
+ // re-derived); Sections 3 (Backend)/4 (Frontend)/5 (External Integrations) populated per
9
+ // component Domain routing; Section 10 (Testing Strategy) built from every component's own
10
+ // Tests subsection, unconditionally. Legacy input (a single monolithic file) is returned
11
+ // unchanged.
12
+
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const { findSectionRange, parseTableRows } = require('./req-assembler')
16
+
17
+ const SECTION_BY_DOMAIN = { BE: 3, FE: 4 } // anything else (DB/DevOps/INFRA/TEST) routes to 5
18
+
19
+ class AssemblyError extends Error {}
20
+
21
+ // Same helper tech-spec-graph-checks.js already has (FTR-023) — duplicated here deliberately,
22
+ // not imported: this script reads 5-column rows for a structurally different purpose (section
23
+ // routing, not Addresses validation), and importing a function named for a different module's
24
+ // job would read as a false shared contract. Same precedent as wbs-assembler.js independently
25
+ // reimplementing plan.js's field-parsing convention (FTR-020).
26
+ function findSubsectionRange(lines, start, end, heading) {
27
+ let subStart = -1
28
+ for (let i = start; i < end; i++) {
29
+ if (lines[i].trim() === `### ${heading}`) { subStart = i; break }
30
+ }
31
+ if (subStart === -1) return null
32
+ let subEnd = end
33
+ for (let i = subStart + 1; i < end; i++) {
34
+ if (/^#{2,3}\s/.test(lines[i])) { subEnd = i; break }
35
+ }
36
+ return { start: subStart, end: subEnd }
37
+ }
38
+
39
+ function parseComponentsTable(indexContent) {
40
+ const { start, end, lines } = findSectionRange(indexContent, 9)
41
+ const entries = []
42
+ // changeType ('New'/'Modified') comes from WHICH table a row was found in, not from any
43
+ // column's text — the "Purpose-or-Change" column is free-text (e.g. "does backend stuff"),
44
+ // never literally the word "New"/"Modified". A real bug, found only by actually assembling a
45
+ // hand-built fixture and inspecting Section 9's rebuilt New/Modified split: every row landed
46
+ // under "Modified" because `change.toLowerCase() === 'new'` almost never matched free text.
47
+ for (const [heading, changeType] of [['New files', 'New'], ['Modified files', 'Modified']]) {
48
+ const sub = findSubsectionRange(lines, start, end, heading)
49
+ if (!sub) continue
50
+ for (const cells of parseTableRows(lines, sub.start, sub.end)) {
51
+ // Column order: Addresses MUST stay 3rd (index 2) — the exact position
52
+ // tech-spec-graph-checks.js's unmodified, positional parser already reads from a
53
+ // 3-column legacy table (FTR-023). Domain/Spec are new trailing columns.
54
+ const [filePath, purposeOrChange, addresses, domain, specLink] = cells
55
+ const m = specLink && specLink.match(/\(components\/([^)]+)\)/)
56
+ if (!m) throw new AssemblyError(`malformed Spec column for "${filePath}" in spec/index.md`)
57
+ entries.push({ filePath, purposeOrChange, changeType, domain, addresses, fileSlug: m[1] })
58
+ }
59
+ }
60
+ return entries
61
+ }
62
+
63
+ // Parses one component file into { change, body, tests }: `body` is everything between
64
+ // "## Change" (exclusive of its own heading) and "## Tests" — the component's own
65
+ // Algorithm/Schema content, whatever heading it actually used (the component template's own
66
+ // section-naming is a free-text "menu, not a checklist" — this parser does not care what that
67
+ // heading is literally called, only that it comes after Change and before Tests).
68
+ //
69
+ // Fence-aware: a component's own Change/body/Tests content may legitimately quote another
70
+ // file's literal "## Heading" line inside a fenced code example (e.g. a before/after excerpt of
71
+ // a markdown prompt file being modified, exactly the shape FTR-025's own spec/components/
72
+ // gaia-validate-feature-docs.md.md needed) — a naive line scan mistook that quoted line for a
73
+ // real section boundary, corrupting the assembled Section 3/4/5/10 content. Lines inside a
74
+ // fence are never treated as a heading, regardless of whether the fence appears before or after
75
+ // the real heading in the file.
76
+ //
77
+ // Length-aware, not a plain open/close boolean toggle: real CommonMark fences close only on a
78
+ // backtick run at least as long as the one that opened them, which is exactly what lets a
79
+ // longer outer fence (e.g. ````markdown) legitimately contain a shorter inner one (```) as a
80
+ // nested quoted example — a simple boolean toggle miscounts any such nesting (every backtick
81
+ // run flips it, regardless of length), landing in the wrong state for the rest of the file the
82
+ // moment the total run count is odd. Found exactly this way: FTR-025's own spec/components/
83
+ // SKILL.md.md quotes a before/after Markdown example that itself contains a fenced block.
84
+ function _nonFencedHeadingIndexes(lines, predicate) {
85
+ let fenceLen = 0 // 0 = not in a fence; otherwise the backtick-run length that opened it
86
+ const indexes = []
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const m = lines[i].match(/^(`{3,})/)
89
+ if (m) {
90
+ const runLen = m[1].length
91
+ if (fenceLen === 0) fenceLen = runLen // opens a new fence
92
+ else if (runLen >= fenceLen) fenceLen = 0 // a run at least as long as the opener closes it
93
+ // a shorter run while already fenced is just literal backtick content inside the fence
94
+ continue
95
+ }
96
+ if (fenceLen !== 0) continue
97
+ if (predicate(lines[i])) indexes.push(i)
98
+ }
99
+ return indexes
100
+ }
101
+ function parseComponentDetail(content) {
102
+ const lines = content.split('\n')
103
+ const changeIdx = _nonFencedHeadingIndexes(lines, l => l.trim() === '## Change')[0] ?? -1
104
+ const testsIdx = _nonFencedHeadingIndexes(lines, l => l.trim() === '## Tests')[0] ?? -1
105
+ if (changeIdx === -1) throw new AssemblyError('component file is missing its "## Change" section')
106
+ if (testsIdx === -1) throw new AssemblyError('component file is missing its "## Tests" section')
107
+ const nextHeadingIdx = _nonFencedHeadingIndexes(lines, l => /^##\s/.test(l)).find(i => i > changeIdx)
108
+ const changeEnd = nextHeadingIdx === undefined ? lines.length : nextHeadingIdx
109
+ return {
110
+ change: lines.slice(changeIdx + 1, changeEnd).join('\n').trim(),
111
+ body: lines.slice(changeEnd, testsIdx).join('\n').trim(),
112
+ tests: lines.slice(testsIdx + 1).join('\n').trim(),
113
+ }
114
+ }
115
+
116
+ // Replaces everything between "## {n}. " and the next "## " heading with `body`, keeping the
117
+ // heading line itself — same splice primitive as req-assembler.js's spliceSection2, generalized
118
+ // to any section number instead of hardcoding 2.
119
+ function spliceSectionBody(doc, n, body) {
120
+ const { start, end, lines } = findSectionRange(doc, n)
121
+ const heading = lines[start]
122
+ const before = lines.slice(0, start).concat([heading]).join('\n')
123
+ const after = lines.slice(end).join('\n')
124
+ return `${before}\n\n${body}\n\n${after}`
125
+ }
126
+
127
+ function rewriteSection9(doc, components) {
128
+ const newFiles = components.filter(c => c.changeType === 'New')
129
+ const modifiedFiles = components.filter(c => c.changeType === 'Modified')
130
+ const table = rows => '| Path | Purpose-or-Change | Addresses |\n|---|---|---|\n' +
131
+ rows.map(c => `| ${c.filePath} | ${c.purposeOrChange} | ${c.addresses} |`).join('\n')
132
+ const body = `### New files\n${table(newFiles)}\n\n### Modified files\n${table(modifiedFiles)}`
133
+ return spliceSectionBody(doc, 9, body)
134
+ }
135
+
136
+ function loadTechSpec(inputPath) {
137
+ const stat = fs.statSync(inputPath, { throwIfNoEntry: false })
138
+ if (!stat) throw new AssemblyError(`path does not exist: ${inputPath}`)
139
+ if (stat.isFile()) return fs.readFileSync(inputPath, 'utf8') // legacy path
140
+
141
+ const indexPath = path.join(inputPath, 'index.md')
142
+ if (!fs.existsSync(indexPath)) throw new AssemblyError(`missing spec/index.md at ${indexPath}`)
143
+ const indexContent = fs.readFileSync(indexPath, 'utf8')
144
+ const components = parseComponentsTable(indexContent)
145
+ if (components.length === 0) throw new AssemblyError('spec/index.md has no rows in its Components table')
146
+
147
+ const detailByPath = {}
148
+ for (const c of components) {
149
+ const compPath = path.join(inputPath, 'components', c.fileSlug)
150
+ if (!fs.existsSync(compPath)) throw new AssemblyError(`component "${c.filePath}" has no detail file at ${compPath}`)
151
+ detailByPath[c.filePath] = parseComponentDetail(fs.readFileSync(compPath, 'utf8'))
152
+ }
153
+
154
+ let doc = rewriteSection9(indexContent, components)
155
+ for (const sectionNum of [3, 4, 5]) {
156
+ const matching = components.filter(c => (SECTION_BY_DOMAIN[c.domain] || 5) === sectionNum)
157
+ const body = matching.length
158
+ ? matching.map(c => `### ${c.filePath}\n\n${detailByPath[c.filePath].body}`).join('\n\n')
159
+ : '_No components in this category._'
160
+ doc = spliceSectionBody(doc, sectionNum, body)
161
+ }
162
+ const testsBody = components.map(c => `### ${c.filePath}\n\n${detailByPath[c.filePath].tests}`).join('\n\n')
163
+ doc = spliceSectionBody(doc, 10, testsBody)
164
+ return doc
165
+ }
166
+
167
+ if (require.main === module) {
168
+ const inputPath = process.argv[2]
169
+ if (!inputPath) {
170
+ process.stderr.write('Usage: node tech-spec-assembler.js <path-to-spec-dir-or-legacy-file>\n')
171
+ process.exit(1)
172
+ }
173
+ try {
174
+ process.stdout.write(loadTechSpec(path.resolve(inputPath)))
175
+ } catch (err) {
176
+ process.stderr.write(`Error: ${err.message}\n`)
177
+ process.exit(err instanceof AssemblyError ? 1 : 2)
178
+ }
179
+ }
180
+
181
+ module.exports = {
182
+ loadTechSpec,
183
+ parseComponentsTable,
184
+ parseComponentDetail,
185
+ spliceSectionBody,
186
+ findSubsectionRange,
187
+ AssemblyError,
188
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-023: deterministic structural check that every Tech-Spec File Inventory "Addresses"
5
+ // reference resolves to a real Requirements AC ID. Combined parse/check/CLI in one file — unlike
6
+ // wb-graph-checks.js/req-graph-checks.js (several checks, multiple consumers), this feature has
7
+ // exactly one check used in exactly one context, so a separate checks-module + CLI-wrapper split
8
+ // isn't justified.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { findSectionRange, parseTableRows } = require('./req-assembler')
13
+ const { parseAcTable } = require('./wb-graph-checks')
14
+
15
+ // Locates a "### {heading}" line within [start, end) and returns its own sub-range, up to the
16
+ // next "###" or "##" heading (or `end`). Tech-Spec-specific: req-assembler.js's
17
+ // findSectionRange only knows about numbered "## N." headings, one level up from this.
18
+ function findSubsectionRange(lines, start, end, heading) {
19
+ let subStart = -1
20
+ for (let i = start; i < end; i++) {
21
+ if (lines[i].trim() === `### ${heading}`) { subStart = i; break }
22
+ }
23
+ if (subStart === -1) return null
24
+ let subEnd = end
25
+ for (let i = subStart + 1; i < end; i++) {
26
+ if (/^#{2,3}\s/.test(lines[i])) { subEnd = i; break }
27
+ }
28
+ return { start: subStart, end: subEnd }
29
+ }
30
+
31
+ function parseFileInventoryEntries(techSpecContent) {
32
+ const { start, end, lines } = findSectionRange(techSpecContent, 9)
33
+ const entries = []
34
+ for (const heading of ['New files', 'Modified files']) {
35
+ const sub = findSubsectionRange(lines, start, end, heading)
36
+ if (!sub) continue // a Tech-Spec with only one of the two tables is valid (e.g. a pure-addition feature)
37
+ for (const cells of parseTableRows(lines, sub.start, sub.end)) {
38
+ const filePath = cells[0]
39
+ const addressesCell = cells[2] // Path | Purpose-or-Change-description | Addresses
40
+ const acIds = (!addressesCell || addressesCell.trim() === '—') ? []
41
+ : addressesCell.split(',').map(s => s.trim()).filter(Boolean)
42
+ entries.push({ filePath, acIds })
43
+ }
44
+ }
45
+ return entries
46
+ }
47
+
48
+ function checkAddressesResolve(entries, validAcIds) {
49
+ const idSet = new Set(validAcIds)
50
+ const errors = []
51
+ for (const entry of entries) {
52
+ for (const acId of entry.acIds) {
53
+ if (!idSet.has(acId)) {
54
+ errors.push({
55
+ category: 'DANGLING_TECH_SPEC_AC_REFERENCE', severity: 'error',
56
+ filePath: entry.filePath, acId,
57
+ message: `File "${entry.filePath}"'s "Addresses" column references unknown AC "${acId}"`,
58
+ })
59
+ }
60
+ }
61
+ }
62
+ return errors
63
+ }
64
+
65
+ if (require.main === module) {
66
+ const [techSpecPath, requirementsPath] = process.argv.slice(2)
67
+ if (!techSpecPath || !requirementsPath) {
68
+ process.stderr.write('Usage: node tech-spec-graph-checks.js <tech-spec-path> <requirements-path>\n')
69
+ process.exit(2)
70
+ }
71
+ let entries, acMap
72
+ try {
73
+ entries = parseFileInventoryEntries(fs.readFileSync(path.resolve(techSpecPath), 'utf8'))
74
+ acMap = parseAcTable(path.resolve(requirementsPath))
75
+ } catch (e) {
76
+ console.log(JSON.stringify({ valid: false, errors: [{ message: `parse failure: ${e.message}` }] }))
77
+ process.exit(2)
78
+ }
79
+ const errors = checkAddressesResolve(entries, [...acMap.keys()])
80
+ console.log(JSON.stringify({ valid: errors.length === 0, errors }, null, 2))
81
+ process.exit(errors.length === 0 ? 0 : 1)
82
+ }
83
+
84
+ module.exports = { parseFileInventoryEntries, checkAddressesResolve, findSubsectionRange }
@@ -0,0 +1,64 @@
1
+ <!--
2
+ COMPONENT TEMPLATE — spec/components/{fileSlug}.md
3
+
4
+ Canonical structure for one component's technical design in the split Tech-Spec format (FTR-024).
5
+ A "component" is one file this feature creates or modifies — the granularity is the same as one
6
+ row in spec/index.md's own Section 9 "File Inventory" / Components table, not an arbitrary
7
+ subdivision.
8
+
9
+ This is the REAL, currently-implemented contract — authoritative source:
10
+ `src/claude/agents/gaia-generate-tech-spec-component.md`'s own "Output" section, and
11
+ `src/claude/scripts/tech-spec-assembler.js`'s `parseComponentDetail`, which requires exactly
12
+ `## Change` and `## Tests` to exist, in that relative order, to parse a component file correctly.
13
+ An earlier FTR-020 draft of this template (component-template.md) additionally specified
14
+ `Related Open Questions`/`Related components`/`Addresses` header lines and a `Decision:` section —
15
+ none of those exist in the real, shipped format; this file reflects what is actually generated and
16
+ parsed today, not that earlier draft.
17
+
18
+ Fill in every placeholder (angle brackets) and delete every HTML comment block (including this
19
+ one) before treating the file as finished.
20
+ -->
21
+
22
+ # {Path} — {New | Modified}
23
+
24
+ ## Change
25
+
26
+ <!--
27
+ One paragraph: what changes about this file and why — name the specific functions/sections
28
+ affected, not just "this file is updated to support the new feature". If New: what it exports and
29
+ the shape of its inputs/outputs. If Modified: what specifically is added, replaced, or removed,
30
+ and what is explicitly left unchanged.
31
+ -->
32
+
33
+ <prose>
34
+
35
+ ## {Algorithm | Data Model | Service Interface | Component Props | whichever name fits this
36
+ component's own real nature — pick one, do not invent multiple competing headings}
37
+
38
+ <!--
39
+ Real pseudocode or real code, not a prose description of "what it does". If this component
40
+ relocates logic from an existing file, name the exact source file and line ranges if known.
41
+
42
+ If this component genuinely needs no design content beyond Change (e.g. a trivial configuration
43
+ file), omit this entire middle heading rather than inventing content — Change and Tests are always
44
+ required, this one is not.
45
+ -->
46
+
47
+ <pseudocode/code + prose explaining non-obvious choices>
48
+
49
+ ## Tests
50
+
51
+ <!--
52
+ The specific test file(s) this component's change requires, and the key cases that matter — not a
53
+ generic "add tests for X".
54
+ -->
55
+
56
+ <prose / bullet list of test file(s) and key cases>
57
+
58
+ <!--
59
+ PARSING CONTRACT — read by tech-spec-assembler.js's parseComponentDetail: `## Change` and
60
+ `## Tests` must both exist, in that relative order; everything between them (including the middle
61
+ heading above, whatever it's actually called) is this component's own design body, concatenated
62
+ verbatim into the assembled Tech-Spec's Section 3 (Backend) / 4 (Frontend) / 5 (External
63
+ Integrations), routed by this component's own Domain from spec/index.md's Components table.
64
+ -->