@dtlabs/ai-toolkit 0.9.0 → 0.10.1

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.
@@ -0,0 +1,309 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // ── Argument parsing ──────────────────────────────────────────────────────────
7
+
8
+ const jsonPath = process.argv[2]
9
+ const prefix = process.argv[3]
10
+
11
+ if (!jsonPath || !prefix) {
12
+ process.stderr.write('Usage: node wb-render.js <path-to-work-breakdown.json> <feature-prefix> [destination-dir]\n')
13
+ process.exit(1)
14
+ }
15
+
16
+ const destDir = process.argv[4]
17
+ ? process.argv[4]
18
+ : path.dirname(path.resolve(jsonPath))
19
+
20
+ // ── Read and parse JSON ───────────────────────────────────────────────────────
21
+
22
+ let raw
23
+ try {
24
+ raw = fs.readFileSync(jsonPath, 'utf8')
25
+ } catch (err) {
26
+ process.stderr.write(`Error: cannot read file "${jsonPath}": ${err.message}\n`)
27
+ process.exit(1)
28
+ }
29
+
30
+ let wb
31
+ try {
32
+ wb = JSON.parse(raw)
33
+ } catch (err) {
34
+ process.stderr.write(`Error: "${jsonPath}" is not valid JSON: ${err.message}\n`)
35
+ process.exit(1)
36
+ }
37
+
38
+ // ── Basic structure check ─────────────────────────────────────────────────────
39
+
40
+ if (wb.schemaVersion !== 2) {
41
+ process.stderr.write(`Error: schemaVersion must be 2, got ${JSON.stringify(wb.schemaVersion)}\n`)
42
+ process.exit(2)
43
+ }
44
+
45
+ if (!Array.isArray(wb.phases)) {
46
+ process.stderr.write('Error: "phases" must be an array\n')
47
+ process.exit(2)
48
+ }
49
+
50
+ // ── Output file paths ─────────────────────────────────────────────────────────
51
+
52
+ const mdPath = path.join(destDir, `${prefix}-Work-Breakdown.md`)
53
+ const csvPath = path.join(destDir, `${prefix}-Work-Breakdown.csv`)
54
+
55
+ // ── Phase-level depends_on aggregation ───────────────────────────────────────
56
+
57
+ function computePhaseDependsOn(wb) {
58
+ // Build taskId → phaseId index
59
+ const taskToPhase = new Map()
60
+ for (const phase of wb.phases) {
61
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
62
+ for (const task of tasks) {
63
+ if (task.id != null) taskToPhase.set(task.id, phase.id)
64
+ }
65
+ }
66
+
67
+ // For each phase, compute external phase dependencies
68
+ const result = new Map()
69
+ for (const phase of wb.phases) {
70
+ if (phase.id == null) continue
71
+ const extPhases = new Set()
72
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
73
+ for (const task of tasks) {
74
+ if (!Array.isArray(task.dependsOn)) continue
75
+ for (const depId of task.dependsOn) {
76
+ const ownerPhase = taskToPhase.get(depId)
77
+ if (ownerPhase == null) continue // unresolved — skip
78
+ if (ownerPhase === phase.id) continue // intra-phase — remove
79
+ extPhases.add(ownerPhase)
80
+ }
81
+ }
82
+ result.set(phase.id, [...extPhases].sort().join(' '))
83
+ }
84
+ return result
85
+ }
86
+
87
+ // ── Compute phase dependencies ────────────────────────────────────────────────
88
+
89
+ let phaseDepsMap
90
+ try {
91
+ phaseDepsMap = computePhaseDependsOn(wb)
92
+ } catch (err) {
93
+ process.stderr.write(`Error: phase dependency aggregation failed: ${err.message}\n`)
94
+ process.exit(3)
95
+ }
96
+
97
+ // ── Field helpers ─────────────────────────────────────────────────────────────
98
+
99
+ function sanitizeField(str) {
100
+ const s = typeof str === 'string' ? str : String(str ?? '')
101
+ return s
102
+ .replace(/\|/g, ' ')
103
+ .replace(/\r/g, '')
104
+ .replace(/\n/g, ' ')
105
+ .trim()
106
+ }
107
+
108
+ function buildCommitSubject(task) {
109
+ if (!task.commit || typeof task.commit !== 'object') {
110
+ return 'chore: implement task'
111
+ }
112
+ const type = sanitizeField(task.commit.type ?? '')
113
+ const scope = sanitizeField(task.commit.scope ?? '')
114
+ const subject = sanitizeField(task.commit.subject ?? '')
115
+ const full = `${type}(${scope}): ${subject}`
116
+ return full.length > 72 ? full.slice(0, 72) + '…' : full
117
+ }
118
+
119
+ // ── Render stubs (implemented in T03–T05) ─────────────────────────────────────
120
+
121
+ function renderMarkdown(wb, prefix, phaseDepsMap) {
122
+ const DOMAINS = ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST']
123
+
124
+ function getAgentMinutes(task) {
125
+ const m = task.agentMinutes ?? task.estimate?.agentMinutes
126
+ return (m !== undefined && m !== null) ? Number(m) : null
127
+ }
128
+
129
+ function getDurationBand(mins) {
130
+ if (mins === null) return null
131
+ if (mins <= 15) return 'target'
132
+ if (mins <= 20) return 'above'
133
+ if (mins <= 30) return 'warning'
134
+ return 'split'
135
+ }
136
+
137
+ const allTasks = wb.phases.flatMap(p => Array.isArray(p.tasks) ? p.tasks : [])
138
+
139
+ // Summary duration totals — across all tasks
140
+ let summaryTarget = 0, summaryAbove = 0, summaryWarning = 0, summarySplit = 0
141
+ for (const task of allTasks) {
142
+ const band = getDurationBand(getAgentMinutes(task))
143
+ if (band === 'target') summaryTarget++
144
+ else if (band === 'above') summaryAbove++
145
+ else if (band === 'warning') summaryWarning++
146
+ else if (band === 'split') summarySplit++
147
+ }
148
+
149
+ // Per-domain stats — for Statistics table and domain distribution string
150
+ const domainStats = {}
151
+ for (const d of DOMAINS) domainStats[d] = { count: 0, target: 0, above: 0, warning: 0, split: 0 }
152
+ for (const task of allTasks) {
153
+ const d = task.domain
154
+ if (!d || !DOMAINS.includes(d)) continue
155
+ const band = getDurationBand(getAgentMinutes(task))
156
+ domainStats[d].count++
157
+ if (band) domainStats[d][band]++
158
+ }
159
+
160
+ const domainDistStr = DOMAINS.map(d => `${d}: ${domainStats[d].count}`).join(', ')
161
+
162
+ const statTotals = DOMAINS.reduce(
163
+ (acc, d) => {
164
+ acc.count += domainStats[d].count
165
+ acc.target += domainStats[d].target
166
+ acc.above += domainStats[d].above
167
+ acc.warning += domainStats[d].warning
168
+ acc.split += domainStats[d].split
169
+ return acc
170
+ },
171
+ { count: 0, target: 0, above: 0, warning: 0, split: 0 }
172
+ )
173
+
174
+ function phaseCommit(phase) {
175
+ if (!phase.commit || typeof phase.commit !== 'object') {
176
+ return `chore(${sanitizeField(prefix)}): implement phase`
177
+ }
178
+ const type = sanitizeField(phase.commit.type ?? 'chore')
179
+ const subject = sanitizeField(phase.commit.subject ?? '')
180
+ return `${type}(${sanitizeField(prefix)}): ${subject}`
181
+ }
182
+
183
+ function taskRow(task) {
184
+ const id = sanitizeField(task.id ?? '—')
185
+ const title = sanitizeField(task.title ?? '')
186
+ const outcome = sanitizeField(task.outcome ?? '')
187
+ const domain = sanitizeField(task.domain ?? '—')
188
+ const mins = getAgentMinutes(task)
189
+ const est = mins !== null ? String(mins) : '—'
190
+ const depArr = Array.isArray(task.dependsOn) && task.dependsOn.length > 0 ? task.dependsOn : null
191
+ const deps = depArr ? depArr.join(', ') : '—'
192
+ const verifArr = task.verificationCommands ?? task.verification?.commands ?? []
193
+ const verif = verifArr.length > 0 ? sanitizeField(verifArr.join('; ')) : '—'
194
+ return `| ${id} | ${title} | ${outcome} | ${domain} | ${est} | ${deps} | ${verif} |`
195
+ }
196
+
197
+ const title = wb.title ?? wb.feature ?? prefix
198
+ const generated = new Date().toISOString()
199
+ const L = []
200
+
201
+ // ── Title ──
202
+ L.push(`# Work Breakdown — ${title}`)
203
+ L.push('')
204
+
205
+ // ── Document Info ──
206
+ L.push('## Document Info')
207
+ L.push('| Field | Value |')
208
+ L.push('|-------|-------|')
209
+ L.push(`| Feature | ${sanitizeField(prefix)} |`)
210
+ L.push('| Schema | v2 |')
211
+ L.push(`| Generated | ${generated} |`)
212
+ L.push('')
213
+
214
+ // ── Summary ──
215
+ L.push('## Summary')
216
+ L.push('| Metric | Value |')
217
+ L.push('|--------|-------|')
218
+ L.push(`| Total tasks | ${allTasks.length} |`)
219
+ L.push(`| Total phases | ${wb.phases.length} |`)
220
+ L.push(`| Within target (≤15 min) | ${summaryTarget} |`)
221
+ L.push(`| Above target (16–20 min) | ${summaryAbove} |`)
222
+ L.push(`| Warning (21–30 min) | ${summaryWarning} |`)
223
+ L.push(`| Split required (>30 min) | ${summarySplit} |`)
224
+ L.push(`| Domain distribution | ${domainDistStr} |`)
225
+ L.push('')
226
+
227
+ // ── Infrastructure Phase ──
228
+ const infraPhases = wb.phases.filter(p => p.id === 'INFRA')
229
+ const userStoryPhases = wb.phases.filter(p => p.id !== 'INFRA')
230
+
231
+ for (const phase of infraPhases) {
232
+ L.push('## Infrastructure Phase (INFRA)')
233
+ L.push('')
234
+ L.push('### Commit')
235
+ L.push(phaseCommit(phase))
236
+ L.push('')
237
+ L.push('### Tasks')
238
+ L.push('| ID | Title | Outcome | Domain | Est. (min) | Dependencies | Verification |')
239
+ L.push('|---|---|---|---|---|---|---|')
240
+ for (const task of (Array.isArray(phase.tasks) ? phase.tasks : [])) {
241
+ L.push(taskRow(task))
242
+ }
243
+ L.push('')
244
+ }
245
+
246
+ // ── User Story Phases ──
247
+ if (userStoryPhases.length > 0) {
248
+ L.push('## User Story Phases')
249
+ L.push('')
250
+ for (const phase of userStoryPhases) {
251
+ L.push(`### ${sanitizeField(phase.id ?? '')}: ${sanitizeField(phase.title ?? '')}`)
252
+ L.push('')
253
+ L.push('### Commit')
254
+ L.push(phaseCommit(phase))
255
+ L.push('')
256
+ L.push('### Tasks')
257
+ L.push('| ID | Title | Outcome | Domain | Est. (min) | Dependencies | Verification |')
258
+ L.push('|---|---|---|---|---|---|---|')
259
+ for (const task of (Array.isArray(phase.tasks) ? phase.tasks : [])) {
260
+ L.push(taskRow(task))
261
+ }
262
+ L.push('')
263
+ }
264
+ }
265
+
266
+ // ── Statistics ──
267
+ L.push('## Statistics')
268
+ L.push('')
269
+ L.push('| Domain | Count | Target | Above | Warning | Split |')
270
+ L.push('|--------|-------|--------|-------|---------|-------|')
271
+ for (const d of DOMAINS) {
272
+ const s = domainStats[d]
273
+ L.push(`| ${d} | ${s.count} | ${s.target} | ${s.above} | ${s.warning} | ${s.split} |`)
274
+ }
275
+ L.push(`| **Total** | **${statTotals.count}** | **${statTotals.target}** | **${statTotals.above}** | **${statTotals.warning}** | **${statTotals.split}** |`)
276
+ L.push('')
277
+
278
+ return L.join('\n')
279
+ }
280
+
281
+ function renderCsv(wb, prefix, phaseDepsMap) {
282
+ const rows = ['phase_id|phase_title|commit_message|depends_on|task_id|task_title|domain|agent_type']
283
+ for (const phase of wb.phases) {
284
+ const phaseId = sanitizeField(phase.id ?? '')
285
+ const phaseTitle = sanitizeField(phase.title ?? '')
286
+ const commitMsg = phase.commit && typeof phase.commit === 'object'
287
+ ? sanitizeField(phase.commit.type ?? 'chore') + '(' + sanitizeField(prefix) + '): ' + sanitizeField(phase.commit.subject ?? '')
288
+ : `chore(${sanitizeField(prefix)}): implement phase`
289
+ const dependsOn = sanitizeField(phaseDepsMap.get(phase.id) ?? '')
290
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
291
+ for (const task of tasks) {
292
+ const taskId = sanitizeField(task.id ?? '')
293
+ const taskTitle = sanitizeField(task.title ?? '')
294
+ const domain = sanitizeField(task.domain ?? '')
295
+ const agentType = sanitizeField(task.agentType ?? '')
296
+ rows.push(`${phaseId}|${phaseTitle}|${commitMsg}|${dependsOn}|${taskId}|${taskTitle}|${domain}|${agentType}`)
297
+ }
298
+ }
299
+ return rows.join('\n') + '\n'
300
+ }
301
+
302
+ // ── Write files and exit ──────────────────────────────────────────────────────
303
+
304
+ const md = renderMarkdown(wb, prefix, phaseDepsMap)
305
+ const csv = renderCsv(wb, prefix, phaseDepsMap)
306
+ fs.writeFileSync(mdPath, md, 'utf8')
307
+ fs.writeFileSync(csvPath, csv, 'utf8')
308
+ process.stdout.write(JSON.stringify({ exitCode: 0, markdownPath: mdPath, csvPath }) + '\n')
309
+ process.exit(0)