@dtlabs/ai-toolkit 0.11.0 → 0.12.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.
@@ -105,6 +105,38 @@ function sanitizeField(str) {
105
105
  .trim()
106
106
  }
107
107
 
108
+ // ── Verification-command helpers (LOSSLESS — never sanitized) ─────────────────
109
+ //
110
+ // Verification commands are executable and MUST survive verbatim: operators like
111
+ // `||`, shell pipes `|`, and regex alternations `grep -E 'a|b|c'` are destroyed by
112
+ // sanitizeField (which maps `|` → space to protect table/CSV column structure).
113
+ // These commands are therefore rendered in a dedicated section as fenced code
114
+ // blocks whose content is byte-equivalent to the source JSON. The task table shows
115
+ // only a reference (a count + anchor link), never a sanitized copy of a command.
116
+
117
+ function getVerificationCommands(task) {
118
+ const v = task && (task.verificationCommands ?? task.verification?.commands)
119
+ return Array.isArray(v) ? v : []
120
+ }
121
+
122
+ // Stable, deterministic HTML anchor id for a task's authoritative detail block.
123
+ // The summary table links here; the "Task Details" section renders every field.
124
+ function taskAnchor(id) {
125
+ const s = typeof id === 'string' ? id : String(id ?? '')
126
+ return 'task-' + s.replace(/[^A-Za-z0-9_-]/g, '-')
127
+ }
128
+
129
+ // Render a single command inside a fenced code block, byte-equivalent to `cmd`.
130
+ // The fence length is chosen to exceed the longest backtick run in the content so a
131
+ // command that itself contains backticks can never prematurely close the fence.
132
+ function fencedCommandBlock(cmd) {
133
+ const s = typeof cmd === 'string' ? cmd : String(cmd ?? '')
134
+ const runs = s.match(/`+/g) || []
135
+ const maxRun = runs.reduce((m, r) => Math.max(m, r.length), 0)
136
+ const fence = '`'.repeat(Math.max(3, maxRun + 1))
137
+ return `${fence}\n${s}\n${fence}`
138
+ }
139
+
108
140
  function buildCommitSubject(task) {
109
141
  if (!task.commit || typeof task.commit !== 'object') {
110
142
  return 'chore: implement task'
@@ -189,8 +221,15 @@ function renderMarkdown(wb, prefix, phaseDepsMap) {
189
221
  const est = mins !== null ? String(mins) : '—'
190
222
  const depArr = Array.isArray(task.dependsOn) && task.dependsOn.length > 0 ? task.dependsOn : null
191
223
  const deps = depArr ? depArr.join(', ') : '—'
192
- const verifArr = task.verificationCommands ?? task.verification?.commands ?? []
193
- const verif = verifArr.length > 0 ? sanitizeField(verifArr.join('; ')) : '—'
224
+ // The summary table is a digest only. Verification commands are NOT rendered
225
+ // in-table (sanitizeField would destroy their operators). The cell links to the
226
+ // authoritative per-task "Task Details" section, which carries every field
227
+ // (outcome, acceptance criteria, estimates, grouping rationale, commit, and the
228
+ // lossless verification commands). See renderTaskDetails / fencedCommandBlock.
229
+ const verifCmds = getVerificationCommands(task)
230
+ const verif = verifCmds.length > 0
231
+ ? `${verifCmds.length} cmd — [details](#${taskAnchor(task.id)})`
232
+ : `[details](#${taskAnchor(task.id)})`
194
233
  return `| ${id} | ${title} | ${outcome} | ${domain} | ${est} | ${deps} | ${verif} |`
195
234
  }
196
235
 
@@ -263,6 +302,66 @@ function renderMarkdown(wb, prefix, phaseDepsMap) {
263
302
  }
264
303
  }
265
304
 
305
+ // ── Task Details (AUTHORITATIVE) ──
306
+ // The summary tables above are a digest. This section is the authoritative,
307
+ // self-contained representation of every task: it renders each field integrally so
308
+ // the Work Breakdown Markdown (plus the dispatch CSV) is a complete deliverable —
309
+ // the intermediate JSON is not required by any downstream consumer.
310
+ //
311
+ // Free-text fields (title, outcome, grouping rationale, commit subject) are rendered
312
+ // OUTSIDE any table and are therefore preserved verbatim — including pipes, which
313
+ // sanitizeField would otherwise map to spaces to protect table/CSV columns.
314
+ // Verification commands are emitted as fenced code blocks that are byte-equivalent
315
+ // to the source; the fence length exceeds any backtick run in the command so a
316
+ // command containing backticks can never break out of its block.
317
+ const raw = v => (typeof v === 'string' ? v : String(v ?? ''))
318
+ const getTokens = task => {
319
+ const t = task.estimate?.tokens ?? task.tokens
320
+ return (t !== undefined && t !== null) ? String(t) : '—'
321
+ }
322
+ const listOrDash = arr =>
323
+ (Array.isArray(arr) && arr.length > 0) ? arr.join(', ') : '—'
324
+
325
+ L.push('## Task Details')
326
+ L.push('')
327
+ L.push('> Authoritative per-task detail. Every field is rendered integrally so this document, together with the dispatch CSV, is a complete deliverable requiring no separate JSON. Verification commands are preserved **verbatim** in fenced code blocks — operators such as `||`, shell pipes `|`, and regex alternations (`grep -E \'a|b|c\'`) survive byte-for-byte. Each command is an independent fenced block.')
328
+ L.push('')
329
+ for (const phase of wb.phases) {
330
+ for (const task of (Array.isArray(phase.tasks) ? phase.tasks : [])) {
331
+ const cmds = getVerificationCommands(task)
332
+ const mins = getAgentMinutes(task)
333
+ L.push(`<a id="${taskAnchor(task.id)}"></a>`)
334
+ L.push(`### ${raw(task.id)}`)
335
+ L.push('')
336
+ L.push(`- **Task ID:** ${raw(task.id)}`)
337
+ L.push(`- **Title:** ${raw(task.title)}`)
338
+ L.push(`- **Outcome:** ${raw(task.outcome)}`)
339
+ L.push(`- **Domain:** ${raw(task.domain)}`)
340
+ L.push(`- **Agent type:** ${raw(task.agentType)}`)
341
+ L.push(`- **Dependencies:** ${listOrDash(task.dependsOn)}`)
342
+ L.push(`- **Acceptance criteria:** ${listOrDash(task.acceptanceCriteria)}`)
343
+ L.push(`- **Estimate — agent minutes:** ${mins !== null ? String(mins) : '—'}`)
344
+ L.push(`- **Estimate — tokens:** ${getTokens(task)}`)
345
+ L.push(`- **Output count:** ${task.outputCount != null ? String(task.outputCount) : '—'}`)
346
+ L.push(`- **Grouping rationale:** ${task.groupingRationale != null ? raw(task.groupingRationale) : '—'}`)
347
+ L.push(`- **Commit type:** ${task.commit && task.commit.type != null ? raw(task.commit.type) : '—'}`)
348
+ L.push(`- **Commit scope:** ${task.commit && task.commit.scope != null ? raw(task.commit.scope) : '—'}`)
349
+ L.push(`- **Commit subject:** ${task.commit && task.commit.subject != null ? raw(task.commit.subject) : '—'}`)
350
+ L.push('')
351
+ L.push('**Verification commands:**')
352
+ L.push('')
353
+ if (cmds.length === 0) {
354
+ L.push('_No verification commands._')
355
+ L.push('')
356
+ } else {
357
+ for (const cmd of cmds) {
358
+ L.push(fencedCommandBlock(cmd))
359
+ L.push('')
360
+ }
361
+ }
362
+ }
363
+ }
364
+
266
365
  // ── Statistics ──
267
366
  L.push('## Statistics')
268
367
  L.push('')
@@ -59,6 +59,36 @@ const ERRORS = {
59
59
  EMPTY_PHASE: 'empty_phase', // check 23
60
60
  }
61
61
 
62
+ // ── Markdown table row splitter ─────────────────────────────────────────────
63
+ // Splits a Markdown table row into cells on unescaped pipe separators only.
64
+ // A backslash-escaped pipe (\|) is treated as a literal pipe *inside* the cell
65
+ // rather than a column separator and is unescaped in the returned cell, so a
66
+ // criterion such as `ai-toolkit ledger open\|close\|fail\|skip` stays in a
67
+ // single cell and does not shift the "Related UC" column.
68
+ // Returns the raw split including the leading/trailing empty cells produced by
69
+ // the row's outer pipes, matching the previous `line.split('|')` contract so
70
+ // callers can keep using `.slice(1, -1)`.
71
+ function splitTableRow(line) {
72
+ const cells = []
73
+ let current = ''
74
+ for (let i = 0; i < line.length; i++) {
75
+ const ch = line[i]
76
+ if (ch === '\\' && line[i + 1] === '|') {
77
+ current += '|' // unescape: keep the literal pipe inside this cell
78
+ i++ // consume the escaped '|'
79
+ continue
80
+ }
81
+ if (ch === '|') {
82
+ cells.push(current)
83
+ current = ''
84
+ continue
85
+ }
86
+ current += ch
87
+ }
88
+ cells.push(current)
89
+ return cells
90
+ }
91
+
62
92
  // ── AC table parser (prerequisite for checks 19–21) ─────────────────────────
63
93
 
64
94
  function parseAcTable(requirementsPath) {
@@ -122,7 +152,7 @@ function parseAcTable(requirementsPath) {
122
152
  }
123
153
 
124
154
  // ── Validate column headers ────────────────────────────────────────────────
125
- const headerCells = lines[tableHeaderIdx].split('|').slice(1, -1).map(c => c.trim())
155
+ const headerCells = splitTableRow(lines[tableHeaderIdx]).slice(1, -1).map(c => c.trim())
126
156
  if (headerCells.length < 3) {
127
157
  process.stderr.write('Error: AC table header has fewer than 3 columns\n')
128
158
  process.exit(2)
@@ -143,7 +173,7 @@ function parseAcTable(requirementsPath) {
143
173
  if (!line.trim().startsWith('|')) break
144
174
 
145
175
  // Skip separator rows: every cell is only dashes, colons, or spaces
146
- const rawCells = line.split('|').slice(1, -1)
176
+ const rawCells = splitTableRow(line).slice(1, -1)
147
177
  if (rawCells.every(c => /^[\s\-:]+$/.test(c))) continue
148
178
 
149
179
  const cells = rawCells.map(c => c.trim())
@@ -204,6 +204,8 @@ From the `<usage>` block of the pm-phase3 result, read:
204
204
 
205
205
  Read `{PREFIX}-Token-Estimate.md`. Append the orchestrator row and grand total:
206
206
 
207
+ > **Null-compatibility — actuals reading and cost calculation:** Treat a token value of `null`, `0`, or `not_available` as **data unavailable** — never as a real, observable zero consumption. Render unavailable values as `—` in the Actual tokens and Actual cost columns; exclude them from sums, averages, and grand totals. Preventing resume clobber and legacy misinterpretation requires that no unavailable measurement is coerced into a real zero.
208
+
207
209
  ```markdown
208
210
  | project-manager/pm-phase3 (orchestrator) | — | sonnet | 80,000 (estimated) | {subagent_tokens} (actual) | ±{delta} | {duration} |
209
211
 
@@ -213,14 +215,14 @@ Read `{PREFIX}-Token-Estimate.md`. Append the orchestrator row and grand total:
213
215
 
214
216
  | Agent | Task / Scope | Model | Est. tokens | Actual tokens | Delta | Est. cost ($) | Actual cost ($) | Duration |
215
217
  |-------|-------------|-------|------------|---------------|-------|--------------|----------------|----------|
216
- {one row per entry in token_ledger — from phase 1, 2, and 3}
218
+ {one row per entry in token_ledger — from phase 1, 2, and 3; for any entry where actual tokens is null, 0, or not_available show — in Actual tokens and Actual cost and exclude that row from totals}
217
219
  | project-manager/pm-phase3 (orchestrator) | — | sonnet | 80,000 | {subagent_tokens} | ±{delta} | $0.4320 | ${actual_cost} | {duration} |
218
220
 
219
221
  ## Estimation accuracy by agent type
220
222
 
221
223
  | Model | Count | Avg est. tokens | Avg actual tokens | Avg delta | Trend |
222
224
  |-------|-------|----------------|------------------|-----------|-------|
223
- {one row per model tier (haiku, sonnet) — exclude N/A rows from averages}
225
+ {one row per model tier (haiku, sonnet) — exclude rows where actual tokens is null, 0, or not_available from averages}
224
226
 
225
227
  ## Grand Total
226
228
 
@@ -9,38 +9,6 @@ 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
- // 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 })
18
- await agent(
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.`,
24
- { label: 'append-ledger', phase: 'Requirements', model: 'haiku' }
25
- )
26
- }
27
-
28
- async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
29
- const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
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__' })
32
- await agent(
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.`,
40
- { label: 'update-ledger', phase: 'Requirements', model: 'haiku' }
41
- )
42
- }
43
-
44
12
  // ── Parse args ────────────────────────────────────────────────────────────────
45
13
  // args is the raw prompt string: "<path-to-feature.md> [--force]"
46
14
  const featurePath = args.split(/\s+/)[0]
@@ -104,7 +72,7 @@ log(`needs_requirements=${discoveryResult.needs_requirements} needs_tech_spec=${
104
72
 
105
73
  // ── Ensure ledger file exists (US-02-T02) ─────────────────────────────────────
106
74
  // If define-feature was not used, the ledger file will not exist yet.
107
- // Touch it with an empty array so appendLedgerEntry can always assume a valid base.
75
+ // Touch it with an empty array so the ledger facade can always assume a valid base.
108
76
  const ledgerFilePath = `${feature_dir}/${prefix}-token-ledger.json`
109
77
  await agent(
110
78
  `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.`,
@@ -120,23 +88,17 @@ const errors = []
120
88
 
121
89
  if (discoveryResult.needs_requirements) {
122
90
  const reqKey = 'generate-requirements:phase1'
123
- await appendLedgerEntry(feature_dir, prefix, {
124
- agent: reqKey,
125
- phase: 'phase1',
126
- model: 'haiku',
127
- status: 'running',
128
- phase_delta_tokens: 0,
129
- started_at: '__TS__',
130
- completed_at: null,
131
- })
91
+ await agent(
92
+ `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${feature_dir} --prefix ${prefix} --agent ${reqKey} --phase phase1 --model haiku --attempt 1\n\nReturn no output.`,
93
+ { label: 'ledger-open-requirements', phase: 'Requirements', model: 'haiku' }
94
+ )
132
95
  const beforeReq = budget.spent()
133
96
  const reqResult = await agent(featurePath, { agentType: 'generate-requirements', label: 'generate-requirements', phase: 'Requirements' })
134
97
  const reqTokens = budget.spent() - beforeReq
135
- await updateLedgerEntry(feature_dir, prefix, reqKey, {
136
- status: 'done',
137
- completed_at: '__TS__',
138
- phase_delta_tokens: reqTokens,
139
- })
98
+ await agent(
99
+ `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${feature_dir} --prefix ${prefix} --agent ${reqKey} --tokens ${reqTokens} --attempt 1\n\nReturn no output.`,
100
+ { label: 'ledger-close-requirements', phase: 'Requirements', model: 'haiku' }
101
+ )
140
102
  tokenLedger.push({ agent: 'generate-requirements', model: 'haiku', phase_delta_tokens: reqTokens })
141
103
  log(`generate-requirements done — phase delta: ${reqTokens} tokens`)
142
104
  } else {
@@ -148,23 +110,17 @@ phase('Tech-Spec')
148
110
 
149
111
  if (discoveryResult.needs_tech_spec) {
150
112
  const specKey = 'generate-tech-spec:phase1'
151
- await appendLedgerEntry(feature_dir, prefix, {
152
- agent: specKey,
153
- phase: 'phase1',
154
- model: 'haiku',
155
- status: 'running',
156
- phase_delta_tokens: 0,
157
- started_at: '__TS__',
158
- completed_at: null,
159
- })
113
+ await agent(
114
+ `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${feature_dir} --prefix ${prefix} --agent ${specKey} --phase phase1 --model haiku --attempt 1\n\nReturn no output.`,
115
+ { label: 'ledger-open-tech-spec', phase: 'Tech-Spec', model: 'haiku' }
116
+ )
160
117
  const beforeSpec = budget.spent()
161
118
  const specResult = await agent(featurePath, { agentType: 'generate-tech-spec', label: 'generate-tech-spec', phase: 'Tech-Spec' })
162
119
  const specTokens = budget.spent() - beforeSpec
163
- await updateLedgerEntry(feature_dir, prefix, specKey, {
164
- status: 'done',
165
- completed_at: '__TS__',
166
- phase_delta_tokens: specTokens,
167
- })
120
+ await agent(
121
+ `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger close --dir ${feature_dir} --prefix ${prefix} --agent ${specKey} --tokens ${specTokens} --attempt 1\n\nReturn no output.`,
122
+ { label: 'ledger-close-tech-spec', phase: 'Tech-Spec', model: 'haiku' }
123
+ )
168
124
  tokenLedger.push({ agent: 'generate-tech-spec', model: 'haiku', phase_delta_tokens: specTokens })
169
125
  log(`generate-tech-spec done — phase delta: ${specTokens} tokens`)
170
126
  } else {
@@ -181,23 +137,17 @@ const MAX_CYCLES = 3
181
137
  for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
182
138
  log(`validate-feature-docs cycle ${cycle}`)
183
139
  const valKey = `validate-feature-docs:phase1:cycle${cycle}`
184
- await appendLedgerEntry(feature_dir, prefix, {
185
- agent: valKey,
186
- phase: 'phase1',
187
- model: 'haiku',
188
- status: 'running',
189
- phase_delta_tokens: 0,
190
- started_at: '__TS__',
191
- completed_at: null,
192
- })
140
+ await agent(
141
+ `Run this shell command via Bash. If the --dir path contains spaces, enclose it in double quotes.\n\nai-toolkit ledger open --dir ${feature_dir} --prefix ${prefix} --agent ${valKey} --phase phase1 --model haiku --attempt 1\n\nReturn no output.`,
142
+ { label: `ledger-open-validate-cycle-${cycle}`, phase: 'Validation', model: 'haiku' }
143
+ )
193
144
  const beforeVal = budget.spent()
194
145
  const valResult = await agent(featurePath, { agentType: 'validate-feature-docs', label: `validate-feature-docs (cycle ${cycle})`, phase: 'Validation' })
195
146
  const valTokens = budget.spent() - beforeVal
196
- await updateLedgerEntry(feature_dir, prefix, valKey, {
197
- status: 'done',
198
- completed_at: '__TS__',
199
- phase_delta_tokens: valTokens,
200
- })
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 ${feature_dir} --prefix ${prefix} --agent ${valKey} --tokens ${valTokens} --attempt 1\n\nReturn no output.`,
149
+ { label: `ledger-close-validate-cycle-${cycle}`, phase: 'Validation', model: 'haiku' }
150
+ )
201
151
  tokenLedger.push({ agent: `validate-feature-docs (cycle ${cycle})`, model: 'haiku', phase_delta_tokens: valTokens })
202
152
 
203
153
  // valResult is the agent's text output — check for gap indicators
@@ -7,42 +7,6 @@ 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
- // 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 })
20
- await agent(
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.`,
26
- { label: 'append-ledger', phase: 'Work Breakdown', model: 'haiku' }
27
- )
28
- }
29
-
30
- async function updateLedgerEntry(featureDir, prefix, agentKey, updates) {
31
- const ledgerPath = `${featureDir}/${prefix}-token-ledger.json`
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__' })
34
- await agent(
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.`,
42
- { label: 'update-ledger', phase: 'Work Breakdown', model: 'haiku' }
43
- )
44
- }
45
-
46
10
  // Output schemas for wb-validate and wb-render agent invocations (used by US-05)
47
11
  const WB_WRAPPER_SCHEMA = {
48
12
  type: 'object',
@@ -90,8 +54,21 @@ function normalizeError(err) {
90
54
  // args: "<path-to-feature.md>"
91
55
  const featurePath = (typeof args === 'string' ? args : '').trim().split(/\s+/)[0]
92
56
 
57
+ // Feature directory = parent of the feature.md path. Separator-agnostic: the workflow
58
+ // runtime provides no `path` module, so we cannot use path.dirname. Handle BOTH POSIX
59
+ // "/" and Windows "\": strip a trailing "<sep>feature.md" tail when present; if the
60
+ // input is already a directory (no feature.md tail), strip only a trailing separator.
61
+ // This replaces the previous POSIX-only regex (/\/[^/]+$/), which left "\feature.md"
62
+ // attached on Windows paths and produced malformed "…\feature.md/…json" paths that only
63
+ // "worked" because an LLM wrapper agent silently repaired them.
64
+ function deriveFeatureDir(featurePath) {
65
+ return /[/\\]feature\.md$/i.test(featurePath)
66
+ ? featurePath.replace(/[/\\]feature\.md$/i, '')
67
+ : featurePath.replace(/[/\\]+$/, '')
68
+ }
69
+
93
70
  // Derive feature directory and prefix early (needed for ledger writes before metrics agent runs)
94
- const featureDir = featurePath.replace(/\/[^/]+$/, '')
71
+ const featureDir = deriveFeatureDir(featurePath)
95
72
  const prefixMatch = featureDir.match(/([A-Z]+-\d+)/)
96
73
  const prefix = prefixMatch ? prefixMatch[1] : 'FTR-000'
97
74
 
@@ -101,15 +78,11 @@ phase('Work Breakdown')
101
78
  const tokenLedger = []
102
79
 
103
80
  log(`Running generate-work-breakdown for ${featurePath}`)
104
- await appendLedgerEntry(featureDir, prefix, {
105
- agent: 'generate-work-breakdown:phase2',
106
- phase: 'phase2',
107
- model: 'haiku',
108
- status: 'running',
109
- phase_delta_tokens: 0,
110
- started_at: '__TS__',
111
- completed_at: null,
112
- })
81
+ const wbKey = 'generate-work-breakdown:phase2'
82
+ await agent(
83
+ `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.`,
84
+ { label: 'ledger-open-generate-work-breakdown', phase: 'Work Breakdown', model: 'haiku' }
85
+ )
113
86
  const beforeWB = budget.spent()
114
87
  await agent(featurePath, {
115
88
  agentType: 'generate-work-breakdown',
@@ -117,11 +90,10 @@ await agent(featurePath, {
117
90
  phase: 'Work Breakdown',
118
91
  })
119
92
  const wbTokens = budget.spent() - beforeWB
120
- await updateLedgerEntry(featureDir, prefix, 'generate-work-breakdown:phase2', {
121
- status: 'done',
122
- completed_at: '__TS__',
123
- phase_delta_tokens: wbTokens,
124
- })
93
+ await agent(
94
+ `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.`,
95
+ { label: 'ledger-close-generate-work-breakdown', phase: 'Work Breakdown', model: 'haiku' }
96
+ )
125
97
  tokenLedger.push({ agent: 'generate-work-breakdown', model: 'haiku', phase_delta_tokens: wbTokens })
126
98
  log(`generate-work-breakdown done — phase delta: ${wbTokens} tokens`)
127
99
 
@@ -129,11 +101,11 @@ log(`generate-work-breakdown done — phase delta: ${wbTokens} tokens`)
129
101
  let wbValidatorReport = null
130
102
  let validateFailed = false
131
103
  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
- })
104
+ const wbValidateKey = 'wb-validate:phase2'
105
+ await agent(
106
+ `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.`,
107
+ { label: 'ledger-open-wb-validate', phase: 'Work Breakdown', model: 'haiku' }
108
+ )
137
109
  const beforeValidate = budget.spent()
138
110
  try {
139
111
  const validateWrapper = await agent(
@@ -173,20 +145,14 @@ try {
173
145
  if (validateWrapper.exitCode === 1 && wbValidatorReport.valid) {
174
146
  throw new Error('Inconsistent wb-validate result: exit 1 with valid=true')
175
147
  }
176
- await updateLedgerEntry(featureDir, prefix, 'wb-validate:phase2', {
177
- status: 'done', completed_at: '__TS__',
178
- phase_delta_tokens: budget.spent() - beforeValidate,
179
- })
180
148
  } catch (err) {
181
149
  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
150
  }
189
151
  const validateTokens = budget.spent() - beforeValidate
152
+ await agent(
153
+ `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.`,
154
+ { label: 'ledger-close-wb-validate', phase: 'Work Breakdown', model: 'haiku' }
155
+ )
190
156
  const wbValidatorPassed = !validateFailed && wbValidatorReport !== null
191
157
  && wbValidatorReport.valid === true && wbValidatorReport.errors.length === 0
192
158
  log(`wb-validate: ${wbValidatorPassed ? 'passed' : 'failed/errors'} — ${validateTokens} tokens`)
@@ -195,18 +161,14 @@ log(`wb-validate: ${wbValidatorPassed ? 'passed' : 'failed/errors'} — ${valida
195
161
  let semanticResult = null
196
162
  let semanticFailed = false
197
163
  let semanticTokens = 0
164
+ const semanticKey = 'validate-work-breakdown-semantic:phase2'
198
165
  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
- })
166
+ log('validate-work-breakdown-semantic: skipped (wb-validate did not pass)')
204
167
  } 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
- })
168
+ await agent(
169
+ `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.`,
170
+ { label: 'ledger-open-semantic-validator', phase: 'Work Breakdown', model: 'haiku' }
171
+ )
210
172
  const beforeSemantic = budget.spent()
211
173
  try {
212
174
  semanticResult = await agent(
@@ -218,18 +180,14 @@ if (!wbValidatorPassed) {
218
180
  schema: WB_SEMANTIC_SCHEMA,
219
181
  }
220
182
  )
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
183
  } catch (err) {
226
184
  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
185
  }
186
+ semanticTokens = budget.spent() - beforeSemantic
187
+ await agent(
188
+ `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.`,
189
+ { label: 'ledger-close-semantic-validator', phase: 'Work Breakdown', model: 'haiku' }
190
+ )
233
191
  }
234
192
  log(`semantic validator: ${semanticFailed ? 'failed' : semanticResult ? 'done' : 'skipped'}`)
235
193
 
@@ -237,18 +195,14 @@ log(`semantic validator: ${semanticFailed ? 'failed' : semanticResult ? 'done' :
237
195
  const canRender = wbValidatorPassed && !semanticFailed
238
196
  let renderFailed = false
239
197
  let renderResult = null
198
+ const wbRenderKey = 'wb-render:phase2'
240
199
  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
- })
200
+ log('wb-render: skipped (wb-validate or semantic validator did not pass)')
246
201
  } 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
- })
202
+ await agent(
203
+ `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.`,
204
+ { label: 'ledger-open-wb-render', phase: 'Work Breakdown', model: 'haiku' }
205
+ )
252
206
  const beforeRender = budget.spent()
253
207
  try {
254
208
  renderResult = await agent(
@@ -291,20 +245,15 @@ if (!canRender) {
291
245
  { _exitCode: renderResult.exitCode }
292
246
  )
293
247
  }
294
- await updateLedgerEntry(featureDir, prefix, 'wb-render:phase2', {
295
- status: 'done', completed_at: '__TS__',
296
- phase_delta_tokens: budget.spent() - beforeRender,
297
- })
298
248
  } catch (err) {
299
249
  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
250
  }
307
- tokenLedger.push({ agent: 'wb-render', model: 'haiku', phase_delta_tokens: budget.spent() - beforeRender })
251
+ const renderTokens = budget.spent() - beforeRender
252
+ await agent(
253
+ `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.`,
254
+ { label: 'ledger-close-wb-render', phase: 'Work Breakdown', model: 'haiku' }
255
+ )
256
+ tokenLedger.push({ agent: 'wb-render', model: 'haiku', phase_delta_tokens: renderTokens })
308
257
  }
309
258
 
310
259
  tokenLedger.push({ agent: 'wb-validate', model: 'haiku', phase_delta_tokens: validateTokens })