@dtlabs/ai-toolkit 0.17.5 → 0.19.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 +29 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/package.json +1 -1
  4. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  5. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  6. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  7. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  8. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  9. package/src/claude/agents/gaia-generate-work-breakdown.md +124 -105
  10. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  11. package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
  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 +121 -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 +324 -18
  35. package/src/claude/workflows/pm-phase2.js +358 -15
@@ -0,0 +1,368 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // Directory/legacy loader + task/phase detail block codec for the split Work Breakdown
7
+ // format (FTR-020). Exports loadWorkBreakdown(inputPath), which detects single-file
8
+ // (legacy schema v2) vs. directory (wbs/, schema v3) input and returns the same
9
+ // { schemaVersion, feature, phases: [{ id, type, title, commit, tasks: [...] }] } shape
10
+ // wb-validate.js/wb-render.js already consume — their own logic is otherwise unchanged.
11
+ // Also exports parseTaskDetailBlock/renderTaskDetailBlock and, for the phase tier (OQ-04),
12
+ // parsePhaseDetailBlock/renderPhaseDetailBlock — all four independently reimplemented from
13
+ // the field-line/fence conventions already proven in lib/task-executor/plan.js, NOT
14
+ // imported from that module (feature.md Out of Scope — plan.js is not touched).
15
+
16
+ const DASH = '—'
17
+
18
+ // Field values are read from the CONTIGUOUS RUN of `- **Label:** value` lines starting
19
+ // immediately after the heading, stopping at the first non-matching line — NOT a global
20
+ // regex scan over the whole file. A task's Implementation notes section may legitimately
21
+ // illustrate this exact block format inside a fenced code example; a global scan would
22
+ // mistake that illustrative text for real field values. Found empirically while authoring
23
+ // this feature's own `wbs/` fixture (see `US-03-TASK-INFRA-01.md`).
24
+ const FIELD_LINE_RE = /^- \*\*(.+?):\*\*[ \t]?(.*)$/
25
+ const FENCE_RE = /^(`{3,})\r?\n([\s\S]*?)\r?\n\1[ \t]*$/gm
26
+
27
+ function _listOrEmpty(value) {
28
+ if (value === DASH) return []
29
+ return value.split(',').map(s => s.trim()).filter(Boolean)
30
+ }
31
+
32
+ function _stringOrNull(value) {
33
+ return value === DASH ? null : value
34
+ }
35
+
36
+ // ── Template engine ───────────────────────────────────────────────────────────
37
+ // Loads a canonical templates/gaia-{name}-template.md file and fills in its placeholders. The
38
+ // template file is the single source of truth for field labels, their order, and section
39
+ // headings — these renderers only ever supply VALUES, never hardcode the shape themselves (the
40
+ // whole point of FTR-025's fix: a field added/reworded/reordered in the template is picked up
41
+ // automatically, instead of the template and the renderer silently drifting apart the way the
42
+ // FTR-020 phase-detail template did for years before anything used it). Fails closed (throws) if
43
+ // the template references a label/section the caller didn't supply a value for, or vice versa —
44
+ // a silently-blank or stale placeholder slipping into real output is exactly the bug class this
45
+ // fix exists to prevent.
46
+
47
+ function _loadTemplate(name) {
48
+ return fs.readFileSync(path.join(__dirname, 'templates', `gaia-${name}-template.md`), 'utf8')
49
+ }
50
+
51
+ function _stripTemplateComments(text) {
52
+ return text.replace(/<!--[\s\S]*?-->/g, '')
53
+ }
54
+
55
+ // Fills the contiguous run of `- **Label:** <placeholder>` fields starting immediately after the
56
+ // heading — same contiguous-run rule _parseFieldBlock uses when reading an instance, applied here
57
+ // to a template to both discover its shape and substitute real values in one pass. A field's own
58
+ // placeholder guidance may itself be hard-wrapped across several physical lines (several of the
59
+ // real FTR-020 templates do this for a long explanation) — a continuation line never starts with
60
+ // `- **`, so it is skipped rather than mistaken for the end of the field block; only a blank line
61
+ // (or EOF) ends the run. The whole multi-line span for a given field is collapsed to the single
62
+ // substituted line.
63
+ function _fillFieldBlock(templateText, values) {
64
+ const lines = templateText.split('\n')
65
+ let start = 0
66
+ while (start < lines.length && !FIELD_LINE_RE.test(lines[start])) start++
67
+ const starts = []
68
+ let end = start
69
+ for (let j = start; j < lines.length; j++) {
70
+ const m = lines[j].match(FIELD_LINE_RE)
71
+ if (m) starts.push({ label: m[1] })
72
+ else if (lines[j].trim() === '') { end = j; break }
73
+ end = j + 1
74
+ }
75
+ const seen = new Set()
76
+ const newFieldLines = starts.map(({ label }) => {
77
+ if (!(label in values)) throw new Error(`Template field "${label}" has no supplied value`)
78
+ seen.add(label)
79
+ return `- **${label}:** ${values[label]}`
80
+ })
81
+ for (const label of Object.keys(values)) {
82
+ if (!seen.has(label)) throw new Error(`Supplied value for "${label}" matches no template field`)
83
+ }
84
+ return [...lines.slice(0, start), ...newFieldLines, ...lines.slice(end)].join('\n')
85
+ }
86
+
87
+ // Replaces the inline value after a `**Label:** <placeholder>` line (a bold field OUTSIDE the
88
+ // dash-prefixed field block, e.g. "Depends on phases:" — the value lives on the same line as the
89
+ // label, not in a separate block below it).
90
+ // Same multi-line-wrapped-placeholder handling as _fillFieldBlock: the value after the label may
91
+ // itself wrap across several physical lines (gaia-user-story-template.md's own "Depends on
92
+ // phases" placeholder does), so this consumes every line from the label's own line up to the next
93
+ // blank line (or EOF), not just the first line — a naive single-line regex would leave the
94
+ // wrapped continuation behind as orphaned text in the real output.
95
+ function _fillInlineBoldField(templateText, label, value) {
96
+ const lines = templateText.split('\n')
97
+ const marker = `**${label}:**`
98
+ const startIdx = lines.findIndex(l => l.includes(marker))
99
+ if (startIdx === -1) throw new Error(`Template is missing the "${label}" inline field`)
100
+ let end = startIdx + 1
101
+ while (end < lines.length && lines[end].trim() !== '') end++
102
+ return [...lines.slice(0, startIdx), `${marker} ${value}`, ...lines.slice(end)].join('\n')
103
+ }
104
+
105
+ // Replaces everything between a `**Section Name:**` marker and the next `**...:**` marker (or end
106
+ // of file) with real content — used for block-shaped sections (a paragraph, a bullet list), as
107
+ // opposed to _fillInlineBoldField's single-line value.
108
+ function _fillNamedSection(templateText, sectionName, content) {
109
+ const marker = `**${sectionName}:**`
110
+ const idx = templateText.indexOf(marker)
111
+ if (idx === -1) throw new Error(`Template is missing the "${sectionName}" section`)
112
+ const afterMarker = idx + marker.length
113
+ const nextMarkerIdx = templateText.indexOf('\n**', afterMarker)
114
+ const end = nextMarkerIdx === -1 ? templateText.length : nextMarkerIdx
115
+ return templateText.slice(0, afterMarker) + '\n\n' + content + '\n' + templateText.slice(end)
116
+ }
117
+
118
+ function _parseFieldBlock(text) {
119
+ const lines = text.split(/\r?\n/)
120
+ const fields = {}
121
+ let i = 0
122
+ while (i < lines.length && !FIELD_LINE_RE.test(lines[i])) i++
123
+ for (; i < lines.length; i++) {
124
+ const m = lines[i].match(FIELD_LINE_RE)
125
+ if (!m) break // first non-field line ends the contiguous run
126
+ fields[m[1].trim()] = m[2].trim()
127
+ }
128
+ return fields
129
+ }
130
+
131
+ function _parseVerificationCommands(text) {
132
+ const marker = '**Verification commands:**'
133
+ const idx = text.lastIndexOf(marker) // LAST occurrence — the real section is always the
134
+ // final one in a well-formed file; an earlier occurrence could be inside an illustrative
135
+ // example in the Implementation notes section (same collision risk as the field lines)
136
+ if (idx === -1) return []
137
+ const verifPart = text.slice(idx + marker.length)
138
+ const commands = []
139
+ let m
140
+ FENCE_RE.lastIndex = 0
141
+ while ((m = FENCE_RE.exec(verifPart))) commands.push(m[2])
142
+ return commands
143
+ }
144
+
145
+ // ── Task detail block codec ──────────────────────────────────────────────────
146
+
147
+ function parseTaskDetailBlock(text) {
148
+ const fields = _parseFieldBlock(text)
149
+ return {
150
+ groupingRationale: _stringOrNull(fields['Grouping rationale']),
151
+ verificationCommands: _parseVerificationCommands(text),
152
+ }
153
+ }
154
+
155
+ // Loads templates/gaia-task-template.md and fills it in — the template is the single source of
156
+ // truth for the 14 field labels/order and the two section headings; this function only supplies
157
+ // values. `implementationNotes` is optional: the real per-task agent (gaia-generate-work-
158
+ // breakdown-task.md) writes its own full response directly, so this function's only real caller
159
+ // is the round-trip tests, which never populate it — a neutral, clearly-a-placeholder default
160
+ // keeps the template's own required section non-blank without inventing fake content.
161
+ function renderTaskDetailBlock(task, implementationNotes) {
162
+ const cmds = (task.verification && task.verification.commands) || []
163
+ const cmdBlocks = cmds.length
164
+ ? cmds.map(c => '```\n' + c + '\n```').join('\n\n')
165
+ : '_No verification commands._'
166
+ const dependsOn = Array.isArray(task.dependsOn) && task.dependsOn.length ? task.dependsOn.join(', ') : DASH
167
+ const acceptanceCriteria = Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length
168
+ ? task.acceptanceCriteria.join(', ')
169
+ : DASH
170
+ let text = _stripTemplateComments(_loadTemplate('task'))
171
+ text = text.replace(/\{TASK_ID\}/g, task.id)
172
+ text = _fillFieldBlock(text, {
173
+ 'Task ID': task.id,
174
+ 'Title': task.title,
175
+ 'Outcome': task.outcome,
176
+ 'Domain': task.domain,
177
+ 'Agent type': task.agentType,
178
+ 'Dependencies': dependsOn,
179
+ 'Acceptance criteria': acceptanceCriteria,
180
+ 'Estimate — agent minutes': task.estimate.agentMinutes,
181
+ 'Estimate — tokens': task.estimate.tokens,
182
+ 'Output count': task.outputCount,
183
+ 'Grouping rationale': task.groupingRationale == null ? DASH : task.groupingRationale,
184
+ 'Commit type': task.commit.type,
185
+ 'Commit scope': task.commit.scope == null ? DASH : task.commit.scope,
186
+ 'Commit subject': task.commit.subject,
187
+ })
188
+ text = _fillNamedSection(text, 'Implementation notes', implementationNotes == null ? '_No implementation notes supplied._' : implementationNotes)
189
+ text = _fillNamedSection(text, 'Verification commands', cmdBlocks)
190
+ return text.trim() + '\n'
191
+ }
192
+
193
+ // ── Phase detail block codec (OQ-04, third tier — no fenced section) ─────────
194
+
195
+ function parsePhaseDetailBlock(text) {
196
+ const fields = _parseFieldBlock(text)
197
+ return {
198
+ title: fields['Title'],
199
+ commit: {
200
+ type: _stringOrNull(fields['Commit type']),
201
+ scope: _stringOrNull(fields['Commit scope']),
202
+ subject: _stringOrNull(fields['Commit subject']),
203
+ },
204
+ }
205
+ }
206
+
207
+ // Loads templates/gaia-user-story-template.md and fills it in — the template is the single
208
+ // source of truth for the 7 field labels/order and the three section headings (Context/Tasks in
209
+ // this phase/Depends on phases); this function only supplies values, mechanically derived where
210
+ // possible (Source UC from phase.sourceUC, Tasks-in-phase from each task's own title, Depends-on-
211
+ // phases from the task dependsOn graph — see pm-phase2.js's computeDependsOnPhases) or taken
212
+ // as-is from the one field that genuinely needs narrative judgment (Context). taskTitles/
213
+ // dependsOnPhases default to [] so the minimal round-trip tests (which only exercise title/
214
+ // commit) can keep calling this with just a phase object.
215
+ function renderPhaseDetailBlock(phase, taskTitles = [], dependsOnPhases = []) {
216
+ const sourceUC = Array.isArray(phase.sourceUC) && phase.sourceUC.length ? phase.sourceUC.join(', ') : DASH
217
+ const tasksBlock = taskTitles.length
218
+ ? taskTitles.map(t => `- \`${t.id}\` — ${t.title}`).join('\n')
219
+ : '_No tasks._'
220
+ const dependsOn = dependsOnPhases.length ? dependsOnPhases.join(', ') : 'none'
221
+ let text = _stripTemplateComments(_loadTemplate('user-story'))
222
+ text = text.replace(/\{PHASE_ID\}/g, phase.id)
223
+ text = _fillFieldBlock(text, {
224
+ 'Phase ID': phase.id,
225
+ 'Type': phase.type,
226
+ 'Source UC': sourceUC,
227
+ 'Title': phase.title,
228
+ 'Commit type': phase.commit.type,
229
+ 'Commit scope': phase.commit.scope == null ? DASH : phase.commit.scope,
230
+ 'Commit subject': phase.commit.subject,
231
+ })
232
+ text = _fillNamedSection(text, 'Context', phase.context == null ? DASH : phase.context)
233
+ text = _fillNamedSection(text, 'Tasks in this phase', tasksBlock)
234
+ text = _fillInlineBoldField(text, 'Depends on phases', dependsOn)
235
+ return text.trim() + '\n'
236
+ }
237
+
238
+ // ── loadWorkBreakdown(inputPath) ──────────────────────────────────────────────
239
+
240
+ function _assemblyError(message) {
241
+ const err = new Error(message)
242
+ err.code = 'WBS_ASSEMBLY_ERROR'
243
+ return err
244
+ }
245
+
246
+ function _isDirectory(p) {
247
+ try {
248
+ return fs.statSync(p).isDirectory()
249
+ } catch (err) {
250
+ throw _assemblyError(`cannot access "${p}": ${err.message}`)
251
+ }
252
+ }
253
+
254
+ function _readFile(p) {
255
+ try {
256
+ return fs.readFileSync(p, 'utf8')
257
+ } catch (err) {
258
+ throw _assemblyError(`cannot read "${p}": ${err.message}`)
259
+ }
260
+ }
261
+
262
+ function loadWorkBreakdown(inputPath) {
263
+ if (_isDirectory(inputPath)) {
264
+ const indexPath = path.join(inputPath, 'index.json')
265
+ let index
266
+ try {
267
+ index = JSON.parse(_readFile(indexPath))
268
+ } catch (err) {
269
+ if (err.code === 'WBS_ASSEMBLY_ERROR') throw err
270
+ throw _assemblyError(`"${indexPath}" is not valid JSON: ${err.message}`)
271
+ }
272
+ if (index.schemaVersion !== 3) {
273
+ throw _assemblyError(`"${indexPath}" must have schemaVersion 3, got ${JSON.stringify(index.schemaVersion)}`)
274
+ }
275
+
276
+ const phases = (index.phases || []).map(phaseSkeleton => {
277
+ const phaseDetailPath = path.join(inputPath, phaseSkeleton.detailFile)
278
+ if (!fs.existsSync(phaseDetailPath)) {
279
+ throw _assemblyError(`phase "${phaseSkeleton.id}" has no detail file at ${phaseDetailPath}`)
280
+ }
281
+ const phaseDetail = parsePhaseDetailBlock(_readFile(phaseDetailPath))
282
+
283
+ const tasks = (phaseSkeleton.taskIds || []).map(taskId => {
284
+ const skeletonTask = index.tasks ? index.tasks[taskId] : null
285
+ if (skeletonTask == null) {
286
+ throw _assemblyError(`index.json references unknown task "${taskId}"`)
287
+ }
288
+ const detailPath = path.join(inputPath, skeletonTask.detailFile)
289
+ if (!fs.existsSync(detailPath)) {
290
+ throw _assemblyError(`task "${taskId}" has no detail file at ${detailPath}`)
291
+ }
292
+ const detail = parseTaskDetailBlock(_readFile(detailPath))
293
+ return {
294
+ id: taskId,
295
+ title: skeletonTask.title,
296
+ outcome: skeletonTask.outcome,
297
+ domain: skeletonTask.domain,
298
+ agentType: skeletonTask.agentType,
299
+ dependsOn: skeletonTask.dependsOn,
300
+ acceptanceCriteria: skeletonTask.acceptanceCriteria,
301
+ estimate: skeletonTask.estimate,
302
+ outputCount: skeletonTask.outputCount,
303
+ groupingRationale: detail.groupingRationale,
304
+ commit: skeletonTask.commit,
305
+ verification: { commands: detail.verificationCommands },
306
+ }
307
+ })
308
+
309
+ return {
310
+ id: phaseSkeleton.id,
311
+ type: phaseSkeleton.type,
312
+ title: phaseDetail.title,
313
+ commit: phaseDetail.commit,
314
+ // sourceUC was dropped here from FTR-020 through FTR-024 — wb-graph-checks.js's
315
+ // check 20 (AC scope validation) needs it to correctly scope an AC to the phase(s)
316
+ // that actually realize its UC, instead of assuming phase ID numbering matches UC
317
+ // numbering 1:1 (which the worked example behind this initiative already found false
318
+ // in general: one phase can span several UCs, one UC can span several phases).
319
+ sourceUC: phaseSkeleton.sourceUC,
320
+ tasks,
321
+ }
322
+ })
323
+
324
+ // Normalized to the existing in-memory shape wb-validate.js/wb-render.js already
325
+ // consume; schemaVersion is reported as 2 here because that is the *assembled,
326
+ // in-memory* contract version those scripts' downstream logic was written against —
327
+ // index.json's own on-disk schemaVersion stays 3, a separate, unrelated version number.
328
+ return { schemaVersion: 2, feature: index.feature, phases }
329
+ }
330
+
331
+ // Legacy path: unchanged behavior — no schemaVersion gate here. wb-validate.js's own
332
+ // check 2 is what reports a schemaVersion mismatch, as a NON-FATAL report entry (exit 1),
333
+ // not a fatal parse error (exit 2); gating on it here would turn that into a fatal abort
334
+ // and break the existing contract (AC-06: "loaded exactly as today").
335
+ return JSON.parse(_readFile(inputPath))
336
+ }
337
+
338
+ module.exports = {
339
+ loadWorkBreakdown,
340
+ parseTaskDetailBlock,
341
+ renderTaskDetailBlock,
342
+ parsePhaseDetailBlock,
343
+ renderPhaseDetailBlock,
344
+ }
345
+
346
+ // ── CLI entry point ───────────────────────────────────────────────────────────
347
+ // pm-phase2.js's workflow-script runtime has no `require` (see lib/workflow-runner.js),
348
+ // so it cannot call loadWorkBreakdown() in-process the way wb-validate.js/wb-render.js do.
349
+ // This CLI mode lets it get the assembled structure (needed for c.estimates()'s metrics)
350
+ // via the same `c.command(['run-asset', 'scripts/wbs-assembler.js', ...])` subprocess
351
+ // pattern already used for wb-validate.js/wb-render.js — prints the assembled JSON to
352
+ // stdout. Coexists with the module.exports above; this guard only runs when the file is
353
+ // executed directly, not when required.
354
+ if (require.main === module) {
355
+ const inputPath = process.argv[2]
356
+ if (!inputPath) {
357
+ process.stderr.write('Usage: node wbs-assembler.js <path-to-work-breakdown-or-wbs-dir>\n')
358
+ process.exit(2)
359
+ }
360
+ try {
361
+ const wb = loadWorkBreakdown(path.resolve(inputPath))
362
+ process.stdout.write(JSON.stringify(wb) + '\n')
363
+ process.exit(0)
364
+ } catch (err) {
365
+ process.stderr.write(`Error: ${err.message}\n`)
366
+ process.exit(2)
367
+ }
368
+ }
@@ -65,8 +65,11 @@ The host verifies the installed workflow and each worker and owns all ledger tra
65
65
 
66
66
  Extract from the result:
67
67
  - `prefix` — feature prefix (e.g. `FTR-009`)
68
- - `requirements.summary` — requirements file summary
69
- - `tech_spec.summary` — tech-spec file summary
68
+ - `requirements.path` / `requirements.summary` — Requirements artifact path (the split
69
+ `req/index.md` by default — generation no longer produces a single assembled file
70
+ automatically) and its summary
71
+ - `tech_spec.path` / `tech_spec.summary` — Tech-Spec artifact path (the split `spec/index.md` by
72
+ default) and its summary
70
73
  - `validation.summary` — validation result (gaps or clean)
71
74
  - `docs_digest` — content digest binding this approval to the exact Requirements/Tech-Spec
72
75
  bytes just produced; embed verbatim into `{PREFIX}-Approvals.md` in Step 2 below
@@ -85,9 +88,12 @@ Present the following to the user:
85
88
  ```
86
89
  Documents generated for {PREFIX}:
87
90
 
88
- {PREFIX}-Requirements.md — {requirements.summary}
89
- {PREFIX}-Tech-Spec.md — {tech_spec.summary}
91
+ {requirements.path} — {requirements.summary}
92
+ {tech_spec.path} — {tech_spec.summary}
90
93
  {PREFIX}-Validation-Report.md — {validation.summary}
94
+
95
+ To view a single combined document instead of the split source files, run:
96
+ /assemble-docs {PREFIX} [req|spec|all]
91
97
  ```
92
98
 
93
99
  Then output this hard-stop message and **wait for a text reply from the user**:
@@ -115,8 +121,8 @@ The pipeline CANNOT continue until you reply directly.
115
121
 
116
122
  | Document | Status | Date | Notes |
117
123
  |----------|--------|------|-------|
118
- | {PREFIX}-Requirements.md | ✅ Approved | {today} | {notes or —} |
119
- | {PREFIX}-Tech-Spec.md | ✅ Approved | {today} | {notes or —} |
124
+ | {requirements.path} | ✅ Approved | {today} | {notes or —} |
125
+ | {tech_spec.path} | ✅ Approved | {today} | {notes or —} |
120
126
  | {PREFIX}-Validation-Report.md | ✅ Approved | {today} | {notes or —} |
121
127
 
122
128
  **Docs Digest:** `{docs_digest}`