@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.
- package/CHANGELOG.md +37 -0
- package/bin/cli.js +279 -63
- package/lib/execution-ledger.js +515 -0
- package/package.json +1 -1
- package/src/claude/agents/define-feature.md +36 -60
- package/src/claude/scripts/wb-render.js +101 -2
- package/src/claude/scripts/wb-validate.js +32 -2
- package/src/claude/skills/implement-feature/SKILL.md +4 -2
- package/src/claude/workflows/pm-phase1.js +25 -75
- package/src/claude/workflows/pm-phase2.js +55 -106
- package/src/claude/workflows/pm-phase3.js +162 -75
|
@@ -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
|
-
|
|
193
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
124
|
-
agent
|
|
125
|
-
phase: '
|
|
126
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
152
|
-
agent
|
|
153
|
-
phase: '
|
|
154
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
185
|
-
agent
|
|
186
|
-
phase: '
|
|
187
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
phase
|
|
107
|
-
model: 'haiku'
|
|
108
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
phase
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
agent
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
agent
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 })
|