@dtlabs/ai-toolkit 0.7.0 → 0.8.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.
@@ -96,6 +96,41 @@ Ask the user: **"What is the name of this feature?"** (short, descriptive, in th
96
96
 
97
97
  Example: "Supplier Onboarding" → `FTR-003-supplier-onboarding`
98
98
 
99
+ ### 1c. Create feature directory and initialize ledger
100
+
101
+ Once you know the PREFIX (from 1a) and the feature slug (from 1b), do the following **before asking any grilling questions**:
102
+
103
+ 1. Compute the paths:
104
+ - `featureDir` = `docs/features/{PREFIX}-{slug}` (e.g. `docs/features/FTR-003-supplier-onboarding`)
105
+ - `ledgerPath` = `{featureDir}/{PREFIX}-token-ledger.json`
106
+
107
+ 2. Create the feature directory via Bash:
108
+ ```bash
109
+ mkdir -p {featureDir}
110
+ ```
111
+
112
+ 3. Capture the current UTC timestamp via Bash:
113
+ ```bash
114
+ started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && echo $started_at
115
+ ```
116
+
117
+ 4. Write the initial ledger file at `{ledgerPath}` using the Write tool with exactly this content (substitute the actual PREFIX and timestamp):
118
+ ```json
119
+ [
120
+ {
121
+ "agent": "define-feature:define",
122
+ "phase": "define",
123
+ "model": "sonnet",
124
+ "status": "running",
125
+ "phase_delta_tokens": 0,
126
+ "started_at": "<started_at from step 3>",
127
+ "completed_at": null
128
+ }
129
+ ]
130
+ ```
131
+
132
+ The ledger records that the define-feature agent is now running. Proceed to Phase 2.
133
+
99
134
  ---
100
135
 
101
136
  ## Phase 2 — Grilling (only the gaps)
@@ -224,7 +259,7 @@ docs/features/{PREFIX}-{slug}/feature.md
224
259
 
225
260
  Example: `docs/features/FTR-003-supplier-onboarding/feature.md`
226
261
 
227
- Create the directory if it doesn't exist.
262
+ The directory was created in Phase 1c. If for any reason it doesn't exist, create it now.
228
263
 
229
264
  ### feature.md template
230
265
 
@@ -315,6 +350,37 @@ For each entity: name, key fields (name, type, constraints), relationships.
315
350
  - Acceptance criteria must be **testable** (Given/When/Then) — for technical features, express them as observable/verifiable checks (e.g. "grep returns nothing", "build passes", "output file exists")
316
351
  - Be concrete and specific — avoid vague statements like "the system should be fast"
317
352
 
353
+ ### 4b. Finalize ledger entry
354
+
355
+ After writing `feature.md`, update the ledger entry to record successful completion:
356
+
357
+ 1. Capture the current UTC timestamp via Bash:
358
+ ```bash
359
+ completed_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && echo $completed_at
360
+ ```
361
+
362
+ 2. Read the ledger file at `{featureDir}/{PREFIX}-token-ledger.json` using the Read tool.
363
+
364
+ 3. Write the updated ledger file using the Write tool, replacing the entry's fields:
365
+ - `"status"`: `"done"`
366
+ - `"completed_at"`: the timestamp captured in step 1
367
+ - `"phase_delta_tokens"`: leave as `0` — token measurement is not available to the agent directly
368
+
369
+ Example final ledger content:
370
+ ```json
371
+ [
372
+ {
373
+ "agent": "define-feature:define",
374
+ "phase": "define",
375
+ "model": "sonnet",
376
+ "status": "done",
377
+ "phase_delta_tokens": 0,
378
+ "started_at": "<original started_at>",
379
+ "completed_at": "<completed_at from step 1>"
380
+ }
381
+ ]
382
+ ```
383
+
318
384
  ---
319
385
 
320
386
  ## Phase 5 — Confirm and handoff
@@ -251,6 +251,39 @@ COMPACT_SECTION
251
251
 
252
252
  ---
253
253
 
254
+ ## Step 6 — Bash Permission Allowlist opt-in
255
+
256
+ After the Compact Instructions step, offer an opt-in to create or update `.claude/settings.local.json` in the destination project with a pre-approved Bash command allowlist.
257
+
258
+ 1. **Offer the opt-in** — inform the user and ask:
259
+
260
+ > "Would you like to create or update `.claude/settings.local.json` in this project with a pre-approved Bash command allowlist? This lets pm-phase3 worker agents run read-only commands (ls, git status, git log, npm test, dotnet build, etc.) without prompting you. Dangerous commands (git push, gh pr create, rm, git reset, git clean) will always require confirmation."
261
+ >
262
+ > Options: "Yes — write allowlist" / "No — skip"
263
+
264
+ 2. If the user selects **No**: skip silently; set `allowlist_status = "skipped (user said No)"`.
265
+
266
+ 3. If the user selects **Yes**:
267
+
268
+ a. **Invoke the merge CLI** from the toolkit root directory:
269
+ ```bash
270
+ node bin/cli.js merge-allowlist "{dest}"
271
+ ```
272
+ Capture stdout and the exit code.
273
+ - If exit code is non-zero: set `allowlist_status = "failed — see above"` and continue to Step 7 (allowlist is advisory, not blocking).
274
+ - If exit code is 0: the stdout line will be one of:
275
+ - `Allowlist: written` → set `allowlist_status = "written"`
276
+ - `Allowlist: merged (N rules preserved)` → set `allowlist_status = "merged (N rules preserved)"`
277
+ - `Allowlist: reset` → set `allowlist_status = "reset (file was malformed)"`
278
+
279
+ b. **Check and update `.gitignore`**:
280
+ ```bash
281
+ node bin/cli.js update-gitignore "{dest}"
282
+ ```
283
+ This ensures `.claude/settings.local.json` is gitignored in the destination project. The CLI is idempotent — it appends the line only if it is not already present.
284
+
285
+ ---
286
+
254
287
  ## Step 7 — Report
255
288
 
256
289
  ```
@@ -262,6 +295,7 @@ Modified/kept: N (user chose to keep existing version)
262
295
  Unchanged (same): N (skipped silently)
263
296
  Spawn depth: ✅ CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=2+ present / ⚠️ MISSING — see action above
264
297
  Compact instructions: {compact_instructions_status}
298
+ Allowlist: {allowlist_status}
265
299
  ──────────────────────────────────────────────────
266
300
  Next steps:
267
301
  1. Run /init-agents to generate AGENTS.md for this project
@@ -9,6 +9,26 @@ export const meta = {
9
9
  ],
10
10
  }
11
11
 
12
+ // ── Ledger helper functions ───────────────────────────────────────────────────
13
+
14
+ async function appendLedgerEntry(featureDir, prefix, entry) {
15
+ const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
16
+ const entryJson = JSON.stringify(entry)
17
+ 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
+ { label: 'append-ledger', phase: 'Requirements', model: 'haiku' }
20
+ )
21
+ }
22
+
23
+ async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
24
+ const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
25
+ const updatesJson = JSON.stringify(updates)
26
+ 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.`,
28
+ { label: 'update-ledger', phase: 'Requirements', model: 'haiku' }
29
+ )
30
+ }
31
+
12
32
  // ── Parse args ────────────────────────────────────────────────────────────────
13
33
  // args is the raw prompt string: "<path-to-feature.md> [--force]"
14
34
  const featurePath = args.split(/\s+/)[0]
@@ -70,6 +90,16 @@ const { prefix, feature_dir } = discoveryResult
70
90
  log(`Prefix: ${prefix} | Dir: ${feature_dir}`)
71
91
  log(`needs_requirements=${discoveryResult.needs_requirements} needs_tech_spec=${discoveryResult.needs_tech_spec}`)
72
92
 
93
+ // ── Ensure ledger file exists (US-02-T02) ─────────────────────────────────────
94
+ // If define-feature was not used, the ledger file will not exist yet.
95
+ // Touch it with an empty array so appendLedgerEntry can always assume a valid base.
96
+ const ledgerFilePath = `${feature_dir}/${prefix}-token-ledger.json`
97
+ await agent(
98
+ `Check whether the file ${ledgerFilePath} exists.\n\n1. Try to read the file using the Read tool.\n2. If the file does NOT exist: write it now using the Write tool with contents: []\n3. If the file already exists and is a valid JSON array: do nothing.\n4. If the file exists but is not valid JSON: overwrite it with: []\n5. Return no output.`,
99
+ { label: 'ensure-ledger', phase: 'Discovery', model: 'haiku' }
100
+ )
101
+ log(`Ledger ensured at ${ledgerFilePath}`)
102
+
73
103
  // ── generate-requirements ─────────────────────────────────────────────────────
74
104
  phase('Requirements')
75
105
 
@@ -77,9 +107,25 @@ const tokenLedger = []
77
107
  const errors = []
78
108
 
79
109
  if (discoveryResult.needs_requirements) {
110
+ const reqKey = 'generate-requirements:phase1'
111
+ const reqStartedAt = new Date().toISOString()
112
+ await appendLedgerEntry(feature_dir, prefix, {
113
+ agent: reqKey,
114
+ phase: 'phase1',
115
+ model: 'haiku',
116
+ status: 'running',
117
+ phase_delta_tokens: 0,
118
+ started_at: reqStartedAt,
119
+ completed_at: null,
120
+ })
80
121
  const beforeReq = budget.spent()
81
122
  const reqResult = await agent(featurePath, { agentType: 'generate-requirements', label: 'generate-requirements', phase: 'Requirements' })
82
123
  const reqTokens = budget.spent() - beforeReq
124
+ await updateLedgerEntry(feature_dir, prefix, reqKey, {
125
+ status: 'done',
126
+ completed_at: new Date().toISOString(),
127
+ phase_delta_tokens: reqTokens,
128
+ })
83
129
  tokenLedger.push({ agent: 'generate-requirements', model: 'haiku', phase_delta_tokens: reqTokens })
84
130
  log(`generate-requirements done — phase delta: ${reqTokens} tokens`)
85
131
  } else {
@@ -90,9 +136,25 @@ if (discoveryResult.needs_requirements) {
90
136
  phase('Tech-Spec')
91
137
 
92
138
  if (discoveryResult.needs_tech_spec) {
139
+ const specKey = 'generate-tech-spec:phase1'
140
+ const specStartedAt = new Date().toISOString()
141
+ await appendLedgerEntry(feature_dir, prefix, {
142
+ agent: specKey,
143
+ phase: 'phase1',
144
+ model: 'haiku',
145
+ status: 'running',
146
+ phase_delta_tokens: 0,
147
+ started_at: specStartedAt,
148
+ completed_at: null,
149
+ })
93
150
  const beforeSpec = budget.spent()
94
151
  const specResult = await agent(featurePath, { agentType: 'generate-tech-spec', label: 'generate-tech-spec', phase: 'Tech-Spec' })
95
152
  const specTokens = budget.spent() - beforeSpec
153
+ await updateLedgerEntry(feature_dir, prefix, specKey, {
154
+ status: 'done',
155
+ completed_at: new Date().toISOString(),
156
+ phase_delta_tokens: specTokens,
157
+ })
96
158
  tokenLedger.push({ agent: 'generate-tech-spec', model: 'haiku', phase_delta_tokens: specTokens })
97
159
  log(`generate-tech-spec done — phase delta: ${specTokens} tokens`)
98
160
  } else {
@@ -108,9 +170,25 @@ const MAX_CYCLES = 3
108
170
 
109
171
  for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
110
172
  log(`validate-feature-docs cycle ${cycle}`)
173
+ const valKey = `validate-feature-docs:phase1:cycle${cycle}`
174
+ const valStartedAt = new Date().toISOString()
175
+ await appendLedgerEntry(feature_dir, prefix, {
176
+ agent: valKey,
177
+ phase: 'phase1',
178
+ model: 'haiku',
179
+ status: 'running',
180
+ phase_delta_tokens: 0,
181
+ started_at: valStartedAt,
182
+ completed_at: null,
183
+ })
111
184
  const beforeVal = budget.spent()
112
185
  const valResult = await agent(featurePath, { agentType: 'validate-feature-docs', label: `validate-feature-docs (cycle ${cycle})`, phase: 'Validation' })
113
186
  const valTokens = budget.spent() - beforeVal
187
+ await updateLedgerEntry(feature_dir, prefix, valKey, {
188
+ status: 'done',
189
+ completed_at: new Date().toISOString(),
190
+ phase_delta_tokens: valTokens,
191
+ })
114
192
  tokenLedger.push({ agent: `validate-feature-docs (cycle ${cycle})`, model: 'haiku', phase_delta_tokens: valTokens })
115
193
 
116
194
  // valResult is the agent's text output — check for gap indicators
@@ -7,15 +7,54 @@ export const meta = {
7
7
  ],
8
8
  }
9
9
 
10
+ // ── Ledger helper functions ───────────────────────────────────────────────────
11
+ // Route all ledger I/O through agent() — fs is not available in the workflow runtime.
12
+ // Mirrors appendLedgerEntry / updateLedgerEntry from bin/cli.js (same contract).
13
+
14
+ async function appendLedgerEntry(featureDir, prefix, entry) {
15
+ const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
16
+ const entryJson = JSON.stringify(entry)
17
+ 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
+ { label: 'append-ledger', phase: 'Work Breakdown', model: 'haiku' }
20
+ )
21
+ }
22
+
23
+ async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
24
+ const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
25
+ const updatesJson = JSON.stringify(updates)
26
+ 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.`,
28
+ { label: 'update-ledger', phase: 'Work Breakdown', model: 'haiku' }
29
+ )
30
+ }
31
+
32
+ // ── Parse args ────────────────────────────────────────────────────────────────
33
+
10
34
  // args: "<path-to-feature.md>"
11
35
  const featurePath = (typeof args === 'string' ? args : '').trim().split(/\s+/)[0]
12
36
 
37
+ // Derive feature directory and prefix early (needed for ledger writes before metrics agent runs)
38
+ const featureDir = featurePath.replace(/\/[^/]+$/, '')
39
+ const prefixMatch = featureDir.match(/([A-Z]+-\d+)/)
40
+ const prefix = prefixMatch ? prefixMatch[1] : 'FTR-000'
41
+
13
42
  // ── generate-work-breakdown ───────────────────────────────────────────────────
14
43
  phase('Work Breakdown')
15
44
 
16
45
  const tokenLedger = []
17
46
 
18
47
  log(`Running generate-work-breakdown for ${featurePath}`)
48
+ const wbStartedAt = new Date().toISOString()
49
+ await appendLedgerEntry(featureDir, prefix, {
50
+ agent: 'generate-work-breakdown:phase2',
51
+ phase: 'phase2',
52
+ model: 'haiku',
53
+ status: 'running',
54
+ phase_delta_tokens: 0,
55
+ started_at: wbStartedAt,
56
+ completed_at: null,
57
+ })
19
58
  const beforeWB = budget.spent()
20
59
  await agent(featurePath, {
21
60
  agentType: 'generate-work-breakdown',
@@ -23,6 +62,11 @@ await agent(featurePath, {
23
62
  phase: 'Work Breakdown',
24
63
  })
25
64
  const wbTokens = budget.spent() - beforeWB
65
+ await updateLedgerEntry(featureDir, prefix, 'generate-work-breakdown:phase2', {
66
+ status: 'done',
67
+ completed_at: new Date().toISOString(),
68
+ phase_delta_tokens: wbTokens,
69
+ })
26
70
  tokenLedger.push({ agent: 'generate-work-breakdown', model: 'haiku', phase_delta_tokens: wbTokens })
27
71
  log(`generate-work-breakdown done — phase delta: ${wbTokens} tokens`)
28
72
 
@@ -48,7 +92,34 @@ const PARSE_SCHEMA = {
48
92
  required: ['prefix', 'user_stories', 'total_tasks', 'work_breakdown_path'],
49
93
  }
50
94
 
51
- const phase1Tokens = wbTokens // tokens consumed by this phase so far
95
+ // ── Load token pricing ────────────────────────────────────────────────────────
96
+ // Pricing config is read inline (haiku agent) so it survives path-agnostic installs.
97
+ // Cost formula: tokens * (0.8 * input + 0.2 * output) / 1_000_000 * usd_to_eur
98
+ // (80/20 input/output split — no per-agent breakdown available in the ledger)
99
+ const pricingRaw = await agent(
100
+ `Read the file docs/token-pricing.json and return its raw JSON contents as a string, nothing else.`,
101
+ { label: 'read-pricing', phase: 'Effort Estimate', model: 'haiku' }
102
+ )
103
+ let pricing = null
104
+ try { pricing = JSON.parse(typeof pricingRaw === 'string' ? pricingRaw.trim() : '{}') } catch (_) {}
105
+
106
+ function tokenCostEur(tokens, model) {
107
+ if (!pricing || !pricing.models) return null
108
+ const m = pricing.models[model] || pricing.models['sonnet']
109
+ const usdPer1M = 0.8 * m.input_per_1m_usd + 0.2 * m.output_per_1m_usd
110
+ return (tokens * usdPer1M / 1_000_000) * (pricing.usd_to_eur || 1)
111
+ }
112
+
113
+ function formatEur(val) {
114
+ if (val === null || val === undefined) return '—'
115
+ return '€' + val.toFixed(4)
116
+ }
117
+
118
+ // NOTE: pm-phase2 is a separate workflow and does NOT receive pm-phase1's token ledger,
119
+ // so the real Phase 1 actuals (generate-requirements/tech-spec/validate) are unknown here.
120
+ // They are written as "—" placeholders below and filled later by the orchestrator (the
121
+ // implement-feature skill Step 7, which holds all three phase ledgers). Do NOT reuse wbTokens
122
+ // as a stand-in for Phase 1 — that mislabels Phase 2's tokens as Phase 1's.
52
123
 
53
124
  const metrics = await agent(
54
125
  `You have four tasks:
@@ -113,46 +184,52 @@ Write {feature_dir}/{PREFIX}-Token-Estimate.md with this format:
113
184
 
114
185
  ## Phase 1 — Documentation (Actuals)
115
186
 
116
- | Agent | Task | Model | Tokens Est. | Tokens Actual |
117
- |-------|------|-------|------------|--------------|
118
- | generate-requirements | Generate requirements from feature.md | haiku | — | {phase1_req_tokens} |
119
- | generate-tech-spec | Generate tech spec from feature.md | haiku | — | {phase1_spec_tokens} |
120
- | validate-feature-docs | Validate requirements + tech spec | haiku | — | {phase1_val_tokens} |
121
- | **Phase 1 total** | | | **—** | **${phase1Tokens}** |
187
+ Phase 1 ran in a separate workflow (pm-phase1); its per-agent token actuals are filled in
188
+ by the orchestrator after implementation. Leave the Tokens Actual cells as "—" here.
189
+
190
+ | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
191
+ |-------|------|-------|------------|------------|--------------|--------------|
192
+ | generate-requirements | Generate requirements from feature.md | haiku | — | — | — | — |
193
+ | generate-tech-spec | Generate tech spec from feature.md | haiku | — | — | — | — |
194
+ | validate-feature-docs | Validate requirements + tech spec | haiku | — | — | — | — |
195
+ | **Phase 1 total** | | | **—** | **—** | **—** | **—** |
122
196
 
123
197
  ## Phase 2 — Work Breakdown (Actuals)
124
198
 
125
- | Agent | Task | Model | Tokens Est. | Tokens Actual |
126
- |-------|------|-------|------------|--------------|
127
- | generate-work-breakdown | Generate work breakdown from docs | haiku | — | {wbTokens from above} |
128
- | **Phase 2 total** | | | **—** | **{wbTokens from above}** |
199
+ | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
200
+ |-------|------|-------|------------|------------|--------------|--------------|
201
+ | generate-work-breakdown | Generate work breakdown from docs | haiku | — | — | {wbTokens from above} | ${formatEur(tokenCostEur(wbTokens, 'haiku'))} |
202
+ | **Phase 2 total** | | | **—** | **—** | **{wbTokens from above}** | **${formatEur(tokenCostEur(wbTokens, 'haiku'))}** |
129
203
 
130
204
  ## Phase 3 — Implementation (Estimates)
131
205
 
132
206
  Estimates based on {total_tasks} tasks ({domain_breakdown}), {user_stories} User Stories.
133
207
  Baseline: ~15,000 tokens/BE task, ~8,000/TEST task, ~5,000/INFRA task.
134
208
 
135
- | Agent | Task | Model | Tokens Est. | Tokens Actual |
136
- |-------|------|-------|------------|--------------|
137
- | developer-backend | Implement BE/INFRA tasks | sonnet | {estimated: be_tasks * 15000 + infra_tasks * 5000} | — |
138
- | developer-testing | Implement TEST tasks | sonnet | {estimated: test_tasks * 8000} | — |
139
- | review-solution (×{user_stories}) | Architect review per US | sonnet | {estimated: user_stories * 8000} | — |
140
- | remediation | Fix review issues | sonnet | ~10,000 | — |
141
- | pr-and-registry | Push branch, create PR | sonnet | ~5,000 | — |
142
- | write-actuals | Update Token/Effort Estimate | sonnet | ~3,000 | — |
143
- | **Phase 3 total** | | | **{sum of phase 3 estimates}** | **—** |
209
+ | Agent | Task | Model | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
210
+ |-------|------|-------|------------|------------|--------------|--------------|
211
+ | developer-backend | Implement BE/INFRA tasks | sonnet | {estimated: be_tasks * 15000 + infra_tasks * 5000} | {cost for that estimate, sonnet} | — | — |
212
+ | developer-testing | Implement TEST tasks | sonnet | {estimated: test_tasks * 8000} | {cost for that estimate, sonnet} | — | — |
213
+ | review-solution (×{user_stories}) | Architect review per US | sonnet | {estimated: user_stories * 8000} | {cost for that estimate, sonnet} | — | — |
214
+ | remediation | Fix review issues | sonnet | ~10,000 | {cost for 10000, sonnet} | — | — |
215
+ | pr-and-registry | Push branch, create PR | sonnet | ~5,000 | {cost for 5000, sonnet} | — | — |
216
+ | write-actuals | Update Token/Effort Estimate | sonnet | ~3,000 | {cost for 3000, sonnet} | — | — |
217
+ | **Phase 3 total** | | | **{sum of phase 3 token estimates}** | **{sum of phase 3 cost estimates}** | **—** | **—** |
218
+
219
+ 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 €.
144
220
 
145
221
  ## Grand Total
146
222
 
147
- | Phase | Tokens Est. | Tokens Actual |
148
- |-------|------------|--------------|
149
- | Phase 1 — Documentation | — | ${phase1Tokens} |
150
- | Phase 2 — Work Breakdown | — | {wbTokens from above} |
151
- | Phase 3 — Implementation | {sum of phase 3 estimates} | — |
152
- | **Total** | **{sum of phase 3 estimates}** | **{phase1Tokens + wbTokens} (partial)** |
223
+ | Phase | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € |
224
+ |-------|------------|------------|--------------|--------------|
225
+ | Phase 1 — Documentation | — | — | — (filled by orchestrator) | — |
226
+ | Phase 2 — Work Breakdown | — | — | {wbTokens from above} | ${formatEur(tokenCostEur(wbTokens, 'haiku'))} |
227
+ | Phase 3 — Implementation | {sum of phase 3 token estimates} | {sum of phase 3 cost estimates} | — | — |
228
+ | **Total** | **{sum of phase 3 token estimates}** | **{sum of phase 3 cost estimates}** | **{wbTokens} (partial)** | **${formatEur(tokenCostEur(wbTokens, 'haiku'))} (partial)** |
153
229
 
154
230
  ---
155
231
  *Actuals will be appended by pm-phase3 after implementation completes.*
232
+ *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'}).*
156
233
 
157
234
  TASK 4 — Set return values.
158
235
  Set effort_estimate_path to the full path of the written Effort-Estimate.md.
@@ -425,12 +425,19 @@ const persistedLedgerRaw = await agent(
425
425
  try {
426
426
  const persisted = JSON.parse(typeof persistedLedgerRaw === 'string' ? persistedLedgerRaw.trim() : '[]')
427
427
  if (Array.isArray(persisted)) {
428
- const seen = new Set(tokenLedger.map(e => e.agent))
428
+ const inMemoryByAgent = new Map(tokenLedger.map((e, i) => [e.agent, i]))
429
429
  for (const entry of persisted) {
430
- if (entry && entry.agent && !seen.has(entry.agent)) {
430
+ if (!entry || !entry.agent) continue
431
+ const idx = inMemoryByAgent.get(entry.agent)
432
+ if (idx === undefined) {
433
+ // entry exists on disk but not in memory — recover it (e.g. from a prior interrupted run)
431
434
  tokenLedger.push(entry)
432
- seen.add(entry.agent)
435
+ inMemoryByAgent.set(entry.agent, tokenLedger.length - 1)
433
436
  log(`Recovered ledger entry from disk: ${entry.agent} (${entry.phase_delta_tokens} tokens)`)
437
+ } else if (tokenLedger[idx].phase_delta_tokens === 0 && (entry.phase_delta_tokens || 0) > 0) {
438
+ // in-memory entry has delta=0 (cached agent on resume) but disk has real data — prefer disk
439
+ tokenLedger[idx] = entry
440
+ log(`Restored real token count from disk: ${entry.agent} (${entry.phase_delta_tokens} tokens)`)
434
441
  }
435
442
  }
436
443
  }
@@ -440,6 +447,44 @@ try {
440
447
 
441
448
  const totalPhase3Tokens = tokenLedger.reduce((s, e) => s + (e.phase_delta_tokens || 0), 0)
442
449
 
450
+ // ── Load token pricing for cost columns ──────────────────────────────────────
451
+ const pricingRaw3 = await agent(
452
+ `Read the file docs/token-pricing.json and return its raw JSON contents as a string, nothing else.`,
453
+ { label: 'read-pricing', phase: 'Actuals', model: 'haiku' }
454
+ )
455
+ let pricing3 = null
456
+ try { pricing3 = JSON.parse(typeof pricingRaw3 === 'string' ? pricingRaw3.trim() : '{}') } catch (_) {}
457
+
458
+ function tokenCostEur3(tokens, model) {
459
+ if (!pricing3 || !pricing3.models) return null
460
+ const m = pricing3.models[model] || pricing3.models['sonnet']
461
+ const usdPer1M = 0.8 * m.input_per_1m_usd + 0.2 * m.output_per_1m_usd
462
+ return (tokens * usdPer1M / 1_000_000) * (pricing3.usd_to_eur || 1)
463
+ }
464
+
465
+ function formatEur3(val) {
466
+ if (val === null || val === undefined) return '—'
467
+ return '€' + val.toFixed(4)
468
+ }
469
+
470
+ // Aggregate the per-agent ledger into per-role totals so the actuals line up with the
471
+ // per-role estimate rows pm-phase2 wrote (developer-backend, developer-testing,
472
+ // review-solution, pr-and-registry, write-actuals, plus final-test-run). Precomputing
473
+ // here keeps the write-actuals agent from having to reason about grouping.
474
+ const roleTotals = {}
475
+ const roleModels = {}
476
+ for (const e of tokenLedger) {
477
+ const role = String(e.agent).split(':')[0]
478
+ roleTotals[role] = (roleTotals[role] || 0) + (e.phase_delta_tokens || 0)
479
+ roleModels[role] = e.model || 'sonnet'
480
+ }
481
+ const roleRows = Object.entries(roleTotals)
482
+ .map(([role, tok]) => `| ${role} | ${tok} | ${formatEur3(tokenCostEur3(tok, roleModels[role]))} |`)
483
+ .join('\n')
484
+ const totalPhase3CostEur = formatEur3(
485
+ Object.entries(roleTotals).reduce((s, [role, tok]) => s + (tokenCostEur3(tok, roleModels[role]) || 0), 0)
486
+ )
487
+
443
488
  const ACTUALS_SCHEMA = {
444
489
  type: 'object',
445
490
  properties: {
@@ -462,30 +507,50 @@ ${JSON.stringify(tokenLedger, null, 2)}
462
507
 
463
508
  Total tokens consumed by pm-phase3 workflow: ${totalPhase3Tokens} (exact, from budget tracking)
464
509
 
465
- TASK 1 — Append actuals to ${prefix}-Token-Estimate.md:
466
- Find the file in the feature directory. If it does not exist, create it.
510
+ TASK 1 — Fill in the Phase 3 actuals in ${prefix}-Token-Estimate.md IN PLACE.
511
+ Read the file in the feature directory first (it was written by pm-phase2 and already contains
512
+ Phase 1/Phase 2 actuals and the Phase 3 estimate table). If it does not exist, create it.
467
513
 
468
- Append this section at the end of the file:
514
+ Do NOT append a second Phase 3 table. Instead:
469
515
 
470
- ---
516
+ 1. Locate the existing "## Phase 3 — Implementation (Estimates)" section. Replace its heading
517
+ with "## Phase 3 — Implementation (Est. vs Actual, by role)" and REPLACE its table with the
518
+ per-role reconciliation below — filling the "Tokens Actual" and "Actual cost €" columns of the
519
+ existing per-role estimate rows (keep the existing Tokens Est. and Est. cost € values; add a Delta column):
471
520
 
472
- ## Phase 3 — Implementation (Actuals)
521
+ | Role | Model | Tokens Actual | Actual cost € |
522
+ |------|-------|--------------|--------------|
523
+ ${roleRows}
524
+ | **Phase 3 total** | | **${totalPhase3Tokens}** | **${totalPhase3CostEur}** |
473
525
 
474
- > Phase totals are exact measurements from budget tracking.
475
- > Per-agent values are exact where each agent ran separately; proportional where merged.
526
+ Match each existing estimate row (developer-backend, developer-testing, review-solution,
527
+ remediation, pr-and-registry, write-actuals) to the actual role total above by name, write the
528
+ actual into its "Tokens Actual" and "Actual cost €" cells, and compute Delta = Actual − Est (tokens).
529
+ Roles present in the actuals but absent from the estimate rows (e.g. final-test-run) get a new row with Est. = —.
476
530
 
477
- | Agent | Phase | Model | Tokens Est. | Tokens Actual |
478
- |-------|-------|-------|------------|--------------|
479
- ${tokenLedger.map(e => `| ${e.agent} | — | ${e.model} | — | ${e.phase_delta_tokens} |`).join('\n')}
480
- | **Phase 3 total** | | | **—** | **${totalPhase3Tokens}** |
531
+ 2. Immediately after that table, add the per-agent detail as a sub-table (source of truth):
481
532
 
482
- ## Grand Total (updated)
533
+ ### Phase 3 — per-agent detail (actuals)
483
534
 
484
- | Phase | Tokens Est. | Tokens Actual |
485
- |-------|------------|--------------|
486
- | Phase 1 — Documentation | — | *(see above)* |
487
- | Phase 2 — Work Breakdown | — | *(see above)* |
488
- | Phase 3 — Implementation | *(see above)* | ${totalPhase3Tokens} |
535
+ | Agent | Model | Tokens Actual | Actual cost € |
536
+ |-------|-------|--------------|--------------|
537
+ ${tokenLedger.map(e => `| ${e.agent} | ${e.model} | ${e.phase_delta_tokens} | ${formatEur3(tokenCostEur3(e.phase_delta_tokens, e.model))} |`).join('\n')}
538
+ | **Detail total** | | **${totalPhase3Tokens}** | **${totalPhase3CostEur}** |
539
+
540
+ 3. Update the existing "## Grand Total" table. Read the Phase 1 and Phase 2 actual totals from
541
+ the top of THIS SAME file (the "Phase 1 total" and "Phase 2 total" rows already present) and
542
+ copy them verbatim — never the literal text "(see above)". If a value there is "—" (Phase 1 is
543
+ written as a placeholder by pm-phase2 and filled later by the orchestrator), keep it as "—";
544
+ do NOT invent a number. The table must read:
545
+
546
+ | Phase | Tokens Est. | Est. cost € | Tokens Actual | Actual cost € | Delta (tokens) |
547
+ |-------|------------|------------|--------------|--------------|---------------|
548
+ | Phase 1 — Documentation | — | — | {Phase 1 total from this file, or — if placeholder} | — | — |
549
+ | Phase 2 — Work Breakdown | — | — | {Phase 2 total from this file} | {Phase 2 cost from this file} | — |
550
+ | Phase 3 — Implementation | {existing Phase 3 estimate total} | {existing Phase 3 est. cost} | ${totalPhase3Tokens} | ${totalPhase3CostEur} | {Actual − Est} |
551
+ | **Total** | **{sum of numeric Est}** | **{sum of numeric Est. cost}** | **{sum of numeric Actual}** | **{sum of numeric Actual cost}** | **{Actual − Est}** |
552
+
553
+ 4. Append (or update if present) the Implementation Summary section:
489
554
 
490
555
  ## Implementation Summary
491
556
 
@@ -495,6 +560,8 @@ ${tokenLedger.map(e => `| ${e.agent} | — | ${e.model} | — | ${e.phase_delta_
495
560
  | US passed | ${usPassed.join(', ') || 'N/A'} |
496
561
  | US escalated | ${usEscalated.join(', ') || 'none'} |
497
562
 
563
+ Remove the stale "*Actuals will be appended by pm-phase3...*" footer line if it is still present.
564
+
498
565
  TASK 2 — Update actuals in ${prefix}-Effort-Estimate.md:
499
566
  Find the file in the feature directory. Read the existing Per-Phase Breakdown table and update the "Actual Agent" column for each phase that was implemented. Use the token ledger to infer which phases completed:
500
567
  - Phases in usPassed: ${usPassed.join(', ') || 'none'} — mark Actual Agent as completed (write the wall-clock duration if available, otherwise write "done (agent)")
@@ -558,6 +625,17 @@ ${phase3Events}`,
558
625
  { label: 'finalize-process-log', phase: 'Actuals' }
559
626
  )
560
627
 
628
+ await agent(
629
+ `Commit the actuals files for this feature delivery run.
630
+
631
+ Run these exact git commands in the repository root:
632
+ git add "${featureDir3}/${prefix}-Token-Estimate.md" "${featureDir3}/${prefix}-Effort-Estimate.md" "${featureDir3}/${prefix}-process-log.txt"
633
+ git commit -m "docs(${prefix}): add token/effort actuals and process log"
634
+
635
+ If there is nothing to commit (all files already committed), that is fine — report success.`,
636
+ { label: 'commit-actuals', phase: 'Actuals' }
637
+ )
638
+
561
639
  return {
562
640
  pr_url: prResult.pr_url,
563
641
  token_ledger: tokenLedger,