@dtlabs/ai-toolkit 0.8.0 → 0.10.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 (41) hide show
  1. package/.claude/agents/generate-work-breakdown.md +212 -192
  2. package/.claude/agents/install-toolkit.md +6 -1
  3. package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
  4. package/.claude/scripts/.gitkeep +0 -0
  5. package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
  6. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
  7. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
  8. package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
  9. package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
  10. package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
  11. package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
  12. package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
  13. package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
  14. package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
  15. package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
  16. package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
  17. package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
  18. package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
  19. package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
  20. package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
  21. package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
  22. package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
  23. package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
  24. package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
  25. package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
  26. package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
  27. package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
  28. package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
  29. package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
  30. package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
  31. package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
  32. package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
  33. package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
  34. package/.claude/scripts/wb-render.js +309 -0
  35. package/.claude/scripts/wb-validate.js +1007 -0
  36. package/.claude/workflows/pm-phase1.js +22 -13
  37. package/.claude/workflows/pm-phase2.js +304 -32
  38. package/.claude/workflows/pm-phase3.js +57 -21
  39. package/CHANGELOG.md +82 -0
  40. package/bin/cli.js +1 -0
  41. package/package.json +1 -1
@@ -13,18 +13,30 @@ export const meta = {
13
13
 
14
14
  async function appendLedgerEntry(featureDir, prefix, entry) {
15
15
  const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
16
- const entryJson = JSON.stringify(entry)
16
+ // started_at is generated via Bash inside the agent to get a real UTC timestamp with time
17
+ const entryWithoutTs = JSON.stringify({ ...entry, started_at: '__TS__', completed_at: null })
17
18
  await agent(
18
- `Append a JSON object to the ledger array at: ${ledgerPath}\n\n1. Read the file. If it does not exist or cannot be parsed as a JSON array, start with [].\n2. Push this object onto the array: ${entryJson}\n3. Write the full array back (JSON, 2-space indent). Return no output.`,
19
+ `Append a JSON object to the ledger array at: ${ledgerPath}\n\n` +
20
+ `1. Run: date -u +"%Y-%m-%dT%H:%M:%SZ" and capture the output as NOW.\n` +
21
+ `2. Read the file. If it does not exist or cannot be parsed as a JSON array, start with [].\n` +
22
+ `3. Push this object onto the array, replacing "__TS__" in started_at with NOW: ${entryWithoutTs}\n` +
23
+ `4. Write the full array back (JSON, 2-space indent). Return no output.`,
19
24
  { label: 'append-ledger', phase: 'Requirements', model: 'haiku' }
20
25
  )
21
26
  }
22
27
 
23
28
  async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
24
29
  const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
25
- const updatesJson = JSON.stringify(updates)
30
+ // completed_at is generated via Bash inside the agent to get a real UTC timestamp with time
31
+ const updatesWithoutTs = JSON.stringify({ ...updates, completed_at: '__TS__' })
26
32
  await agent(
27
- `Update an entry in the ledger array at: ${ledgerPath}\n\n1. Read the file. If it does not exist or cannot be parsed as a JSON array, do nothing.\n2. Search from the end for the last entry where agent === "${agentKey}".\n3. If found, merge these fields into that entry: ${updatesJson}\n4. Write the full array back (JSON, 2-space indent). Return no output.\n5. If not found, do nothing.`,
33
+ `Update an entry in the ledger array at: ${ledgerPath}\n\n` +
34
+ `1. Run: date -u +"%Y-%m-%dT%H:%M:%SZ" and capture the output as NOW.\n` +
35
+ `2. Read the file. If it does not exist or cannot be parsed as a JSON array, do nothing.\n` +
36
+ `3. Search from the end for the last entry where agent === "${agentKey}".\n` +
37
+ `4. If found, merge these fields into that entry, replacing "__TS__" in completed_at with NOW: ${updatesWithoutTs}\n` +
38
+ `5. Write the full array back (JSON, 2-space indent). Return no output.\n` +
39
+ `6. If not found, do nothing.`,
28
40
  { label: 'update-ledger', phase: 'Requirements', model: 'haiku' }
29
41
  )
30
42
  }
@@ -108,14 +120,13 @@ const errors = []
108
120
 
109
121
  if (discoveryResult.needs_requirements) {
110
122
  const reqKey = 'generate-requirements:phase1'
111
- const reqStartedAt = new Date().toISOString()
112
123
  await appendLedgerEntry(feature_dir, prefix, {
113
124
  agent: reqKey,
114
125
  phase: 'phase1',
115
126
  model: 'haiku',
116
127
  status: 'running',
117
128
  phase_delta_tokens: 0,
118
- started_at: reqStartedAt,
129
+ started_at: '__TS__',
119
130
  completed_at: null,
120
131
  })
121
132
  const beforeReq = budget.spent()
@@ -123,7 +134,7 @@ if (discoveryResult.needs_requirements) {
123
134
  const reqTokens = budget.spent() - beforeReq
124
135
  await updateLedgerEntry(feature_dir, prefix, reqKey, {
125
136
  status: 'done',
126
- completed_at: new Date().toISOString(),
137
+ completed_at: '__TS__',
127
138
  phase_delta_tokens: reqTokens,
128
139
  })
129
140
  tokenLedger.push({ agent: 'generate-requirements', model: 'haiku', phase_delta_tokens: reqTokens })
@@ -137,14 +148,13 @@ phase('Tech-Spec')
137
148
 
138
149
  if (discoveryResult.needs_tech_spec) {
139
150
  const specKey = 'generate-tech-spec:phase1'
140
- const specStartedAt = new Date().toISOString()
141
151
  await appendLedgerEntry(feature_dir, prefix, {
142
152
  agent: specKey,
143
153
  phase: 'phase1',
144
154
  model: 'haiku',
145
155
  status: 'running',
146
156
  phase_delta_tokens: 0,
147
- started_at: specStartedAt,
157
+ started_at: '__TS__',
148
158
  completed_at: null,
149
159
  })
150
160
  const beforeSpec = budget.spent()
@@ -152,7 +162,7 @@ if (discoveryResult.needs_tech_spec) {
152
162
  const specTokens = budget.spent() - beforeSpec
153
163
  await updateLedgerEntry(feature_dir, prefix, specKey, {
154
164
  status: 'done',
155
- completed_at: new Date().toISOString(),
165
+ completed_at: '__TS__',
156
166
  phase_delta_tokens: specTokens,
157
167
  })
158
168
  tokenLedger.push({ agent: 'generate-tech-spec', model: 'haiku', phase_delta_tokens: specTokens })
@@ -171,14 +181,13 @@ const MAX_CYCLES = 3
171
181
  for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
172
182
  log(`validate-feature-docs cycle ${cycle}`)
173
183
  const valKey = `validate-feature-docs:phase1:cycle${cycle}`
174
- const valStartedAt = new Date().toISOString()
175
184
  await appendLedgerEntry(feature_dir, prefix, {
176
185
  agent: valKey,
177
186
  phase: 'phase1',
178
187
  model: 'haiku',
179
188
  status: 'running',
180
189
  phase_delta_tokens: 0,
181
- started_at: valStartedAt,
190
+ started_at: '__TS__',
182
191
  completed_at: null,
183
192
  })
184
193
  const beforeVal = budget.spent()
@@ -186,7 +195,7 @@ for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
186
195
  const valTokens = budget.spent() - beforeVal
187
196
  await updateLedgerEntry(feature_dir, prefix, valKey, {
188
197
  status: 'done',
189
- completed_at: new Date().toISOString(),
198
+ completed_at: '__TS__',
190
199
  phase_delta_tokens: valTokens,
191
200
  })
192
201
  tokenLedger.push({ agent: `validate-feature-docs (cycle ${cycle})`, model: 'haiku', phase_delta_tokens: valTokens })
@@ -13,22 +13,78 @@ export const meta = {
13
13
 
14
14
  async function appendLedgerEntry(featureDir, prefix, entry) {
15
15
  const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
16
- const entryJson = JSON.stringify(entry)
16
+ // Defaults come first so the caller can override started_at/completed_at.
17
+ // Skipped entries pass completed_at: '__TS__' to get a real timestamp; running entries
18
+ // pass completed_at: null to keep it null. Both are replaced/kept by the agent below.
19
+ const entryWithoutTs = JSON.stringify({ started_at: '__TS__', completed_at: null, ...entry })
17
20
  await agent(
18
- `Append a JSON object to the ledger array at: ${ledgerPath}\n\n1. Read the file. If it does not exist or cannot be parsed as a JSON array, start with [].\n2. Push this object onto the array: ${entryJson}\n3. Write the full array back (JSON, 2-space indent). Return no output.`,
21
+ `Append a JSON object to the ledger array at: ${ledgerPath}\n\n` +
22
+ `1. Run: date -u +"%Y-%m-%dT%H:%M:%SZ" and capture the output as NOW.\n` +
23
+ `2. Read the file. If it does not exist or cannot be parsed as a JSON array, start with [].\n` +
24
+ `3. Push this object onto the array, replacing every "__TS__" string value in the object with NOW: ${entryWithoutTs}\n` +
25
+ `4. Write the full array back (JSON, 2-space indent). Return no output.`,
19
26
  { label: 'append-ledger', phase: 'Work Breakdown', model: 'haiku' }
20
27
  )
21
28
  }
22
29
 
23
30
  async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
24
31
  const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
25
- const updatesJson = JSON.stringify(updates)
32
+ // completed_at is generated via Bash inside the agent to get a real UTC timestamp with time
33
+ const updatesWithoutTs = JSON.stringify({ ...updates, completed_at: '__TS__' })
26
34
  await agent(
27
- `Update an entry in the ledger array at: ${ledgerPath}\n\n1. Read the file. If it does not exist or cannot be parsed as a JSON array, do nothing.\n2. Search from the end for the last entry where agent === "${agentKey}".\n3. If found, merge these fields into that entry: ${updatesJson}\n4. Write the full array back (JSON, 2-space indent). Return no output.\n5. If not found, do nothing.`,
35
+ `Update an entry in the ledger array at: ${ledgerPath}\n\n` +
36
+ `1. Run: date -u +"%Y-%m-%dT%H:%M:%SZ" and capture the output as NOW.\n` +
37
+ `2. Read the file. If it does not exist or cannot be parsed as a JSON array, do nothing.\n` +
38
+ `3. Search from the end for the last entry where agent === "${agentKey}".\n` +
39
+ `4. If found, merge these fields into that entry, replacing "__TS__" in completed_at with NOW: ${updatesWithoutTs}\n` +
40
+ `5. Write the full array back (JSON, 2-space indent). Return no output.\n` +
41
+ `6. If not found, do nothing.`,
28
42
  { label: 'update-ledger', phase: 'Work Breakdown', model: 'haiku' }
29
43
  )
30
44
  }
31
45
 
46
+ // Output schemas for wb-validate and wb-render agent invocations (used by US-05)
47
+ const WB_WRAPPER_SCHEMA = {
48
+ type: 'object',
49
+ properties: {
50
+ exitCode: { type: 'number' },
51
+ stdout: { type: 'string' },
52
+ stderr: { type: 'string' },
53
+ },
54
+ required: ['exitCode', 'stdout', 'stderr'],
55
+ }
56
+
57
+ const WB_RENDER_SCHEMA = {
58
+ type: 'object',
59
+ properties: {
60
+ exitCode: { type: 'number' },
61
+ stdout: { type: 'string' },
62
+ stderr: { type: 'string' },
63
+ markdownPath: { type: 'string' },
64
+ csvPath: { type: 'string' },
65
+ markdownExists: { type: 'boolean' },
66
+ csvExists: { type: 'boolean' },
67
+ },
68
+ required: ['exitCode', 'stdout', 'stderr', 'markdownPath', 'csvPath', 'markdownExists', 'csvExists'],
69
+ }
70
+
71
+ const WB_SEMANTIC_SCHEMA = {
72
+ type: 'object',
73
+ properties: {
74
+ valid: { type: 'boolean' },
75
+ findings: { type: 'array' },
76
+ },
77
+ required: ['valid', 'findings'],
78
+ }
79
+
80
+ function normalizeError(err) {
81
+ if (!err) return '<unknown error>'
82
+ if (typeof err === 'string') return err
83
+ if (err.message) return err.message
84
+ if (err.stderr) return err.stderr
85
+ try { return JSON.stringify(err) } catch (_) { return '<unknown error>' }
86
+ }
87
+
32
88
  // ── Parse args ────────────────────────────────────────────────────────────────
33
89
 
34
90
  // args: "<path-to-feature.md>"
@@ -45,14 +101,13 @@ phase('Work Breakdown')
45
101
  const tokenLedger = []
46
102
 
47
103
  log(`Running generate-work-breakdown for ${featurePath}`)
48
- const wbStartedAt = new Date().toISOString()
49
104
  await appendLedgerEntry(featureDir, prefix, {
50
105
  agent: 'generate-work-breakdown:phase2',
51
106
  phase: 'phase2',
52
107
  model: 'haiku',
53
108
  status: 'running',
54
109
  phase_delta_tokens: 0,
55
- started_at: wbStartedAt,
110
+ started_at: '__TS__',
56
111
  completed_at: null,
57
112
  })
58
113
  const beforeWB = budget.spent()
@@ -64,12 +119,231 @@ await agent(featurePath, {
64
119
  const wbTokens = budget.spent() - beforeWB
65
120
  await updateLedgerEntry(featureDir, prefix, 'generate-work-breakdown:phase2', {
66
121
  status: 'done',
67
- completed_at: new Date().toISOString(),
122
+ completed_at: '__TS__',
68
123
  phase_delta_tokens: wbTokens,
69
124
  })
70
125
  tokenLedger.push({ agent: 'generate-work-breakdown', model: 'haiku', phase_delta_tokens: wbTokens })
71
126
  log(`generate-work-breakdown done — phase delta: ${wbTokens} tokens`)
72
127
 
128
+ // ── Step 2: wb-validate.js (deterministic structural validator) ───────────────
129
+ let wbValidatorReport = null
130
+ let validateFailed = false
131
+ let validateExitCode = null
132
+ await appendLedgerEntry(featureDir, prefix, {
133
+ agent: 'wb-validate:phase2',
134
+ phase: 'phase2', model: 'haiku', status: 'running',
135
+ phase_delta_tokens: 0, started_at: '__TS__', completed_at: null,
136
+ })
137
+ const beforeValidate = budget.spent()
138
+ try {
139
+ const validateWrapper = await agent(
140
+ `Run this command and return the result as structured output:\n` +
141
+ `node .claude/scripts/wb-validate.js "${featureDir}/${prefix}-Work-Breakdown.json" "${featureDir}/${prefix}-Requirements.md"\n\n` +
142
+ `Capture exitCode (integer), stdout (string), stderr (string). Return all three.`,
143
+ {
144
+ label: 'wb-validate',
145
+ phase: 'Work Breakdown',
146
+ model: 'haiku',
147
+ schema: WB_WRAPPER_SCHEMA,
148
+ }
149
+ )
150
+ validateExitCode = validateWrapper.exitCode
151
+ if (validateWrapper.stderr?.trim()) {
152
+ log(`wb-validate stderr: ${validateWrapper.stderr.trim()}`)
153
+ }
154
+ if (validateWrapper.exitCode === 2) {
155
+ throw Object.assign(
156
+ new Error(validateWrapper.stderr?.trim() || 'wb-validate runtime error (exit 2)'),
157
+ { _exitCode: validateWrapper.exitCode }
158
+ )
159
+ }
160
+ if (![0, 1].includes(validateWrapper.exitCode)) {
161
+ throw Object.assign(
162
+ new Error(`wb-validate unexpected exit code: ${validateWrapper.exitCode}`),
163
+ { _exitCode: validateWrapper.exitCode }
164
+ )
165
+ }
166
+ if (!validateWrapper.stdout?.trim()) {
167
+ throw new Error('wb-validate returned empty stdout')
168
+ }
169
+ wbValidatorReport = JSON.parse(validateWrapper.stdout)
170
+ if (validateWrapper.exitCode === 0 && !wbValidatorReport.valid) {
171
+ throw new Error('Inconsistent wb-validate result: exit 0 with valid=false')
172
+ }
173
+ if (validateWrapper.exitCode === 1 && wbValidatorReport.valid) {
174
+ throw new Error('Inconsistent wb-validate result: exit 1 with valid=true')
175
+ }
176
+ await updateLedgerEntry(featureDir, prefix, 'wb-validate:phase2', {
177
+ status: 'done', completed_at: '__TS__',
178
+ phase_delta_tokens: budget.spent() - beforeValidate,
179
+ })
180
+ } catch (err) {
181
+ validateFailed = true
182
+ await updateLedgerEntry(featureDir, prefix, 'wb-validate:phase2', {
183
+ status: 'failed', completed_at: '__TS__',
184
+ phase_delta_tokens: budget.spent() - beforeValidate,
185
+ error_summary: normalizeError(err),
186
+ exit_code: err._exitCode ?? validateExitCode ?? null,
187
+ })
188
+ }
189
+ const validateTokens = budget.spent() - beforeValidate
190
+ const wbValidatorPassed = !validateFailed && wbValidatorReport !== null
191
+ && wbValidatorReport.valid === true && wbValidatorReport.errors.length === 0
192
+ log(`wb-validate: ${wbValidatorPassed ? 'passed' : 'failed/errors'} — ${validateTokens} tokens`)
193
+
194
+ // ── Step 3: Semantic validator (only if wb-validate passed) ───────────────────
195
+ let semanticResult = null
196
+ let semanticFailed = false
197
+ let semanticTokens = 0
198
+ if (!wbValidatorPassed) {
199
+ await appendLedgerEntry(featureDir, prefix, {
200
+ agent: 'validate-work-breakdown-semantic:phase2',
201
+ phase: 'phase2', model: 'sonnet', status: 'skipped',
202
+ phase_delta_tokens: 0, started_at: '__TS__', completed_at: '__TS__',
203
+ })
204
+ } else {
205
+ await appendLedgerEntry(featureDir, prefix, {
206
+ agent: 'validate-work-breakdown-semantic:phase2',
207
+ phase: 'phase2', model: 'sonnet', status: 'running',
208
+ phase_delta_tokens: 0, started_at: '__TS__', completed_at: null,
209
+ })
210
+ const beforeSemantic = budget.spent()
211
+ try {
212
+ semanticResult = await agent(
213
+ `${featureDir}/${prefix}-Work-Breakdown.json\n${featureDir}/${prefix}-Requirements.md`,
214
+ {
215
+ label: 'validate-work-breakdown-semantic',
216
+ phase: 'Work Breakdown',
217
+ agentType: 'validate-work-breakdown-semantic',
218
+ schema: WB_SEMANTIC_SCHEMA,
219
+ }
220
+ )
221
+ semanticTokens = budget.spent() - beforeSemantic
222
+ await updateLedgerEntry(featureDir, prefix, 'validate-work-breakdown-semantic:phase2', {
223
+ status: 'done', completed_at: '__TS__', phase_delta_tokens: semanticTokens,
224
+ })
225
+ } catch (err) {
226
+ semanticFailed = true
227
+ semanticTokens = budget.spent() - beforeSemantic
228
+ await updateLedgerEntry(featureDir, prefix, 'validate-work-breakdown-semantic:phase2', {
229
+ status: 'failed', completed_at: '__TS__', phase_delta_tokens: semanticTokens,
230
+ error_summary: normalizeError(err), exit_code: null,
231
+ })
232
+ }
233
+ }
234
+ log(`semantic validator: ${semanticFailed ? 'failed' : semanticResult ? 'done' : 'skipped'}`)
235
+
236
+ // ── Step 4: wb-render.js (only if wb-validate passed and semantic did not fail) ─
237
+ const canRender = wbValidatorPassed && !semanticFailed
238
+ let renderFailed = false
239
+ let renderResult = null
240
+ if (!canRender) {
241
+ await appendLedgerEntry(featureDir, prefix, {
242
+ agent: 'wb-render:phase2',
243
+ phase: 'phase2', model: 'haiku', status: 'skipped',
244
+ phase_delta_tokens: 0, started_at: '__TS__', completed_at: '__TS__',
245
+ })
246
+ } else {
247
+ await appendLedgerEntry(featureDir, prefix, {
248
+ agent: 'wb-render:phase2',
249
+ phase: 'phase2', model: 'haiku', status: 'running',
250
+ phase_delta_tokens: 0, started_at: '__TS__', completed_at: null,
251
+ })
252
+ const beforeRender = budget.spent()
253
+ try {
254
+ renderResult = await agent(
255
+ `Run this command and return the result as structured output:\n` +
256
+ `node .claude/scripts/wb-render.js "${featureDir}/${prefix}-Work-Breakdown.json" "${prefix}"\n\n` +
257
+ `Capture exitCode (integer), stdout (string), stderr (string).\n` +
258
+ `Resolve the expected output paths:\n` +
259
+ ` markdownPath = "${featureDir}/${prefix}-Work-Breakdown.md"\n` +
260
+ ` csvPath = "${featureDir}/${prefix}-Work-Breakdown.csv"\n` +
261
+ `Check whether each file exists on disk and return:\n` +
262
+ ` markdownExists (boolean), csvExists (boolean).\n` +
263
+ `Return all seven fields.`,
264
+ {
265
+ label: 'wb-render',
266
+ phase: 'Work Breakdown',
267
+ model: 'haiku',
268
+ schema: WB_RENDER_SCHEMA,
269
+ }
270
+ )
271
+ if (renderResult.stderr?.trim()) {
272
+ log(`wb-render stderr: ${renderResult.stderr.trim()}`)
273
+ }
274
+ if (renderResult.exitCode !== 0) {
275
+ throw Object.assign(
276
+ new Error(`wb-render exited ${renderResult.exitCode}: ${renderResult.stderr?.trim() || renderResult.stdout?.trim()}`),
277
+ { _exitCode: renderResult.exitCode }
278
+ )
279
+ }
280
+ if (!renderResult.markdownPath?.trim()) throw new Error('wb-render did not return markdownPath')
281
+ if (!renderResult.csvPath?.trim()) throw new Error('wb-render did not return csvPath')
282
+ if (!renderResult.markdownExists) {
283
+ throw Object.assign(
284
+ new Error(`wb-render output file not found: ${renderResult.markdownPath}`),
285
+ { _exitCode: renderResult.exitCode }
286
+ )
287
+ }
288
+ if (!renderResult.csvExists) {
289
+ throw Object.assign(
290
+ new Error(`wb-render output file not found: ${renderResult.csvPath}`),
291
+ { _exitCode: renderResult.exitCode }
292
+ )
293
+ }
294
+ await updateLedgerEntry(featureDir, prefix, 'wb-render:phase2', {
295
+ status: 'done', completed_at: '__TS__',
296
+ phase_delta_tokens: budget.spent() - beforeRender,
297
+ })
298
+ } catch (err) {
299
+ renderFailed = true
300
+ await updateLedgerEntry(featureDir, prefix, 'wb-render:phase2', {
301
+ status: 'failed', completed_at: '__TS__',
302
+ phase_delta_tokens: budget.spent() - beforeRender,
303
+ error_summary: normalizeError(err),
304
+ exit_code: err._exitCode ?? renderResult?.exitCode ?? null,
305
+ })
306
+ }
307
+ tokenLedger.push({ agent: 'wb-render', model: 'haiku', phase_delta_tokens: budget.spent() - beforeRender })
308
+ }
309
+
310
+ tokenLedger.push({ agent: 'wb-validate', model: 'haiku', phase_delta_tokens: validateTokens })
311
+ tokenLedger.push({ agent: 'validate-work-breakdown-semantic', model: 'sonnet', phase_delta_tokens: semanticTokens })
312
+
313
+ // ── Step 5: Assemble gate2_payload ────────────────────────────────────────────
314
+ const gate2_payload = {
315
+ js_validator_report: wbValidatorReport,
316
+ js_validator_failed: validateFailed,
317
+ semantic_validator_result: semanticResult,
318
+ semantic_validator_failed: semanticFailed,
319
+ renderer_result: renderResult,
320
+ renderer_failed: renderFailed,
321
+ duration_bands: wbValidatorReport ? wbValidatorReport.durationBands : null,
322
+ domain_distribution: wbValidatorReport ? wbValidatorReport.domainDistribution : null,
323
+ warning_band_tasks: wbValidatorReport
324
+ ? wbValidatorReport.warnings.filter(w => w.category === 'duration_warning')
325
+ .map(w => ({ taskId: w.taskId, agentMinutes: w.details ? w.details.agentMinutes : null }))
326
+ : [],
327
+ split_required_tasks: wbValidatorReport
328
+ ? wbValidatorReport.errors.filter(e => e.category === 'split_required')
329
+ .map(e => ({ taskId: e.taskId, agentMinutes: e.details ? e.details.agentMinutes : null }))
330
+ : [],
331
+ must_ac_uncovered: wbValidatorReport
332
+ ? wbValidatorReport.errors.filter(e => e.category === 'must_ac_uncovered')
333
+ .map(e => ({ acId: e.details ? e.details.acId : null }))
334
+ : [],
335
+ phase_unschedulable: wbValidatorReport
336
+ ? (wbValidatorReport.dependencies ? wbValidatorReport.dependencies.phaseUnschedulable || [] : [])
337
+ : [],
338
+ gate2_blocked:
339
+ validateFailed ||
340
+ !wbValidatorPassed ||
341
+ semanticFailed ||
342
+ renderFailed ||
343
+ Boolean(semanticResult && semanticResult.findings && semanticResult.findings.some(f => f.blocking)),
344
+ }
345
+ log(`gate2_payload assembled — gate2_blocked: ${gate2_payload.gate2_blocked}`)
346
+
73
347
  // ── Parse WB + write Effort-Estimate + Token-Estimate ────────────────────────
74
348
  phase('Effort Estimate')
75
349
 
@@ -124,30 +398,27 @@ function formatEur(val) {
124
398
  const metrics = await agent(
125
399
  `You have four tasks:
126
400
 
127
- TASK 1 — Read the Work Breakdown file.
401
+ TASK 1 — Read the Work Breakdown JSON file.
128
402
  The feature.md is at: ${featurePath}
129
- The directory containing feature.md also contains the Work Breakdown file named {PREFIX}-Work-Breakdown.md,
130
- where PREFIX matches the pattern [A-Z]+-[0-9]+ from the directory name (e.g. FTR-009).
131
- Use Glob or Read to find and read that file.
132
-
133
- Extract from the Summary table:
134
- - prefix (e.g. "FTR-009")
135
- - feature_dir (directory containing feature.md)
136
- - feature_title (full feature title from the Work Breakdown header)
137
- - user_stories (integer)
138
- - total_tasks (integer)
139
- - domain_breakdown (e.g. "DB:0, BE:11, FE:0, INFRA:4, TEST:0")
140
- - implementation_phases (integer)
141
- - human_estimate (e.g. "~34h")
142
- - agent_estimate (e.g. "~2h 6min")
143
- - work_breakdown_path (full path to the Work Breakdown file)
144
-
145
- Also read Section 4 of the Work Breakdown to extract per-User-Story details:
146
- - US ID (e.g. US-01)
147
- - US title
148
- - task count
149
- - domains involved
150
- - estimated hours (from the Work Breakdown if present, otherwise estimate based on task count: ~2h per BE task, ~1h per TEST task, ~30min per INFRA task)
403
+ The JSON work breakdown is at: ${featureDir}/${prefix}-Work-Breakdown.json
404
+ Read the JSON file and extract:
405
+ - prefix (e.g. "FTR-009") — use "${prefix}"
406
+ - feature_dir (directory containing feature.md) — use "${featureDir}"
407
+ - feature_title — from wb.title or wb.feature field
408
+ - total_tasks (integer) — count all tasks across all phases
409
+ - user_stories (integer) — count phases whose id starts with "US-"
410
+ - domain_breakdown (string) — e.g. "BE: N, FE: N, DB: N, INFRA: N, TEST: N"
411
+ - implementation_phases (integer) — total number of phases
412
+ - human_estimate — compute as: (BE_tasks * 120 + TEST_tasks * 60 + INFRA_tasks * 30 + FE_tasks * 90 + DB_tasks * 90) minutes, format as "~Xh Ymin"
413
+ - agent_estimate — human_estimate / 4 (parallel dispatch), format as "~Xmin" or "~Xh Ymin"
414
+ - work_breakdown_path — ${featureDir}/${prefix}-Work-Breakdown.json
415
+
416
+ Also extract per-User-Story details for the Effort-Estimate:
417
+ - US ID (phase.id where it starts with "US-")
418
+ - US title (phase.title)
419
+ - task count (phase.tasks.length)
420
+ - domains involved (unique task.domain values in the phase)
421
+ - estimated hours (per the formula above)
151
422
 
152
423
  TASK 2 — Write the Effort-Estimate.md file.
153
424
  Write {feature_dir}/{PREFIX}-Effort-Estimate.md with this format:
@@ -234,7 +505,7 @@ For the "Est. cost €" column in Phase 3: use the formula tokens * (0.8 * 3.00
234
505
  TASK 4 — Set return values.
235
506
  Set effort_estimate_path to the full path of the written Effort-Estimate.md.
236
507
  Set token_estimate_path to the full path of the written Token-Estimate.md.
237
- Set tasks_csv_path to the full path of the {PREFIX}-Work-Breakdown.csv file (written by generate-work-breakdown in the same directory as feature.md).
508
+ Set tasks_csv_path to "${featureDir}/${prefix}-Work-Breakdown.csv" (written by wb-render in the same directory as feature.md).
238
509
 
239
510
  Return the extracted metrics as structured output.`,
240
511
  {
@@ -276,7 +547,7 @@ ${phase2Events}`,
276
547
  )
277
548
 
278
549
  return {
279
- prefix: metrics.prefix,
550
+ prefix: metrics.prefix || prefix,
280
551
  feature_path: featurePath,
281
552
  user_stories: metrics.user_stories,
282
553
  total_tasks: metrics.total_tasks,
@@ -290,4 +561,5 @@ return {
290
561
  tasks_csv_path: metrics.tasks_csv_path || '',
291
562
  token_ledger: tokenLedger,
292
563
  errors: [],
564
+ gate2_payload,
293
565
  }