@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.
- package/CHANGELOG.md +30 -0
- package/lib/agent-registry.js +39 -0
- package/lib/workflow-control.js +65 -13
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
- package/src/claude/agents/gaia-generate-requirements.md +22 -27
- package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
- package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
- package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
- package/src/claude/agents/gaia-generate-work-breakdown.md +94 -100
- package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
- package/src/claude/commands/assemble-docs.md +16 -0
- package/src/claude/commands/feature-status.md +9 -4
- package/src/claude/scripts/ac-task-matrix.js +87 -0
- package/src/claude/scripts/assemble-docs.js +66 -0
- package/src/claude/scripts/req-assembler.js +127 -0
- package/src/claude/scripts/req-graph-checks.js +71 -0
- package/src/claude/scripts/req-skeleton-check.js +64 -0
- package/src/claude/scripts/tech-spec-assembler.js +188 -0
- package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
- package/src/claude/scripts/templates/gaia-component-template.md +64 -0
- package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
- package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
- package/src/claude/scripts/templates/gaia-task-template.md +92 -0
- package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
- package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
- package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
- package/src/claude/scripts/wb-graph-checks.js +754 -0
- package/src/claude/scripts/wb-render.js +6 -11
- package/src/claude/scripts/wb-skeleton-check.js +99 -0
- package/src/claude/scripts/wb-validate.js +49 -655
- package/src/claude/scripts/wbs-assembler.js +368 -0
- package/src/claude/skills/implement-feature/SKILL.md +12 -6
- package/src/claude/workflows/pm-phase1.js +342 -16
- 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
|
+
-->
|