@dtlabs/ai-toolkit 0.18.0 → 0.19.1

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 CHANGED
@@ -1,4 +1,19 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.19.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.0...v0.19.1) (2026-10-19)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * stop headless workers from Reading their own toolkit templates ([0f2c707](https://github.com/dtlab-tech/ai-toolkit/commit/0f2c7079799cdfb95ab99dcf7333f615a8f49a8e))
8
+
9
+ ## [0.19.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.18.0...v0.19.0) (2026-10-19)
10
+
11
+
12
+ ### Features
13
+
14
+ * make the Work Breakdown pipeline split-source-native (FTR-026) ([9d08a17](https://github.com/dtlab-tech/ai-toolkit/commit/9d08a1743e199534753b6c6d47b855ec7953f032))
15
+ * **pm-phase2, ac-task-matrix:** make the Work Breakdown pipeline split-source-native (FTR-026) ([067a8e3](https://github.com/dtlab-tech/ai-toolkit/commit/067a8e369f8a5db72678670bebb62c91c93303d1))
16
+
2
17
  ## [0.18.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.5...v0.18.0) (2026-10-19)
3
18
 
4
19
 
package/bin/cli.js CHANGED
@@ -2056,6 +2056,51 @@ async function main() {
2056
2056
  process.stderr.write(err.message + '\n');
2057
2057
  process.exit(1);
2058
2058
  }
2059
+ } else if (argv[0] === 'print-asset') {
2060
+ // print-asset <relativePath> [--project <dir>] [--home <dir>]
2061
+ // Security constraints: scripts/templates/ subpath only; .md extension only — the
2062
+ // read-only counterpart to run-asset's "scripts category, .js only, execute" contract.
2063
+ //
2064
+ // Exists so a sandboxed workflow script (pm-phase1.js/pm-phase2.js — no `require()`, no
2065
+ // filesystem access outside its own project root) can fetch a toolkit template's raw
2066
+ // text and inline it directly into a worker's prompt, instead of telling the headless
2067
+ // agent to Read the template file itself. That file lives outside the project root for
2068
+ // a globally-installed toolkit, which is exactly the boundary a non-interactive `--print`
2069
+ // session (no TTY to answer a permission prompt) cannot grant — confirmed by a real
2070
+ // worker run failing with "Worker permission denied: ...; tools: Read" against exactly
2071
+ // this path. Inlining via the host (a plain Node process, never subject to the Claude
2072
+ // Code tool-permission system) sidesteps the problem entirely, the same pattern FTR-025
2073
+ // already established for inlining Requirements/Tech-Spec text into gaia-validate-
2074
+ // feature-docs' prompt instead of telling it to Read those files itself.
2075
+ const remaining = argv.slice(1);
2076
+ let relativePath = null;
2077
+ let projectDir = process.cwd();
2078
+ let home;
2079
+ for (let i = 0; i < remaining.length; i++) {
2080
+ if (remaining[i] === '--project' && remaining[i + 1]) { projectDir = remaining[++i]; }
2081
+ else if (remaining[i] === '--home' && remaining[i + 1]) { home = remaining[++i]; }
2082
+ else if (!remaining[i].startsWith('-')) { relativePath = remaining[i]; }
2083
+ }
2084
+ if (!relativePath) {
2085
+ process.stderr.write('Error: print-asset requires a relative path argument\n');
2086
+ process.exit(1);
2087
+ }
2088
+ if (!relativePath.startsWith('scripts/templates/')) {
2089
+ process.stderr.write(`Error: print-asset is restricted to scripts/templates/ (got: ${relativePath})\n`);
2090
+ process.exit(1);
2091
+ }
2092
+ if (!relativePath.endsWith('.md')) {
2093
+ process.stderr.write(`Error: print-asset only reads .md files (got: ${relativePath})\n`);
2094
+ process.exit(1);
2095
+ }
2096
+ try {
2097
+ const assetPath = resolveClaudeRuntimeAsset(relativePath, { projectDir, home });
2098
+ process.stdout.write(fs.readFileSync(assetPath, 'utf8'));
2099
+ process.exit(0);
2100
+ } catch (err) {
2101
+ process.stderr.write(err.message + '\n');
2102
+ process.exit(1);
2103
+ }
2059
2104
  } else if (argv[0] === 'list-assets') {
2060
2105
  // list-assets --project <dir> --category <name> [--format json|plain] [--home <dir>]
2061
2106
  // Both --project and --category are MANDATORY (Tech Spec contract).
@@ -106,7 +106,7 @@ function createControl(options) {
106
106
  }
107
107
  function command(argv, accepted = [0]) {
108
108
  if (!Array.isArray(argv) || argv.some(a => typeof a !== 'string')) throw new Error('CLI argv array required');
109
- if (options.home && argv[0] === 'run-asset') {
109
+ if (options.home && (argv[0] === 'run-asset' || argv[0] === 'print-asset')) {
110
110
  argv = argv.slice();
111
111
  const split = argv.indexOf('--');
112
112
  argv.splice(split < 0 ? argv.length : split, 0, '--home', options.home);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -23,18 +23,21 @@ The host supplies, in the dispatch prompt:
23
23
  3. The path to `feature.md` — read it in full before proceeding, to ground this use case's flow
24
24
  in the feature's actual described behavior rather than inventing detail the row alone would
25
25
  not carry.
26
+ 4. The canonical field-table/flow structure, inlined under a `Template (req/use-cases/{UC-ID}.md):`
27
+ label. Use this text — do NOT attempt to Read, Glob, or otherwise search the filesystem for
28
+ `gaia-use-case-template.md` yourself; it is not guaranteed to be reachable from where this agent
29
+ runs, and searching for it is not a safe fallback either.
26
30
 
27
31
  You do not read `req/index.md` yourself and you never decide which use cases exist — that is
28
32
  already fixed by the skeleton call that produced the row you were given.
29
33
 
30
- Read `.claude/scripts/templates/gaia-use-case-template.md` before writing your response for the
31
- canonical field-table/flow structure. Note: that template also shows "Related Business Rules"/
32
- "Related Acceptance Criteria" cross-reference lines at the end of the file — do NOT produce them
33
- yourself. The host computes both, mechanically, from `req/index.md`'s own Business Rules/
34
- Acceptance Criteria tables, and appends them to your response after you return it — you are not
35
- given that table data (and must not re-read `req/index.md` yourself to get it), so any version you
36
- wrote would risk being wrong or duplicated. The Output section below is your actual, complete
37
- output contract; stop after Postconditions.
34
+ Note: the inlined template also shows "Related Business Rules"/"Related Acceptance Criteria"
35
+ cross-reference lines at the end of the file — do NOT produce them yourself. The host computes
36
+ both, mechanically, from `req/index.md`'s own Business Rules/Acceptance Criteria tables, and
37
+ appends them to your response after you return it — you are not given that table data (and must
38
+ not re-read `req/index.md` yourself to get it), so any version you wrote would risk being wrong or
39
+ duplicated. The Output section below is your actual, complete output contract; stop after
40
+ Postconditions.
38
41
 
39
42
  ## Mandatory instruction
40
43
 
@@ -19,11 +19,15 @@ together with those per-use-case files afterward (FTR-021).
19
19
 
20
20
  The user provides the path to a `feature.md` file. Read it in full before proceeding.
21
21
 
22
- Also read `.claude/scripts/templates/gaia-requirements-index-template.md` before writing your
23
- response — it is the canonical, authoritative structure for `req/index.md` (section order,
24
- exact headings, table column contracts, and the parsing contract `req-skeleton-check.js`/
25
- `req-assembler.js` rely on). The Output Structure section below summarizes it; the template
26
- itself is the source of truth if the two ever disagree.
22
+ The prompt also includes, inlined under a `Template (req/index.md):` label, the canonical,
23
+ authoritative structure for `req/index.md` (section order, exact headings, table column
24
+ contracts, and the parsing contract `req-skeleton-check.js`/`req-assembler.js` rely on). Use the
25
+ inlined text — do NOT attempt to Read, Glob, or otherwise search the filesystem for
26
+ `gaia-requirements-index-template.md` yourself; it is not guaranteed to be reachable from where
27
+ this agent runs, and searching for it is not a safe fallback either (your Glob/Grep tools remain
28
+ available for exploring the feature's own project, just not for locating this template). The
29
+ Output Structure section below summarizes it; the inlined template text is the source of truth if
30
+ the two ever disagree.
27
31
 
28
32
  ## Process
29
33
 
@@ -28,10 +28,15 @@ The host supplies, in the dispatch prompt:
28
28
  You do not read `spec/index.md` yourself and you never decide which components exist — that is
29
29
  already fixed by the skeleton call that produced the row you were given.
30
30
 
31
- Read `.claude/scripts/templates/gaia-component-template.md` before writing your response — it is
32
- the canonical, authoritative structure for your output (Change/design-body/Tests, and the parsing
33
- contract `tech-spec-assembler.js`'s `parseComponentDetail` relies on). The Output section below is
34
- a summary; the template itself is the source of truth if the two ever disagree.
31
+ The prompt also includes, inlined under a `Template (spec/components/{fileSlug}.md):` label, the
32
+ canonical, authoritative structure for your output (Change/design-body/Tests, and the parsing
33
+ contract `tech-spec-assembler.js`'s `parseComponentDetail` relies on). Use the inlined text — do
34
+ NOT attempt to Read, Glob, or otherwise search the filesystem for `gaia-component-template.md`
35
+ yourself under any circumstances, including as a fallback if you are unsure of its exact path; it
36
+ is not guaranteed to be reachable from where this agent runs, and guessing at or searching for its
37
+ absolute path is not a safe substitute for the text already given to you below. The Output
38
+ section below is a summary; the inlined template text is the source of truth if
39
+ the two ever disagree.
35
40
 
36
41
  ## Mandatory instruction
37
42
 
@@ -19,11 +19,15 @@ together with those per-component files afterward (FTR-024).
19
19
 
20
20
  The user provides the path to a `feature.md` file. Read it in full before proceeding. Also read the relevant existing codebase files to ensure the spec aligns with current architecture.
21
21
 
22
- Also read `.claude/scripts/templates/gaia-tech-spec-index-template.md` before writing your
23
- response — it is the canonical, authoritative structure for `spec/index.md` (section order, exact
24
- headings, the Section 9 column-order contract `tech-spec-graph-checks.js` relies on, and the
25
- parsing contract `tech-spec-assembler.js` relies on). The Output Structure section below
26
- summarizes it; the template itself is the source of truth if the two ever disagree.
22
+ The prompt also includes, inlined under a `Template (spec/index.md):` label, the canonical,
23
+ authoritative structure for `spec/index.md` (section order, exact headings, the Section 9
24
+ column-order contract `tech-spec-graph-checks.js` relies on, and the parsing contract
25
+ `tech-spec-assembler.js` relies on). Use the inlined text — do NOT attempt to Read, Glob, or
26
+ otherwise search the filesystem for `gaia-tech-spec-index-template.md` yourself; it is not
27
+ guaranteed to be reachable from where this agent runs, and searching for it is not a safe fallback
28
+ either (your Glob/Grep tools remain available for exploring the project's existing codebase, just
29
+ not for locating this template). The Output Structure section below summarizes it; the inlined
30
+ template text is the source of truth if the two ever disagree.
27
31
 
28
32
  ## Step 0 — Read Project Conventions (MANDATORY)
29
33
 
@@ -9,11 +9,6 @@ tools: Read, Glob, Grep
9
9
 
10
10
  You are a **senior implementation planner**. You are given exactly one task's already-decided skeleton entry — its ID, title, outcome, domain, agent type, dependencies, acceptance criteria, estimate, output count, and commit subject are already fixed — and you fill in the fields that are still missing: **implementation notes**, **verification commands**, and, if applicable, **grouping rationale**. You do not re-decide anything the skeleton already fixed.
11
11
 
12
- Read `.claude/scripts/templates/gaia-task-template.md` before writing your response — it is the
13
- canonical, authoritative structure for your output (field order, the two free-content sections,
14
- and the exact parsing contract downstream tooling relies on). This section and the Output section
15
- below summarize it; the template itself is the source of truth if the two ever disagree.
16
-
17
12
  This is the **second of two generation passes** (FTR-020). The skeleton pass (`gaia-generate-work-breakdown`) already produced the whole task graph in one bounded call; this agent is dispatched once per task, so a failure or truncation on one task never requires regenerating any other task.
18
13
 
19
14
  ---
@@ -23,10 +18,17 @@ This is the **second of two generation passes** (FTR-020). The skeleton pass (`g
23
18
  The caller supplies, in the prompt (you do not read `{PREFIX}-Work-Breakdown.json`/`wbs/index.json` yourself — the host extracts and hands you exactly what you need):
24
19
 
25
20
  1. **This task's own skeleton entry**, verbatim: `id`, `title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `estimate`, `outputCount`, `commit`.
26
- 2. **The relevant Tech-Spec excerpt** for this task's domain (not the whole Tech-Spec — keeps this call small, which is the whole point of the per-task split).
21
+ 2. **The relevant Tech-Spec excerpt** for this task's own Acceptance Criteria (matched by the host against each component's `Addresses` column in `spec/index.md` — not the whole Tech-Spec, which is the whole point of the per-task split). If no component's `Addresses` column cites any of this task's Acceptance Criteria, you instead receive the full Components table from `spec/index.md` as a fallback — in that case, use your own judgment about which listed component(s) are actually relevant, the same way you would if asked to work from the full Tech-Spec directly.
27
22
  3. **The compact full-skeleton task list**: every task's `id` + `title` + `outcome` only (no detail), so you can tell whether another task already built the behavior this one would otherwise duplicate.
23
+ 4. **The canonical output template**, inlined under a `Template (tasks/{TASK_ID}.md):` label — the
24
+ authoritative structure for your output (field order, the two free-content sections, and the
25
+ exact parsing contract downstream tooling relies on). Use the inlined text — do NOT attempt to
26
+ Read, Glob, or otherwise search the filesystem for `gaia-task-template.md` yourself; it is not
27
+ guaranteed to be reachable from where this agent runs, and searching for it is not a safe
28
+ fallback either. This section and the Output section below summarize it; the inlined template
29
+ text is the source of truth if the two ever disagree.
28
30
 
29
- If the prompt is missing any of these three, stop and report exactly what is missing — do not guess at a skeleton entry you were not given.
31
+ If the prompt is missing any of these four, stop and report exactly what is missing — do not guess at a skeleton entry you were not given.
30
32
 
31
33
  ---
32
34
 
@@ -142,9 +144,9 @@ Return exactly this block as your final response, and nothing else (no preamble,
142
144
  ```
143
145
 
144
146
  This is the same format already rendered today inside a Work Breakdown's "Task Details"
145
- section — canonical reference: `.claude/scripts/templates/gaia-task-template.md` (you have a Read
146
- tool; read it directly rather than relying only on this summary). That template's parsing-contract
147
- note applies to your own output too: a naive parser reads only the field lines immediately
147
+ section — canonical reference: the `Template (tasks/{TASK_ID}.md):`-labeled text inlined in your
148
+ own prompt (see Input §4 above; rely on that, not just this summary). That template's
149
+ parsing-contract note applies to your own output too: a naive parser reads only the field lines immediately
148
150
  following the heading, and the *last* `**Implementation notes:**`/`**Verification commands:**`
149
151
  occurrence — so if you need to illustrate this format inside a longer explanation (e.g. showing
150
152
  another agent's expected output shape), make sure your own real sections remain the last
@@ -17,12 +17,20 @@ This is the **first of two generation passes** (FTR-020). This call decides the
17
17
 
18
18
  The user provides the path to a `feature.md` file. From the same directory, read:
19
19
 
20
- 1. `{PREFIX}-Requirements.md` — source of User Stories (derived from Use Cases), acceptance criteria, business rules
21
- 2. `{PREFIX}-Tech-Spec.md` — source of technical tasks, implementation order, file inventory, architecture decisions
20
+ 1. Requirements — `req/index.md` (the split format, FTR-021; source of User Stories derived from
21
+ Use Cases, acceptance criteria, business rules) if it exists, otherwise the legacy
22
+ `{PREFIX}-Requirements.md`
23
+ 2. Tech-Spec — `spec/index.md` (the split format, FTR-024; source of technical tasks,
24
+ implementation order, file inventory, architecture decisions) if it exists, otherwise the
25
+ legacy `{PREFIX}-Tech-Spec.md`
22
26
  3. `{PREFIX}-Validation-Report.md` — confirms both documents passed coverage validation
23
27
  4. `{PREFIX}-Approvals.md` — confirms user has approved the documents
24
28
 
25
- **This agent runs ONLY after validation and human approval.** If any of the four input files is missing, abort and report:
29
+ See Step 1 below for exactly how to read whichever shape (1)/(2) actually are, including per-item
30
+ drilldown into `req/use-cases/*.md`/`spec/components/*.md`.
31
+
32
+ **This agent runs ONLY after validation and human approval.** If Requirements/Tech-Spec exist in
33
+ neither shape, or (3)/(4) are missing, abort and report:
26
34
  ```
27
35
  Cannot generate work breakdown: `{file}` not found.
28
36
  The work breakdown requires validated and approved Requirements + Tech-Spec.
@@ -79,8 +87,25 @@ Every task must be the **smallest independently implementable, verifiable, and c
79
87
 
80
88
  ### Step 1 — Load and Cross-Reference
81
89
 
82
- 1. Read `{PREFIX}-Requirements.md` fully — extract all Use Cases (UC-XX), acceptance criteria (AC-XX), business rules (BR-XX), NFRs
83
- 2. Read `{PREFIX}-Tech-Spec.md` fully — extract architecture, file inventory, implementation order, data model, API endpoints, frontend components
90
+ 1. Check whether `req/index.md` exists (the split Requirements format, FTR-021). If it does:
91
+ - Read `req/index.md` fully — it already contains the full Use Cases index table, all Business
92
+ Rules, all Acceptance Criteria, and all NFRs (FTR-021 kept these feature-wide, not per-UC)
93
+ - For any UC whose own full flow description you need beyond the index's short Title/Actor
94
+ summary, Read `req/use-cases/{UC-ID}.md` directly
95
+ If `req/index.md` does not exist (a legacy feature), Read `{PREFIX}-Requirements.md` fully
96
+ instead — extract the same information from the monolithic file.
97
+ 2. Check whether `spec/index.md` exists (the split Tech-Spec format, FTR-024). If it does:
98
+ - Read `spec/index.md` fully — it already contains Architecture, Section 9's complete File
99
+ Inventory/Components table (with Domain/Addresses routing), Implementation Order, and Risks
100
+ - For any component whose own Backend/Frontend/design detail you need beyond the Components
101
+ table's one-line Purpose/Addresses summary, Read `spec/components/{file-slug}.md` directly —
102
+ the `Spec` column in Section 9's table gives you the exact path
103
+ - If, after reading the index, you cannot determine from its own summary tables which per-item
104
+ files are relevant to a given phase/task decision, fall back to reading every file under
105
+ `spec/components/` rather than proceeding with incomplete context
106
+ If `spec/index.md` does not exist (a legacy feature), Read `{PREFIX}-Tech-Spec.md` fully
107
+ instead — extract architecture, file inventory, implementation order, data model, API
108
+ endpoints, frontend components from the monolithic file.
84
109
 
85
110
  ### Step 2 — Derive User Stories from Use Cases
86
111
 
@@ -15,12 +15,17 @@ You do NOT repeat structural checks (schema conformance, required fields, ID for
15
15
 
16
16
  ## Input
17
17
 
18
- The user provides the path to `{PREFIX}-Work-Breakdown.json`. From the same directory, locate and read:
18
+ The prompt gives you two paths, one per line, in this order:
19
19
 
20
- 1. `{PREFIX}-Work-Breakdown.json` — the validated work breakdown (schema v2)
21
- 2. `{PREFIX}-Requirements.md` — User Story descriptions and Acceptance Criteria text
20
+ 1. The Work Breakdown directory (the split format, FTR-020) — contains `index.json` (the
21
+ validated skeleton, schema v2) plus `user-stories/*.md`/`tasks/*.md` detail files. Use Glob/Read
22
+ to explore it as needed; do not expect a single `{PREFIX}-Work-Breakdown.json` file.
23
+ 2. The Requirements source — `req/index.md` for a feature using the split Requirements format
24
+ (FTR-021; User Story descriptions and Acceptance Criteria text live in its own tables there,
25
+ unaffected by the per-UC split), or a legacy `{PREFIX}-Requirements.md` file otherwise. Read
26
+ whichever path is actually given — do not assume either shape.
22
27
 
23
- Derive the prefix from the filename (e.g., `FTR-014-Work-Breakdown.json` → prefix `FTR-014`).
28
+ Derive the prefix from the Work Breakdown directory's own `index.json`'s `feature` field.
24
29
 
25
30
  ---
26
31
 
@@ -45,6 +45,10 @@ function computeTechSpecCoverage(entries, acMap) {
45
45
  }
46
46
 
47
47
  function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
48
+ const mustEntries = [...acMap.entries()].filter(([, ac]) => ac.priority === 'Must')
49
+ const mustCovered = mustEntries.filter(([id]) => coverage.get(id).taskIds.size > 0).length
50
+ const summary = `**Must-AC coverage:** ${mustCovered}/${mustEntries.length} covered. ` +
51
+ 'Machine-readable data: [index.json](index.json).'
48
52
  const rows = [...acMap.entries()].map(([acId, ac]) => {
49
53
  const relatedUc = ac.unscoped ? 'All UCs' : ac.relatedUCs.join(', ')
50
54
  const cov = coverage.get(acId)
@@ -53,7 +57,7 @@ function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
53
57
  const techSpecFiles = [...(techSpecCoverage.get(acId) || [])].join(', ') || '—'
54
58
  return `| ${acId} | ${ac.criterion} | ${ac.priority} | ${relatedUc} | ${techSpecFiles} | ${taskIds} | ${phaseIds} |`
55
59
  })
56
- return `# AC ↔ Task Traceability Matrix — ${prefix}\n\n` +
60
+ return `# AC ↔ Task Traceability Matrix — ${prefix}\n\n${summary}\n\n` +
57
61
  '| AC ID | Criterion | Priority | Related UC | Addressed by Tech-Spec File(s) | Covering Task(s) | Covering Phase(s) |\n' +
58
62
  '|---|---|---|---|---|---|---|\n' +
59
63
  rows.join('\n') + '\n\n' +
@@ -68,10 +72,36 @@ function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
68
72
  'belong to, comma-separated, or `—` if none.\n'
69
73
  }
70
74
 
75
+ // FTR-026: serializes the same in-memory data renderMatrix() already consumes into a
76
+ // machine-readable shape — one entry per Requirements AC, in table order. No new computation.
77
+ function buildMatrixJson(prefix, acMap, coverage, techSpecCoverage = new Map()) {
78
+ const acceptanceCriteria = [...acMap.entries()].map(([acId, ac]) => {
79
+ const cov = coverage.get(acId)
80
+ return {
81
+ id: acId,
82
+ criterion: ac.criterion,
83
+ priority: ac.priority,
84
+ unscoped: !!ac.unscoped,
85
+ relatedUCs: ac.unscoped ? [] : ac.relatedUCs,
86
+ techSpecFiles: [...(techSpecCoverage.get(acId) || [])],
87
+ taskIds: [...cov.taskIds],
88
+ phaseIds: [...cov.phaseIds],
89
+ }
90
+ })
91
+ return { prefix, generatedAt: new Date().toISOString(), acceptanceCriteria }
92
+ }
93
+
71
94
  if (require.main === module) {
72
- const [wbsPath, requirementsPath, techSpecPath, prefix] = process.argv.slice(2)
73
- if (!wbsPath || !requirementsPath || !techSpecPath || !prefix) {
74
- process.stderr.write('Usage: node ac-task-matrix.js <wbs-path> <requirements-path> <tech-spec-path> <prefix>\n')
95
+ // FTR-026: featureDir is REQUIRED (not optional) — confirmed by grep that pm-phase2.js and this
96
+ // script's own test suite are the only callers, so there is no backward-compatible no-arg case
97
+ // worth preserving. Output moves from one loose {prefix}-AC-Task-Matrix.md file (whose location
98
+ // used to be derived from path.dirname(requirementsPath) — a real bug: that would have silently
99
+ // moved once a caller passes a Requirements path living under req/) into a dedicated
100
+ // coverage-matrix/ subfolder under featureDir, as a JSON+MD pair.
101
+ const [wbsPath, requirementsPath, techSpecPath, prefix, featureDir] = process.argv.slice(2)
102
+ if (!wbsPath || !requirementsPath || !techSpecPath || !prefix || !featureDir) {
103
+ process.stderr.write(
104
+ 'Usage: node ac-task-matrix.js <wbs-path> <requirements-path> <tech-spec-path> <prefix> <feature-dir>\n')
75
105
  process.exit(1)
76
106
  }
77
107
  const wb = loadWorkBreakdown(path.resolve(wbsPath))
@@ -79,9 +109,13 @@ if (require.main === module) {
79
109
  const coverage = computeCoverage(wb, acMap)
80
110
  const techSpecEntries = parseFileInventoryEntries(fs.readFileSync(path.resolve(techSpecPath), 'utf8'))
81
111
  const techSpecCoverage = computeTechSpecCoverage(techSpecEntries, acMap)
82
- const outPath = path.join(path.dirname(path.resolve(requirementsPath)), `${prefix}-AC-Task-Matrix.md`)
83
- fs.writeFileSync(outPath, renderMatrix(prefix, acMap, coverage, techSpecCoverage))
84
- console.log(JSON.stringify({ outputPath: outPath }))
112
+ const outDir = path.join(path.resolve(featureDir), 'coverage-matrix')
113
+ fs.mkdirSync(outDir, { recursive: true })
114
+ const jsonPath = path.join(outDir, 'index.json')
115
+ const mdPath = path.join(outDir, 'index.md')
116
+ fs.writeFileSync(jsonPath, JSON.stringify(buildMatrixJson(prefix, acMap, coverage, techSpecCoverage), null, 2))
117
+ fs.writeFileSync(mdPath, renderMatrix(prefix, acMap, coverage, techSpecCoverage))
118
+ console.log(JSON.stringify({ jsonPath, mdPath }))
85
119
  }
86
120
 
87
- module.exports = { computeCoverage, renderMatrix, computeTechSpecCoverage }
121
+ module.exports = { computeCoverage, renderMatrix, computeTechSpecCoverage, buildMatrixJson }
@@ -36,6 +36,21 @@ return c.run(meta.name, featureDir, prefix, async () => {
36
36
  const valId = 'gaia.agent.planner.validate-feature-docs'
37
37
  const output = (suffix, inputs = [featurePath]) => ({ path: file(suffix), inputs })
38
38
 
39
+ // Templates live under the toolkit's own installed scripts/templates/ — outside this project
40
+ // for a globally-installed toolkit, which a non-interactive `--print` worker invocation has no
41
+ // way to get permission for (no TTY to answer a prompt; confirmed by a real run failing with
42
+ // "Worker permission denied: ...; tools: Read" against exactly this kind of path). The host
43
+ // reads the template itself (a plain Node process, never subject to the Claude Code
44
+ // tool-permission system) via the dedicated print-asset CLI command, and inlines its text
45
+ // directly into the prompt — the agent is never asked to Read it. Same pattern FTR-025 already
46
+ // established for inlining Requirements/Tech-Spec text into gaia-validate-feature-docs' prompt.
47
+ function readTemplate(templateFile) {
48
+ return c.command(['print-asset', `scripts/templates/${templateFile}`, '--project', c.root]).stdout
49
+ }
50
+ function withTemplate(prompt, label, templateFile) {
51
+ return `${prompt}\n\nTemplate (${label}):\n${readTemplate(templateFile)}`
52
+ }
53
+
39
54
  // Deliberately NOT file('req') — same reasoning as FTR-020's wbsDir: this is a directory
40
55
  // nested inside the feature's own already-prefixed folder, so a second prefix would be
41
56
  // redundant.
@@ -109,11 +124,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
109
124
  (relatedACs.length ? relatedACs.join(', ') : '—') + ' (see [index.md](../index.md#7-acceptance-criteria))\n'
110
125
  }
111
126
 
112
- function buildUcPrompt(row, ucTable, featurePathArg) {
127
+ function buildUcPrompt(row, ucTable, featurePathArg, ucTemplate) {
113
128
  const compactIndex = ucTable.map(r => `${r.id}: ${r.title} (${r.actor})`).join('\n')
114
- return `${featurePathArg}\n\nGenerate the full detail for this use case:\n` +
129
+ const base = `${featurePathArg}\n\nGenerate the full detail for this use case:\n` +
115
130
  `UC ID: ${row.id}\nTitle: ${row.title}\nActor: ${row.actor}\nPriority: ${row.priority}\n\n` +
116
131
  `Full use-case index (for cross-UC awareness, do not restate):\n${compactIndex}`
132
+ return `${base}\n\nTemplate (req/use-cases/{UC-ID}.md):\n${ucTemplate}`
117
133
  }
118
134
 
119
135
  // forceAll: bypasses resume-by-existence entirely, re-dispatching the skeleton and every
@@ -144,7 +160,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
144
160
  // 1. Skeleton (no declared worker() output — host writes it itself, mirroring FTR-020's
145
161
  // skeleton step)
146
162
  if (regenSkeleton) {
147
- const skeleton = await c.worker(reqId, featurePath)
163
+ const skeleton = await c.worker(reqId, withTemplate(featurePath, 'req/index.md', 'gaia-requirements-index-template.md'))
148
164
  c.writeOutput(reqIndexPath, skeleton)
149
165
  }
150
166
 
@@ -162,13 +178,15 @@ return c.run(meta.name, featureDir, prefix, async () => {
162
178
 
163
179
  // 3. Per-use-case generation, resume-by-existence, sequential
164
180
  const ucTable = parseUseCaseIndexTable(c.read(reqIndexPath))
181
+ // Read once, outside the loop — the template is identical for every use case in this run.
182
+ const ucTemplate = ucTable.length ? readTemplate('gaia-use-case-template.md') : null
165
183
  for (const row of ucTable) {
166
184
  const ucPath = `${reqDir}/${row.file}`
167
185
  // If the skeleton itself was just (re)generated, every use case must regenerate too —
168
186
  // an existing use-case file would have been written against the OLD skeleton's row
169
187
  // (title/actor/priority may have changed) and cannot be trusted as-is.
170
188
  if (!regenSkeleton && c.exists(ucPath)) continue // resume: skip, no dispatch
171
- const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath),
189
+ const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
172
190
  { label: `requirements-use-case:${row.id}` })
173
191
  c.writeOutput(ucPath, detail.trimEnd() + ucCrossReferences(c.read(reqIndexPath), row.id))
174
192
  }
@@ -223,11 +241,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
223
241
  return entries
224
242
  }
225
243
 
226
- function buildComponentPrompt(row, components, featurePathArg) {
244
+ function buildComponentPrompt(row, components, featurePathArg, componentTemplate) {
227
245
  const compactIndex = components.map(c => `${c.filePath} (${c.domain})`).join('\n')
228
- return `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
246
+ const base = `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
229
247
  `Path: ${row.filePath}\nChange: ${row.changeType}\nDomain: ${row.domain}\nAddresses: ${row.addresses}\n\n` +
230
248
  `Full component index (for cross-component awareness, do not restate):\n${compactIndex}`
249
+ return `${base}\n\nTemplate (spec/components/{fileSlug}.md):\n${componentTemplate}`
231
250
  }
232
251
 
233
252
  // Deterministic, host-computed — never asks the model to pick unique names (same principle
@@ -279,7 +298,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
279
298
 
280
299
  // 1. Skeleton (no declared worker() output — host writes it itself)
281
300
  if (regenSkeleton) {
282
- const skeleton = await c.worker(specId, featurePath)
301
+ const skeleton = await c.worker(specId, withTemplate(featurePath, 'spec/index.md', 'gaia-tech-spec-index-template.md'))
283
302
  c.writeOutput(specIndexPath, disambiguateFileSlugs(skeleton))
284
303
  }
285
304
 
@@ -298,10 +317,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
298
317
 
299
318
  // 3. Per-component generation, resume-by-existence, sequential
300
319
  const components = parseComponentsTable(c.read(specIndexPath))
320
+ // Read once, outside the loop — the template is identical for every component in this run.
321
+ const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
301
322
  for (const comp of components) {
302
323
  const compPath = `${specDir}/components/${comp.fileSlug}`
303
324
  if (!regenSkeleton && c.exists(compPath)) continue // resume: skip, no dispatch
304
- const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath),
325
+ const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
305
326
  { label: `tech-spec-component:${comp.fileSlug}` })
306
327
  c.writeOutput(compPath, detail)
307
328
  }
@@ -164,11 +164,82 @@ function computeDependsOnPhases(skeleton, phaseId, taskIdToPhase) {
164
164
  return [...deps]
165
165
  }
166
166
 
167
+ // FTR-026: local re-implementation of tech-spec-assembler.js's findSectionRange/parseTableRows
168
+ // (from req-assembler.js)/findSubsectionRange/parseComponentsTable — duplicated, not imported,
169
+ // same "sandboxed runtime has no `require`" reason as the template-engine primitives above.
170
+ // Keep in sync with tech-spec-assembler.js if spec/index.md's Section 9 table shape ever changes.
171
+ function _findSectionRange(content, sectionNumber) {
172
+ const lines = content.split('\n')
173
+ let start = -1, end = lines.length
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const m = lines[i].match(/^## (\d+)\.\s/)
176
+ if (!m) continue
177
+ const n = Number(m[1])
178
+ if (n === sectionNumber) start = i
179
+ else if (start !== -1 && n > sectionNumber) { end = i; break }
180
+ }
181
+ if (start === -1) throw new Error(`missing section ${sectionNumber}`)
182
+ return { start, end, lines }
183
+ }
184
+ function _parseTableRows(lines, start, end) {
185
+ const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
186
+ if (tableLines.length < 2) return []
187
+ return tableLines.slice(2).map(line =>
188
+ line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
189
+ }
190
+ function _findSubsectionRange(lines, start, end, heading) {
191
+ let subStart = -1
192
+ for (let i = start; i < end; i++) {
193
+ if (lines[i].trim() === `### ${heading}`) { subStart = i; break }
194
+ }
195
+ if (subStart === -1) return null
196
+ let subEnd = end
197
+ for (let i = subStart + 1; i < end; i++) {
198
+ if (/^#{2,3}\s/.test(lines[i])) { subEnd = i; break }
199
+ }
200
+ return { start: subStart, end: subEnd }
201
+ }
202
+ function parseComponentsTableLocal(specIndexContent) {
203
+ const { start, end, lines } = _findSectionRange(specIndexContent, 9)
204
+ const entries = []
205
+ for (const [heading, changeType] of [['New files', 'New'], ['Modified files', 'Modified']]) {
206
+ const sub = _findSubsectionRange(lines, start, end, heading)
207
+ if (!sub) continue
208
+ for (const cells of _parseTableRows(lines, sub.start, sub.end)) {
209
+ const [filePath, purposeOrChange, addresses, domain, specLink] = cells
210
+ const m = specLink && specLink.match(/\(components\/([^)]+)\)/)
211
+ if (!m) throw new Error(`malformed Spec column for "${filePath}" in spec/index.md`)
212
+ entries.push({ filePath, purposeOrChange, changeType, domain, addresses, fileSlug: m[1] })
213
+ }
214
+ }
215
+ return entries
216
+ }
217
+
218
+ // Matches a task's own acceptanceCriteria against each component's Addresses column (the same
219
+ // relation spec/index.md's own Section 9 table already expresses) to give the per-task worker
220
+ // only the Tech-Spec content relevant to its own task, instead of the entire assembled spec —
221
+ // the single largest structural token cost this feature removes. Falls back to the full
222
+ // Components table (never zero content) when nothing matches, e.g. a purely organizational task.
223
+ function buildTaskTechSpecExcerpt(components, task, specDir, specIndexContent) {
224
+ const acSet = new Set(task.acceptanceCriteria || [])
225
+ const relevant = components.filter(comp =>
226
+ (comp.addresses || '').split(',').map(s => s.trim()).some(acId => acSet.has(acId)))
227
+ if (relevant.length === 0) {
228
+ return "_No component's Addresses column cites this task's Acceptance Criteria — full " +
229
+ 'Components table below._\n\n' + specIndexContent
230
+ }
231
+ return relevant.map(comp => {
232
+ const body = c.read(`${specDir}/components/${comp.fileSlug}`)
233
+ return `### ${comp.filePath}\n\n${body}`
234
+ }).join('\n\n')
235
+ }
236
+
167
237
  // Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
168
238
  // the full skeleton's sibling task list (id + title + outcome only, for duplicate-
169
- // implementation awareness), and the Tech-Spec text (the per-task agent further narrows its
170
- // own Read/Grep use as needed — this call does not pre-slice the Tech-Spec by domain).
171
- function buildTaskPrompt(index, taskId, techSpecText) {
239
+ // implementation awareness), and the Tech-Spec excerpt (FTR-026: already pre-sliced to this
240
+ // task's own relevant component(s) by buildTaskTechSpecExcerpt, or the full legacy Tech-Spec
241
+ // text for a feature with no spec/ directory).
242
+ function buildTaskPrompt(index, taskId, techSpecText, taskTemplate) {
172
243
  const task = index.tasks[taskId]
173
244
  const siblingList = Object.entries(index.tasks)
174
245
  .map(([id, t]) => `- ${id}: ${t.title} — ${t.outcome}`).join('\n')
@@ -176,6 +247,7 @@ function buildTaskPrompt(index, taskId, techSpecText) {
176
247
  `Task skeleton entry (restate these fields verbatim; do not re-derive them):\n${JSON.stringify({ id: taskId, ...task }, null, 2)}`,
177
248
  `Tech-Spec:\n${techSpecText}`,
178
249
  `All tasks in this feature (for duplicate-implementation awareness):\n${siblingList}`,
250
+ `Template (tasks/{TASK_ID}.md):\n${taskTemplate}`,
179
251
  ].join('\n\n')
180
252
  }
181
253
 
@@ -184,32 +256,19 @@ const { featurePath, featureDir, prefix } = c.feature(args[0])
184
256
  return c.run(meta.name, featureDir, prefix, async () => {
185
257
  const file = suffix => `${featureDir}/${prefix}-${suffix}`
186
258
 
187
- // FTR-025: pm-phase1.js no longer writes the assembled Requirements/Tech-Spec files — this
188
- // phase's own unmodified internals (the Gate 1 digest re-check right below, wb-skeleton-
189
- // check.js, wb-validate.js, the semantic validator prompt, ac-task-matrix.js, and
190
- // gaia-generate-work-breakdown, which hard-aborts without these files) still need them on
191
- // disk. Transparently bridge the gap: assemble fresh from req/+spec/ every run (same two
192
- // subprocess calls pm-phase1.js used to make, relocated here), before anything below reads
193
- // them. A legacy feature (monolithic files only, no req/spec/ directories — generated before
194
- // this feature existed) is left untouched: nothing to assemble, no error.
195
- await c.activity('bridge-requirements-tech-spec', async () => {
196
- const reqDir = `${featureDir}/req`, specDir = `${featureDir}/spec`
197
- // c.exists() only checks isFile() — never true for a directory — so this checks each
198
- // split's own index.md (a real file) rather than the directory itself.
199
- const hasReq = c.exists(`${reqDir}/index.md`), hasSpec = c.exists(`${specDir}/index.md`)
200
- if (!hasReq && !hasSpec) return { bridged: false, reason: 'legacy (no split sources)' }
201
- if (hasReq) {
202
- const assembledRequirements = c.command(['run-asset', 'scripts/req-assembler.js',
203
- '--project', c.root, '--', reqDir]).stdout
204
- c.writeOutput(file('Requirements.md'), assembledRequirements)
205
- }
206
- if (hasSpec) {
207
- const assembledTechSpec = c.command(['run-asset', 'scripts/tech-spec-assembler.js',
208
- '--project', c.root, '--', specDir]).stdout
209
- c.writeOutput(file('Tech-Spec.md'), assembledTechSpec)
210
- }
211
- return { bridged: true }
212
- })
259
+ // FTR-026: pm-phase2.js is now split-source-native itself — every call site below reads
260
+ // req/index.md/spec/index.md directly, in-memory assembly feeds only the Gate 1 digest
261
+ // re-check, and nothing here writes an assembled monolithic file to disk (the
262
+ // bridge-requirements-tech-spec activity FTR-025 added as a transitional shim is gone; see
263
+ // git history for FTR-025's original version of this block). A legacy feature (monolithic
264
+ // files only, no req/spec/ directories) falls back to reading those files directly at every
265
+ // site below, unchanged from pre-FTR-026 behavior.
266
+ const reqDir = `${featureDir}/req`, specDir = `${featureDir}/spec`
267
+ // c.exists() only checks isFile() — never true for a directory — so this checks each split's
268
+ // own index.md (a real file) rather than the directory itself.
269
+ const hasReq = c.exists(`${reqDir}/index.md`), hasSpec = c.exists(`${specDir}/index.md`)
270
+ const reqIndexPath = hasReq ? `${reqDir}/index.md` : file('Requirements.md')
271
+ const specIndexPath = hasSpec ? `${specDir}/index.md` : file('Tech-Spec.md')
213
272
 
214
273
  const approvals = c.read(file('Approvals.md'))
215
274
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
@@ -225,7 +284,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
225
284
  // re-approved, not silently carried forward on stale consent.
226
285
  const digestMatch = gate[1].match(/\*\*Docs Digest:\*\*\s*`([^`]+)`/)
227
286
  if (digestMatch) {
228
- const currentDigest = c.hash(c.read(file('Requirements.md')) + '\u0000' + c.read(file('Tech-Spec.md')))
287
+ // FTR-026: recomputed in-memory from the split sources (same req-assembler.js/
288
+ // tech-spec-assembler.js subprocess calls pm-phase1.js already makes for its own
289
+ // docsDigest) instead of reading an assembled file from disk — never written here.
290
+ const assembledRequirements = hasReq
291
+ ? c.command(['run-asset', 'scripts/req-assembler.js', '--project', c.root, '--', reqDir]).stdout
292
+ : c.read(file('Requirements.md'))
293
+ const assembledTechSpec = hasSpec
294
+ ? c.command(['run-asset', 'scripts/tech-spec-assembler.js', '--project', c.root, '--', specDir]).stdout
295
+ : c.read(file('Tech-Spec.md'))
296
+ const currentDigest = c.hash(assembledRequirements + '\u0000' + assembledTechSpec)
229
297
  if (digestMatch[1] !== currentDigest) {
230
298
  throw new Error('Gate 1 approval is stale: Requirements/Tech-Spec content no longer matches what was approved (docs digest mismatch) — re-approve Gate 1 before proceeding')
231
299
  }
@@ -259,7 +327,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
259
327
  // wb-validate.js now imports — run via subprocess since this runtime has no `require`)
260
328
  const skeletonCheck = await c.activity('wb-skeleton-check', async () => {
261
329
  const res = c.command(['run-asset', 'scripts/wb-skeleton-check.js', '--project', c.root,
262
- '--', indexPath, file('Requirements.md')], [0, 1])
330
+ '--', indexPath, reqIndexPath], [0, 1])
263
331
  const parsed = JSON.parse(res.stdout)
264
332
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
265
333
  (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid skeleton-check result')
@@ -268,13 +336,27 @@ return c.run(meta.name, featureDir, prefix, async () => {
268
336
  })
269
337
 
270
338
  // 3. Per-task generation, resume-by-status, sequential
339
+ // FTR-026: each task's own worker call now receives only the Tech-Spec content relevant to its
340
+ // own Acceptance Criteria, not the entire assembled spec — computed once, before the loop, by
341
+ // matching each task's acceptanceCriteria against spec/index.md's own Addresses column.
342
+ const components = hasSpec ? parseComponentsTableLocal(c.read(specIndexPath)) : []
343
+ // The template lives under the toolkit's own installed scripts/templates/ — outside the
344
+ // project for a globally-installed toolkit, which a non-interactive worker invocation has no
345
+ // way to get Read permission for. Read once via print-asset, inline into every task's prompt,
346
+ // instead of telling the agent to Read it itself (same reasoning as pm-phase1.js's
347
+ // readTemplate/withTemplate — this runtime duplicates the primitive, not requires it, for the
348
+ // same "no `require`" reason as every other duplicated helper in this file).
349
+ const taskTemplate = c.command(['print-asset', 'scripts/templates/gaia-task-template.md', '--project', c.root]).stdout
271
350
  for (const phase of index.phases) {
272
351
  for (const taskId of phase.taskIds) {
273
352
  const task = index.tasks[taskId]
274
353
  const detailPath = `${wbsDir}/${task.detailFile}`
275
354
  if (task.status === 'generated' && c.exists(detailPath)) continue // resume: skip, no dispatch
355
+ const techSpecExcerpt = hasSpec
356
+ ? buildTaskTechSpecExcerpt(components, task, specDir, c.read(specIndexPath))
357
+ : c.read(file('Tech-Spec.md')) // legacy fallback: unchanged, full text
276
358
  const detail = await c.worker('gaia.agent.planner.work-breakdown-task',
277
- buildTaskPrompt(index, taskId, c.read(file('Tech-Spec.md'))), { label: `work-breakdown-task:${taskId}` })
359
+ buildTaskPrompt(index, taskId, techSpecExcerpt, taskTemplate), { label: `work-breakdown-task:${taskId}` })
278
360
  c.writeOutput(detailPath, detail)
279
361
  task.status = 'generated'
280
362
  c.writeOutput(indexPath, JSON.stringify(index, null, 2)) // persist status after each task
@@ -285,7 +367,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
285
367
  // existing pipeline, unchanged past this point except the path passed in
286
368
  const report = await c.activity('wb-validate', async () => {
287
369
  const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
288
- '--', wbsDir, file('Requirements.md')], [0, 1])
370
+ '--', wbsDir, reqIndexPath], [0, 1])
289
371
  const parsed = JSON.parse(res.stdout)
290
372
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
291
373
  !Array.isArray(parsed.warnings) || (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid validator result')
@@ -293,7 +375,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
293
375
  return parsed
294
376
  })
295
377
  const semantic = await c.worker('gaia.agent.planner.validate-work-breakdown',
296
- `${wbsDir}\n${file('Requirements.md')}`, { schema: {
378
+ `${wbsDir}\n${reqIndexPath}`, { schema: {
297
379
  type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array' } }, required: ['valid', 'findings'],
298
380
  } })
299
381
  if (!semantic.valid || semantic.findings.some(f => f.blocking)) throw new Error('Work breakdown semantic validation failed')
@@ -307,12 +389,17 @@ return c.run(meta.name, featureDir, prefix, async () => {
307
389
  const acTaskMatrix = await c.activity('ac-task-matrix', async () => {
308
390
  // FTR-023: ac-task-matrix.js's CLI gained a 3rd positional argument (Tech-Spec path) to
309
391
  // render the "Addressed by Tech-Spec File(s)" column.
392
+ // FTR-026: gained a required 5th (featureDir) argument; output moved from one loose
393
+ // {PREFIX}-AC-Task-Matrix.md file into a coverage-matrix/ subfolder as a JSON+MD pair.
310
394
  const result = c.command(['run-asset', 'scripts/ac-task-matrix.js', '--project', c.root,
311
- '--', wbsDir, file('Requirements.md'), file('Tech-Spec.md'), prefix])
312
- const matrixPath = file('AC-Task-Matrix.md')
313
- if (!c.exists(matrixPath)) throw new Error('AC-Task matrix output missing')
314
- c.read(matrixPath) // never trust an unread write
315
- return { ...result, matrixPath }
395
+ '--', wbsDir, reqIndexPath, specIndexPath, prefix, featureDir])
396
+ const matrixJsonPath = `${featureDir}/coverage-matrix/index.json`
397
+ const matrixMdPath = `${featureDir}/coverage-matrix/index.md`
398
+ if (!c.exists(matrixJsonPath)) throw new Error('AC-Task matrix JSON output missing')
399
+ if (!c.exists(matrixMdPath)) throw new Error('AC-Task matrix MD output missing')
400
+ c.read(matrixJsonPath) // never trust an unread write
401
+ c.read(matrixMdPath)
402
+ return { ...result, matrixJsonPath, matrixMdPath }
316
403
  })
317
404
  // Assembled structure for c.estimates()'s metrics — via wbs-assembler.js's own CLI mode
318
405
  // (subprocess), since this runtime cannot `require` it in-process either.
@@ -326,7 +413,8 @@ return c.run(meta.name, featureDir, prefix, async () => {
326
413
  js_validator_report: report, js_validator_failed: false,
327
414
  semantic_validator_result: semantic, semantic_validator_failed: false,
328
415
  gate2_blocked: false,
329
- ac_task_matrix_path: acTaskMatrix.matrixPath,
416
+ ac_task_matrix_json_path: acTaskMatrix.matrixJsonPath,
417
+ ac_task_matrix_md_path: acTaskMatrix.matrixMdPath,
330
418
  duration_bands: report.durationBands, domain_distribution: report.domainDistribution,
331
419
  warning_band_tasks: report.warnings.filter(w => w.category === 'duration_warning').map(w => ({ taskId: w.taskId, agentMinutes: w.details?.agentMinutes })),
332
420
  split_required_tasks: [], must_ac_uncovered: [], phase_unschedulable: [],