@dtlabs/ai-toolkit 0.13.0 → 0.14.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.
@@ -1,575 +1,54 @@
1
1
  export const meta = {
2
+ controlPlaneVersion: 1,
2
3
  name: 'pm-phase2',
3
- description: 'Feature delivery phase 2: generate-work-breakdown → Effort-Estimate → gate2_payload. Precondition: Gate 1 approved.',
4
- phases: [
5
- { title: 'Work Breakdown', detail: 'Run generate-work-breakdown' },
6
- { title: 'Effort Estimate', detail: 'Parse WB metrics, write Effort-Estimate.md' },
7
- ],
8
- }
9
-
10
- // Output schemas for wb-validate and wb-render agent invocations (used by US-05)
11
- const WB_WRAPPER_SCHEMA = {
12
- type: 'object',
13
- properties: {
14
- exitCode: { type: 'number' },
15
- stdout: { type: 'string' },
16
- stderr: { type: 'string' },
17
- },
18
- required: ['exitCode', 'stdout', 'stderr'],
19
- }
20
-
21
- const WB_RENDER_SCHEMA = {
22
- type: 'object',
23
- properties: {
24
- exitCode: { type: 'number' },
25
- stdout: { type: 'string' },
26
- stderr: { type: 'string' },
27
- markdownPath: { type: 'string' },
28
- csvPath: { type: 'string' },
29
- markdownExists: { type: 'boolean' },
30
- csvExists: { type: 'boolean' },
31
- },
32
- required: ['exitCode', 'stdout', 'stderr', 'markdownPath', 'csvPath', 'markdownExists', 'csvExists'],
33
- }
34
-
35
- const WB_SEMANTIC_SCHEMA = {
36
- type: 'object',
37
- properties: {
38
- valid: { type: 'boolean' },
39
- findings: { type: 'array' },
40
- },
41
- required: ['valid', 'findings'],
42
- }
43
-
44
- function normalizeError(err) {
45
- if (!err) return '<unknown error>'
46
- if (typeof err === 'string') return err
47
- if (err.message) return err.message
48
- if (err.stderr) return err.stderr
49
- try { return JSON.stringify(err) } catch (_) { return '<unknown error>' }
50
- }
51
-
52
- // ── Tier 2 resolution guard ───────────────────────────────────────────────────
53
- // All worker agents must be resolved via the CLI before dispatch.
54
- // No direct require('lib/agent-registry') — resolution goes through the CLI facade.
55
- const RESOLVE_SCHEMA = {
56
- type: 'object',
57
- properties: {
58
- nativeName: { type: 'string' },
59
- status: { type: 'string' },
60
- agentId: { type: 'string' },
61
- exitNonZero: { type: 'boolean' },
62
- error: { type: 'string' },
63
- },
64
- }
65
-
66
- async function resolveAgentTier2(agentId, label, phaseName) {
67
- const result = await agent(
68
- `Run this command via Bash:\n\nai-toolkit agents resolve --project . --id ${agentId} --require-verified\n\nIf the command exits 0: parse the JSON from stdout and return it with exitNonZero: false.\nIf the command exits non-zero: return {"exitNonZero": true, "error": "<stderr text>", "agentId": "${agentId}"}.`,
69
- { label, phase: phaseName, model: 'haiku', schema: RESOLVE_SCHEMA }
70
- )
71
- if (!result || result.exitNonZero || !result.nativeName) {
72
- throw new Error(`HARD STOP — Tier 2 resolution failed for ${agentId}: ${result && result.error ? result.error : 'no nativeName returned'}`)
4
+ description: 'Generate, validate and render a work breakdown with deterministic control-plane operations.',
5
+ phases: [{ title: 'Work Breakdown' }, { title: 'Effort Estimate' }],
6
+ }
7
+
8
+
9
+
10
+ const c = control
11
+ const { featurePath, featureDir, prefix } = c.feature(args[0])
12
+ return c.run(meta.name, featureDir, prefix, async () => {
13
+ const file = suffix => `${featureDir}/${prefix}-${suffix}`
14
+ const approvals = c.read(file('Approvals.md'))
15
+ const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
16
+ if (!gate || !/✅\s*Approved/.test(gate[1])) throw new Error('Gate 1 approval evidence missing')
17
+ await c.worker('gaia.agent.planner.work-breakdown', featurePath)
18
+ const jsonPath = file('Work-Breakdown.json')
19
+ const report = await c.activity('wb-validate', async () => {
20
+ const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
21
+ '--', jsonPath, file('Requirements.md')], [0, 1])
22
+ const parsed = JSON.parse(res.stdout)
23
+ if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
24
+ !Array.isArray(parsed.warnings) || (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid validator result')
25
+ if (!parsed.valid || parsed.errors.length) throw new Error('Work breakdown structural validation failed: ' + res.stdout)
26
+ return parsed
27
+ })
28
+ const semantic = await c.worker('gaia.agent.planner.validate-work-breakdown',
29
+ `${jsonPath}\n${file('Requirements.md')}`, { schema: {
30
+ type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array' } }, required: ['valid', 'findings'],
31
+ } })
32
+ if (!semantic.valid || semantic.findings.some(f => f.blocking)) throw new Error('Work breakdown semantic validation failed')
33
+ const rendered = await c.activity('wb-render', async () => {
34
+ const result = c.command(['run-asset', 'scripts/wb-render.js', '--project', c.root, '--', jsonPath, prefix])
35
+ const markdownPath = file('Work-Breakdown.md'), csvPath = file('Work-Breakdown.csv')
36
+ if (!c.exists(markdownPath) || !c.exists(csvPath)) throw new Error('Renderer outputs missing')
37
+ // Read the actual bytes now: unreadable output must never produce an approved gate.
38
+ c.read(markdownPath); c.read(csvPath)
39
+ return { ...result, markdownPath, csvPath, markdownExists: true, csvExists: true }
40
+ })
41
+ const metrics = await c.estimates(featureDir, prefix, c.json(jsonPath))
42
+ c.append(file('process-log.txt'), `[${new Date().toISOString()}] pm-phase2 complete; Gate 2 approval requested\n`)
43
+ return {
44
+ ...metrics, feature_path: featurePath, token_ledger: c.tokenLedger, errors: [],
45
+ gate2_payload: {
46
+ js_validator_report: report, js_validator_failed: false,
47
+ semantic_validator_result: semantic, semantic_validator_failed: false,
48
+ renderer_result: rendered, renderer_failed: false, gate2_blocked: false,
49
+ duration_bands: report.durationBands, domain_distribution: report.domainDistribution,
50
+ warning_band_tasks: report.warnings.filter(w => w.category === 'duration_warning').map(w => ({ taskId: w.taskId, agentMinutes: w.details?.agentMinutes })),
51
+ split_required_tasks: [], must_ac_uncovered: [], phase_unschedulable: [],
52
+ },
73
53
  }
74
- return result.nativeName
75
- }
76
-
77
- // ── Parse args ────────────────────────────────────────────────────────────────
78
-
79
- // args: "<path-to-feature.md>"
80
- const featurePath = (typeof args === 'string' ? args : '').trim().split(/\s+/)[0]
81
-
82
- // Feature directory = parent of the feature.md path. Separator-agnostic: the workflow
83
- // runtime provides no `path` module, so we cannot use path.dirname. Handle BOTH POSIX
84
- // "/" and Windows "\": strip a trailing "<sep>feature.md" tail when present; if the
85
- // input is already a directory (no feature.md tail), strip only a trailing separator.
86
- // This replaces the previous POSIX-only regex (/\/[^/]+$/), which left "\feature.md"
87
- // attached on Windows paths and produced malformed "…\feature.md/…json" paths that only
88
- // "worked" because an LLM wrapper agent silently repaired them.
89
- function deriveFeatureDir(featurePath) {
90
- return /[/\\]feature\.md$/i.test(featurePath)
91
- ? featurePath.replace(/[/\\]feature\.md$/i, '')
92
- : featurePath.replace(/[/\\]+$/, '')
93
- }
94
-
95
- // Derive feature directory and prefix early (needed for ledger writes before metrics agent runs)
96
- const featureDir = deriveFeatureDir(featurePath)
97
- const prefixMatch = featureDir.match(/([A-Z]+-\d+)/)
98
- const prefix = prefixMatch ? prefixMatch[1] : 'FTR-000'
99
-
100
- // ── Tier 3 — self-ledger helper ───────────────────────────────────────────────
101
- const SELF_LEDGER_SCHEMA = {
102
- type: 'object',
103
- properties: {
104
- exitCode: { type: 'number' },
105
- stdout: { type: 'string' },
106
- stderr: { type: 'string' },
107
- },
108
- required: ['exitCode'],
109
- }
110
-
111
- async function selfLedgerOp(cmd, label, phaseName) {
112
- const result = await agent(
113
- `Run this shell command via Bash and return the exit code as structured output.\n\nCommand: ${cmd}\n\nCapture: exitCode (integer), stdout (string), stderr (string). Return all three.`,
114
- { label, phase: phaseName, model: 'haiku', schema: SELF_LEDGER_SCHEMA }
115
- )
116
- const status = (result && typeof result.exitCode === 'number') ? result.exitCode : 1
117
- if (status !== 0) {
118
- throw new Error(`HARD STOP — self-ledger operation failed. Exit code: ${status}. Command: ${cmd}`)
119
- }
120
- }
121
-
122
- // ── Tier 3 self-registration (open BEFORE any main logic, fail-closed) ────────
123
- await selfLedgerOp(
124
- `ai-toolkit ledger open --prefix ${prefix} --agent pm-phase2:self --phase phase2 --dir "${featureDir}" --attempt 1`,
125
- 'ledger-open-pm-phase2-self', 'Work Breakdown'
126
- )
127
-
128
- // ── generate-work-breakdown ───────────────────────────────────────────────────
129
- phase('Work Breakdown')
130
-
131
- const tokenLedger = []
132
-
133
- log(`Running generate-work-breakdown for ${featurePath}`)
134
- const wbNativeName = await resolveAgentTier2('gaia.agent.planner.work-breakdown', 'resolve-generate-work-breakdown', 'Work Breakdown')
135
- const wbKey = 'generate-work-breakdown:phase2'
136
- await agent(
137
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${featureDir} --prefix ${prefix} --agent ${wbKey} --phase phase2 --model haiku --attempt 1\n\nReturn no output.`,
138
- { label: 'ledger-open-generate-work-breakdown', phase: 'Work Breakdown', model: 'haiku' }
139
- )
140
- const beforeWB = budget.spent()
141
- await agent(featurePath, {
142
- agentType: wbNativeName,
143
- label: 'generate-work-breakdown',
144
- phase: 'Work Breakdown',
145
54
  })
146
- const wbTokens = budget.spent() - beforeWB
147
- await agent(
148
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${featureDir} --prefix ${prefix} --agent ${wbKey} --tokens ${wbTokens} --attempt 1\n\nReturn no output.`,
149
- { label: 'ledger-close-generate-work-breakdown', phase: 'Work Breakdown', model: 'haiku' }
150
- )
151
- tokenLedger.push({ agent: 'generate-work-breakdown', model: 'haiku', phase_delta_tokens: wbTokens })
152
- log(`generate-work-breakdown done — phase delta: ${wbTokens} tokens`)
153
-
154
- // ── Step 2: wb-validate.js (deterministic structural validator) ───────────────
155
- let wbValidatorReport = null
156
- let validateFailed = false
157
- let validateExitCode = null
158
- const wbValidateKey = 'wb-validate:phase2'
159
- await agent(
160
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${featureDir} --prefix ${prefix} --agent ${wbValidateKey} --phase phase2 --model haiku --attempt 1\n\nReturn no output.`,
161
- { label: 'ledger-open-wb-validate', phase: 'Work Breakdown', model: 'haiku' }
162
- )
163
- const beforeValidate = budget.spent()
164
- try {
165
- const validateWrapper = await agent(
166
- `Run this command and return the result as structured output:\n` +
167
- `ai-toolkit run-asset scripts/wb-validate.js -- "${featureDir}/${prefix}-Work-Breakdown.json" "${featureDir}/${prefix}-Requirements.md"\n\n` +
168
- `Capture exitCode (integer), stdout (string), stderr (string). Return all three.`,
169
- {
170
- label: 'wb-validate',
171
- phase: 'Work Breakdown',
172
- model: 'haiku',
173
- schema: WB_WRAPPER_SCHEMA,
174
- }
175
- )
176
- validateExitCode = validateWrapper.exitCode
177
- if (validateWrapper.stderr?.trim()) {
178
- log(`wb-validate stderr: ${validateWrapper.stderr.trim()}`)
179
- }
180
- if (validateWrapper.exitCode === 2) {
181
- throw Object.assign(
182
- new Error(validateWrapper.stderr?.trim() || 'wb-validate runtime error (exit 2)'),
183
- { _exitCode: validateWrapper.exitCode }
184
- )
185
- }
186
- if (![0, 1].includes(validateWrapper.exitCode)) {
187
- throw Object.assign(
188
- new Error(`wb-validate unexpected exit code: ${validateWrapper.exitCode}`),
189
- { _exitCode: validateWrapper.exitCode }
190
- )
191
- }
192
- if (!validateWrapper.stdout?.trim()) {
193
- throw new Error('wb-validate returned empty stdout')
194
- }
195
- wbValidatorReport = JSON.parse(validateWrapper.stdout)
196
- if (validateWrapper.exitCode === 0 && !wbValidatorReport.valid) {
197
- throw new Error('Inconsistent wb-validate result: exit 0 with valid=false')
198
- }
199
- if (validateWrapper.exitCode === 1 && wbValidatorReport.valid) {
200
- throw new Error('Inconsistent wb-validate result: exit 1 with valid=true')
201
- }
202
- } catch (err) {
203
- validateFailed = true
204
- }
205
- const validateTokens = budget.spent() - beforeValidate
206
- await agent(
207
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${featureDir} --prefix ${prefix} --agent ${wbValidateKey} --tokens ${validateTokens} --attempt 1\n\nReturn no output.`,
208
- { label: 'ledger-close-wb-validate', phase: 'Work Breakdown', model: 'haiku' }
209
- )
210
- const wbValidatorPassed = !validateFailed && wbValidatorReport !== null
211
- && wbValidatorReport.valid === true && wbValidatorReport.errors.length === 0
212
- log(`wb-validate: ${wbValidatorPassed ? 'passed' : 'failed/errors'} — ${validateTokens} tokens`)
213
-
214
- // ── Step 3: Semantic validator (only if wb-validate passed) ───────────────────
215
- let semanticResult = null
216
- let semanticFailed = false
217
- let semanticTokens = 0
218
- const semanticKey = 'validate-work-breakdown-semantic:phase2'
219
- if (!wbValidatorPassed) {
220
- log('validate-work-breakdown-semantic: skipped (wb-validate did not pass)')
221
- } else {
222
- const semanticNativeName = await resolveAgentTier2('gaia.agent.planner.validate-work-breakdown', 'resolve-validate-work-breakdown-semantic', 'Work Breakdown')
223
- await agent(
224
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${featureDir} --prefix ${prefix} --agent ${semanticKey} --phase phase2 --model sonnet --attempt 1\n\nReturn no output.`,
225
- { label: 'ledger-open-semantic-validator', phase: 'Work Breakdown', model: 'haiku' }
226
- )
227
- const beforeSemantic = budget.spent()
228
- try {
229
- semanticResult = await agent(
230
- `${featureDir}/${prefix}-Work-Breakdown.json\n${featureDir}/${prefix}-Requirements.md`,
231
- {
232
- label: 'validate-work-breakdown-semantic',
233
- phase: 'Work Breakdown',
234
- agentType: semanticNativeName,
235
- schema: WB_SEMANTIC_SCHEMA,
236
- }
237
- )
238
- } catch (err) {
239
- semanticFailed = true
240
- }
241
- semanticTokens = budget.spent() - beforeSemantic
242
- await agent(
243
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${featureDir} --prefix ${prefix} --agent ${semanticKey} --tokens ${semanticTokens} --attempt 1\n\nReturn no output.`,
244
- { label: 'ledger-close-semantic-validator', phase: 'Work Breakdown', model: 'haiku' }
245
- )
246
- }
247
- log(`semantic validator: ${semanticFailed ? 'failed' : semanticResult ? 'done' : 'skipped'}`)
248
-
249
- // ── Step 4: wb-render.js (only if wb-validate passed and semantic did not fail) ─
250
- const canRender = wbValidatorPassed && !semanticFailed
251
- let renderFailed = false
252
- let renderResult = null
253
- const wbRenderKey = 'wb-render:phase2'
254
- if (!canRender) {
255
- log('wb-render: skipped (wb-validate or semantic validator did not pass)')
256
- } else {
257
- await agent(
258
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${featureDir} --prefix ${prefix} --agent ${wbRenderKey} --phase phase2 --model haiku --attempt 1\n\nReturn no output.`,
259
- { label: 'ledger-open-wb-render', phase: 'Work Breakdown', model: 'haiku' }
260
- )
261
- const beforeRender = budget.spent()
262
- try {
263
- renderResult = await agent(
264
- `Run this command and return the result as structured output:\n` +
265
- `ai-toolkit run-asset scripts/wb-render.js -- "${featureDir}/${prefix}-Work-Breakdown.json" "${prefix}"\n\n` +
266
- `Capture exitCode (integer), stdout (string), stderr (string).\n` +
267
- `Resolve the expected output paths:\n` +
268
- ` markdownPath = "${featureDir}/${prefix}-Work-Breakdown.md"\n` +
269
- ` csvPath = "${featureDir}/${prefix}-Work-Breakdown.csv"\n` +
270
- `Check whether each file exists on disk and return:\n` +
271
- ` markdownExists (boolean), csvExists (boolean).\n` +
272
- `Return all seven fields.`,
273
- {
274
- label: 'wb-render',
275
- phase: 'Work Breakdown',
276
- model: 'haiku',
277
- schema: WB_RENDER_SCHEMA,
278
- }
279
- )
280
- if (renderResult.stderr?.trim()) {
281
- log(`wb-render stderr: ${renderResult.stderr.trim()}`)
282
- }
283
- if (renderResult.exitCode !== 0) {
284
- throw Object.assign(
285
- new Error(`wb-render exited ${renderResult.exitCode}: ${renderResult.stderr?.trim() || renderResult.stdout?.trim()}`),
286
- { _exitCode: renderResult.exitCode }
287
- )
288
- }
289
- if (!renderResult.markdownPath?.trim()) throw new Error('wb-render did not return markdownPath')
290
- if (!renderResult.csvPath?.trim()) throw new Error('wb-render did not return csvPath')
291
- if (!renderResult.markdownExists) {
292
- throw Object.assign(
293
- new Error(`wb-render output file not found: ${renderResult.markdownPath}`),
294
- { _exitCode: renderResult.exitCode }
295
- )
296
- }
297
- if (!renderResult.csvExists) {
298
- throw Object.assign(
299
- new Error(`wb-render output file not found: ${renderResult.csvPath}`),
300
- { _exitCode: renderResult.exitCode }
301
- )
302
- }
303
- } catch (err) {
304
- renderFailed = true
305
- }
306
- const renderTokens = budget.spent() - beforeRender
307
- await agent(
308
- `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${featureDir} --prefix ${prefix} --agent ${wbRenderKey} --tokens ${renderTokens} --attempt 1\n\nReturn no output.`,
309
- { label: 'ledger-close-wb-render', phase: 'Work Breakdown', model: 'haiku' }
310
- )
311
- tokenLedger.push({ agent: 'wb-render', model: 'haiku', phase_delta_tokens: renderTokens })
312
- }
313
-
314
- tokenLedger.push({ agent: 'wb-validate', model: 'haiku', phase_delta_tokens: validateTokens })
315
- tokenLedger.push({ agent: 'validate-work-breakdown-semantic', model: 'sonnet', phase_delta_tokens: semanticTokens })
316
-
317
- // ── Step 5: Assemble gate2_payload ────────────────────────────────────────────
318
- const gate2_payload = {
319
- js_validator_report: wbValidatorReport,
320
- js_validator_failed: validateFailed,
321
- semantic_validator_result: semanticResult,
322
- semantic_validator_failed: semanticFailed,
323
- renderer_result: renderResult,
324
- renderer_failed: renderFailed,
325
- duration_bands: wbValidatorReport ? wbValidatorReport.durationBands : null,
326
- domain_distribution: wbValidatorReport ? wbValidatorReport.domainDistribution : null,
327
- warning_band_tasks: wbValidatorReport
328
- ? wbValidatorReport.warnings.filter(w => w.category === 'duration_warning')
329
- .map(w => ({ taskId: w.taskId, agentMinutes: w.details ? w.details.agentMinutes : null }))
330
- : [],
331
- split_required_tasks: wbValidatorReport
332
- ? wbValidatorReport.errors.filter(e => e.category === 'split_required')
333
- .map(e => ({ taskId: e.taskId, agentMinutes: e.details ? e.details.agentMinutes : null }))
334
- : [],
335
- must_ac_uncovered: wbValidatorReport
336
- ? wbValidatorReport.errors.filter(e => e.category === 'must_ac_uncovered')
337
- .map(e => ({ acId: e.details ? e.details.acId : null }))
338
- : [],
339
- phase_unschedulable: wbValidatorReport
340
- ? (wbValidatorReport.dependencies ? wbValidatorReport.dependencies.phaseUnschedulable || [] : [])
341
- : [],
342
- gate2_blocked:
343
- validateFailed ||
344
- !wbValidatorPassed ||
345
- semanticFailed ||
346
- renderFailed ||
347
- Boolean(semanticResult && semanticResult.findings && semanticResult.findings.some(f => f.blocking)),
348
- }
349
- log(`gate2_payload assembled — gate2_blocked: ${gate2_payload.gate2_blocked}`)
350
-
351
- // ── Parse WB + write Effort-Estimate + Token-Estimate ────────────────────────
352
- phase('Effort Estimate')
353
-
354
- const PARSE_SCHEMA = {
355
- type: 'object',
356
- properties: {
357
- prefix: { type: 'string' },
358
- feature_dir: { type: 'string' },
359
- user_stories: { type: 'number' },
360
- total_tasks: { type: 'number' },
361
- domain_breakdown: { type: 'string' },
362
- implementation_phases: { type: 'number' },
363
- human_estimate: { type: 'string' },
364
- agent_estimate: { type: 'string' },
365
- work_breakdown_path: { type: 'string' },
366
- effort_estimate_path: { type: 'string' },
367
- token_estimate_path: { type: 'string' },
368
- tasks_csv_path: { type: 'string' },
369
- },
370
- required: ['prefix', 'user_stories', 'total_tasks', 'work_breakdown_path'],
371
- }
372
-
373
- // ── Load token pricing ────────────────────────────────────────────────────────
374
- // Pricing config is read inline (haiku agent) so it survives path-agnostic installs.
375
- // Cost formula: tokens * (0.8 * input + 0.2 * output) / 1_000_000 * usd_to_eur
376
- // (80/20 input/output split — no per-agent breakdown available in the ledger)
377
- const pricingRaw = await agent(
378
- `Read the file docs/token-pricing.json and return its raw JSON contents as a string, nothing else.`,
379
- { label: 'read-pricing', phase: 'Effort Estimate', model: 'haiku' }
380
- )
381
- let pricing = null
382
- try { pricing = JSON.parse(typeof pricingRaw === 'string' ? pricingRaw.trim() : '{}') } catch (_) {}
383
-
384
- function tokenCostEur(tokens, model) {
385
- if (!pricing || !pricing.models) return null
386
- const m = pricing.models[model] || pricing.models['sonnet']
387
- const usdPer1M = 0.8 * m.input_per_1m_usd + 0.2 * m.output_per_1m_usd
388
- return (tokens * usdPer1M / 1_000_000) * (pricing.usd_to_eur || 1)
389
- }
390
-
391
- function formatEur(val) {
392
- if (val === null || val === undefined) return '—'
393
- return '€' + val.toFixed(4)
394
- }
395
-
396
- // NOTE: pm-phase2 is a separate workflow and does NOT receive pm-phase1's token ledger,
397
- // so the real Phase 1 actuals (generate-requirements/tech-spec/validate) are unknown here.
398
- // They are written as "—" placeholders below and filled later by the orchestrator (the
399
- // implement-feature skill Step 7, which holds all three phase ledgers). Do NOT reuse wbTokens
400
- // as a stand-in for Phase 1 — that mislabels Phase 2's tokens as Phase 1's.
401
-
402
- const metrics = await agent(
403
- `You have four tasks:
404
-
405
- TASK 1 — Read the Work Breakdown JSON file.
406
- The feature.md is at: ${featurePath}
407
- The JSON work breakdown is at: ${featureDir}/${prefix}-Work-Breakdown.json
408
- Read the JSON file and extract:
409
- - prefix (e.g. "FTR-009") — use "${prefix}"
410
- - feature_dir (directory containing feature.md) — use "${featureDir}"
411
- - feature_title — from wb.title or wb.feature field
412
- - total_tasks (integer) — count all tasks across all phases
413
- - user_stories (integer) — count phases whose id starts with "US-"
414
- - domain_breakdown (string) — e.g. "BE: N, FE: N, DB: N, INFRA: N, TEST: N"
415
- - implementation_phases (integer) — total number of phases
416
- - human_estimate — compute as: (BE_tasks * 120 + TEST_tasks * 60 + INFRA_tasks * 30 + FE_tasks * 90 + DB_tasks * 90) minutes, format as "~Xh Ymin"
417
- - agent_estimate — human_estimate / 4 (parallel dispatch), format as "~Xmin" or "~Xh Ymin"
418
- - work_breakdown_path — ${featureDir}/${prefix}-Work-Breakdown.json
419
-
420
- Also extract per-User-Story details for the Effort-Estimate:
421
- - US ID (phase.id where it starts with "US-")
422
- - US title (phase.title)
423
- - task count (phase.tasks.length)
424
- - domains involved (unique task.domain values in the phase)
425
- - estimated hours (per the formula above)
426
-
427
- TASK 2 — Write the Effort-Estimate.md file.
428
- Write {feature_dir}/{PREFIX}-Effort-Estimate.md with this format:
429
-
430
- # Effort Estimate — {PREFIX} — {feature_title}
431
-
432
- ## Summary
433
-
434
- | Metric | Value |
435
- |--------|-------|
436
- | User Stories | {user_stories} |
437
- | Total tasks | {total_tasks} ({domain_breakdown}) |
438
- | Implementation phases | {implementation_phases} |
439
- | Human estimate | {human_estimate} (sequential, no parallelism) |
440
- | Agent estimate | {agent_estimate} (parallel dispatch, critical path only) |
441
-
442
- ## Per-Phase Breakdown
443
-
444
- | Phase | Title | Tasks | Domains | Est. Human | Est. Agent | Actual Human | Actual Agent |
445
- |-------|-------|-------|---------|-----------|-----------|-------------|-------------|
446
- {one row per phase (INFRA + each US) extracted from Section 4 of the Work Breakdown — use ~2h per BE task, ~1h per TEST task, ~30min per INFRA task for human estimate; agent estimate = human / 6 as baseline}
447
- | **Total** | | **{total_tasks}** | | **{human_estimate}** | **{agent_estimate}** | **—** | **—** |
448
-
449
- ## Notes
450
- - Human estimate assumes sequential execution with no parallelism.
451
- - Agent estimate assumes parallel dispatch of independent tasks within each phase (critical path only).
452
- - Actual Human: filled in if a human developer performed or reviewed the implementation.
453
- - Actual Agent: filled in by pm-phase3 after implementation completes.
454
-
455
- TASK 3 — Write the Token-Estimate.md file.
456
- Write {feature_dir}/{PREFIX}-Token-Estimate.md with this format:
457
-
458
- # Token Estimate — {PREFIX} — {feature_title}
459
-
460
- ## Phase 1 — Documentation (Actuals)
461
-
462
- Phase 1 ran in a separate workflow (pm-phase1); its per-agent token actuals are filled in
463
- by the orchestrator after implementation. Leave the Tokens Actual cells as "—" here.
464
-
465
- | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
466
- |-------|------|-------|------------|------------|--------------|--------------|
467
- | generate-requirements | Generate requirements from feature.md | haiku | — | — | — | — |
468
- | generate-tech-spec | Generate tech spec from feature.md | haiku | — | — | — | — |
469
- | validate-feature-docs | Validate requirements + tech spec | haiku | — | — | — | — |
470
- | **Phase 1 total** | | | **—** | **—** | **—** | **—** |
471
-
472
- ## Phase 2 — Work Breakdown (Actuals)
473
-
474
- | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
475
- |-------|------|-------|------------|------------|--------------|--------------|
476
- | generate-work-breakdown | Generate work breakdown from docs | haiku | — | — | {wbTokens from above} | ${formatEur(tokenCostEur(wbTokens, 'haiku'))} |
477
- | **Phase 2 total** | | | **—** | **—** | **{wbTokens from above}** | **${formatEur(tokenCostEur(wbTokens, 'haiku'))}** |
478
-
479
- ## Phase 3 — Implementation (Estimates)
480
-
481
- Estimates based on {total_tasks} tasks ({domain_breakdown}), {user_stories} User Stories.
482
- Baseline: ~15,000 tokens/BE task, ~8,000/TEST task, ~5,000/INFRA task.
483
-
484
- | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
485
- |-------|------|-------|------------|------------|--------------|--------------|
486
- | developer-backend | Implement BE/INFRA tasks | sonnet | {estimated: be_tasks * 15000 + infra_tasks * 5000} | {cost for that estimate, sonnet} | — | — |
487
- | developer-testing | Implement TEST tasks | sonnet | {estimated: test_tasks * 8000} | {cost for that estimate, sonnet} | — | — |
488
- | review-solution (×{user_stories}) | Architect review per US | sonnet | {estimated: user_stories * 8000} | {cost for that estimate, sonnet} | — | — |
489
- | remediation | Fix review issues | sonnet | ~10,000 | {cost for 10000, sonnet} | — | — |
490
- | pr-and-registry | Push branch, create PR | sonnet | ~5,000 | {cost for 5000, sonnet} | — | — |
491
- | write-actuals | Update Token/Effort Estimate | sonnet | ~3,000 | {cost for 3000, sonnet} | — | — |
492
- | **Phase 3 total** | | | **{sum of phase 3 token estimates}** | **{sum of phase 3 cost estimates}** | **—** | **—** |
493
-
494
- For the "Est. cost €" column in Phase 3: use the formula tokens * (0.8 * 3.00 + 0.2 * 15.00) / 1_000_000 * ${pricing ? pricing.usd_to_eur : 0.92} for sonnet rows. Round to 4 decimal places, prefix with €.
495
-
496
- ## Grand Total
497
-
498
- | Phase | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
499
- |-------|------------|------------|--------------|--------------|
500
- | Phase 1 — Documentation | — | — | — (filled by orchestrator) | — |
501
- | Phase 2 — Work Breakdown | — | — | {wbTokens from above} | ${formatEur(tokenCostEur(wbTokens, 'haiku'))} |
502
- | Phase 3 — Implementation | {sum of phase 3 token estimates} | {sum of phase 3 cost estimates} | — | — |
503
- | **Total** | **{sum of phase 3 token estimates}** | **{sum of phase 3 cost estimates}** | **{wbTokens} (partial)** | **${formatEur(tokenCostEur(wbTokens, 'haiku'))} (partial)** |
504
-
505
- ---
506
- *Actuals will be appended by pm-phase3 after implementation completes.*
507
- *Cost assumes 80% input / 20% output token split. Pricing: sonnet $3.00/$15.00 per 1M, haiku $0.80/$4.00 per 1M (USD). Rate: $1 = €${pricing ? pricing.usd_to_eur : '0.92'} (${pricing ? pricing.usd_to_eur_date : 'see docs/token-pricing.json'}).*
508
-
509
- TASK 4 — Set return values.
510
- Set effort_estimate_path to the full path of the written Effort-Estimate.md.
511
- Set token_estimate_path to the full path of the written Token-Estimate.md.
512
- Set tasks_csv_path to "${featureDir}/${prefix}-Work-Breakdown.csv" (written by wb-render in the same directory as feature.md).
513
-
514
- Return the extracted metrics as structured output.`,
515
- {
516
- label: 'parse-wb-write-estimates',
517
- phase: 'Effort Estimate',
518
- schema: PARSE_SCHEMA,
519
- }
520
- )
521
-
522
- log(`WB parsed: ${metrics.user_stories} US, ${metrics.total_tasks} tasks, ${metrics.implementation_phases} phases`)
523
- log(`Effort-Estimate written: ${metrics.effort_estimate_path}`)
524
- log(`Token-Estimate written: ${metrics.token_estimate_path}`)
525
-
526
- // ── Append to process-log (phase 2 events) ───────────────────────────────────
527
- const wbEntry = tokenLedger.find(e => e.agent === 'generate-work-breakdown')
528
- const phase2Events = [
529
- `APPROVAL GRANTED by user — Gate 1`,
530
- `pm-phase2 START — work breakdown phase`,
531
- `Agent DONE: generate-work-breakdown — tokens: ${wbEntry ? wbEntry.phase_delta_tokens : 'N/A'}`,
532
- `Written: ${metrics.prefix}-Work-Breakdown.md (${metrics.user_stories} US, ${metrics.total_tasks} tasks, ${metrics.implementation_phases} phases)`,
533
- `Written: ${metrics.prefix}-Effort-Estimate.md`,
534
- `pm-phase2 COMPLETE`,
535
- `APPROVAL REQUESTED — Gate 2`,
536
- ].join('\n')
537
-
538
- await agent(
539
- `Append phase 2 events to the process-log for this feature delivery run.
540
-
541
- Process-log path: ${metrics.feature_dir}/${metrics.prefix}-process-log.txt
542
-
543
- Steps:
544
- 1. Get current UTC datetime via Bash: run \`date -u +"%Y-%m-%dT%H:%M:%S"\`
545
- 2. Append the following lines to the existing file. Use the datetime from step 1 for ALL event lines.
546
- Format each event line as: [{datetime}] {event text}
547
-
548
- Events to append:
549
- ${phase2Events}`,
550
- { label: 'append-process-log', phase: 'Effort Estimate' }
551
- )
552
-
553
- // ── Tier 3 self-registration — close on successful completion ─────────────────
554
- await selfLedgerOp(
555
- `ai-toolkit ledger close --prefix ${prefix} --agent pm-phase2:self --dir "${featureDir}" --attempt 1`,
556
- 'ledger-close-pm-phase2-self', 'Effort Estimate'
557
- )
558
-
559
- return {
560
- prefix: metrics.prefix || prefix,
561
- feature_path: featurePath,
562
- user_stories: metrics.user_stories,
563
- total_tasks: metrics.total_tasks,
564
- domain_breakdown: metrics.domain_breakdown || 'N/A',
565
- implementation_phases: metrics.implementation_phases,
566
- human_estimate: metrics.human_estimate || 'N/A',
567
- agent_estimate: metrics.agent_estimate || 'N/A',
568
- work_breakdown_path: metrics.work_breakdown_path,
569
- effort_estimate_path: metrics.effort_estimate_path || '',
570
- token_estimate_path: metrics.token_estimate_path || '',
571
- tasks_csv_path: metrics.tasks_csv_path || '',
572
- token_ledger: tokenLedger,
573
- errors: [],
574
- gate2_payload,
575
- }