@tekyzinc/gsd-t 3.16.12 → 3.18.12

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +13 -3
  3. package/bin/gsd-t-depgraph-validate.cjs +140 -0
  4. package/bin/gsd-t-economics.cjs +287 -0
  5. package/bin/gsd-t-file-disjointness.cjs +227 -0
  6. package/bin/gsd-t-in-session-usage.cjs +213 -0
  7. package/bin/gsd-t-orchestrator-config.cjs +100 -3
  8. package/bin/gsd-t-orchestrator.js +2 -1
  9. package/bin/gsd-t-parallel.cjs +382 -0
  10. package/bin/gsd-t-report-tokens.cjs +549 -0
  11. package/bin/gsd-t-task-graph.cjs +366 -0
  12. package/bin/gsd-t-token-capture.cjs +29 -14
  13. package/bin/gsd-t-token-dashboard.cjs +35 -0
  14. package/bin/gsd-t-tool-attribution.cjs +377 -0
  15. package/bin/gsd-t-tool-cost.cjs +195 -0
  16. package/bin/gsd-t-unattended-platform.cjs +7 -1
  17. package/bin/gsd-t-unattended.cjs +2 -0
  18. package/bin/gsd-t.js +155 -5
  19. package/bin/headless-auto-spawn.cjs +69 -49
  20. package/bin/headless-auto-spawn.js +18 -24
  21. package/bin/runway-estimator.cjs +212 -0
  22. package/bin/spawn-plan-derive.cjs +163 -0
  23. package/bin/spawn-plan-status-updater.cjs +292 -0
  24. package/bin/spawn-plan-writer.cjs +204 -0
  25. package/commands/gsd-t-debug.md +26 -7
  26. package/commands/gsd-t-execute.md +36 -28
  27. package/commands/gsd-t-help.md +11 -0
  28. package/commands/gsd-t-integrate.md +27 -7
  29. package/commands/gsd-t-quick.md +30 -13
  30. package/commands/gsd-t-scan.md +5 -5
  31. package/commands/gsd-t-unattended-watch.md +4 -3
  32. package/commands/gsd-t-unattended.md +9 -3
  33. package/commands/gsd-t-verify.md +5 -5
  34. package/commands/gsd-t-wave.md +21 -8
  35. package/commands/gsd.md +45 -3
  36. package/docs/GSD-T-README.md +43 -5
  37. package/docs/architecture.md +423 -3
  38. package/docs/requirements.md +203 -0
  39. package/package.json +1 -1
  40. package/scripts/gsd-t-calibration-hook.js +256 -0
  41. package/scripts/gsd-t-compact-detector.js +223 -0
  42. package/scripts/gsd-t-compaction-scanner.js +305 -0
  43. package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
  44. package/scripts/gsd-t-dashboard-server.js +179 -0
  45. package/scripts/gsd-t-dashboard.html +3 -3
  46. package/scripts/gsd-t-heartbeat.js +50 -2
  47. package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
  48. package/scripts/gsd-t-transcript.html +546 -43
  49. package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
  50. package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
  51. package/templates/CLAUDE-global.md +8 -3
  52. package/templates/CLAUDE-project.md +17 -14
  53. package/templates/hooks/post-commit-spawn-plan.sh +85 -0
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-task-graph — M44 D1
5
+ *
6
+ * Parses `.gsd-t/domains/* /tasks.md` (and `scope.md` for fallback touch-lists)
7
+ * into an in-memory DAG that downstream M44 domains consume:
8
+ * - D2 `gsd-t parallel` CLI
9
+ * - D4 dep-graph validation (veto on unmet deps)
10
+ * - D5 file-disjointness prover (touch-list overlap check)
11
+ * - D6 pre-spawn economics (per-task cost estimate)
12
+ *
13
+ * Contract: .gsd-t/contracts/task-graph-contract.md (v1.0.0)
14
+ *
15
+ * Hard rules (from constraints.md):
16
+ * - Zero external runtime deps (Node built-ins only)
17
+ * - Cycle detection MANDATORY → throws TaskGraphCycleError with cycle path
18
+ * - Read-only: never writes to tasks.md / scope.md
19
+ * - Synchronous; main build path < 200ms for 100-domain/1000-task project
20
+ * - Mode-agnostic: knows nothing about in-session vs unattended
21
+ */
22
+
23
+ const fs = require("node:fs");
24
+ const path = require("node:path");
25
+
26
+ // ─── Custom error ─────────────────────────────────────────────────────────
27
+
28
+ class TaskGraphCycleError extends Error {
29
+ constructor(cycle) {
30
+ super(`Task graph cycle detected: ${Array.isArray(cycle) ? cycle.join(" → ") : "(unknown)"}`);
31
+ this.name = "TaskGraphCycleError";
32
+ this.cycle = Array.isArray(cycle) ? cycle.slice() : [];
33
+ }
34
+ }
35
+
36
+ // ─── Status marker map ────────────────────────────────────────────────────
37
+
38
+ const STATUS_MAP = {
39
+ " ": "pending",
40
+ "x": "done",
41
+ "X": "done",
42
+ "-": "skipped",
43
+ "!": "failed",
44
+ };
45
+
46
+ // ─── tasks.md parser ──────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Parse a single tasks.md file into an array of partial task records.
50
+ * Returns: { tasks: TaskNode[], warnings: string[] }
51
+ */
52
+ function parseTasksMd(absPath, domainName) {
53
+ let src;
54
+ try {
55
+ src = fs.readFileSync(absPath, "utf8");
56
+ } catch {
57
+ return { tasks: [], warnings: [`tasks.md unreadable: ${absPath}`] };
58
+ }
59
+ const lines = src.split(/\r?\n/);
60
+ const tasks = [];
61
+ const warnings = [];
62
+ let currentWave = 0;
63
+ let cur = null;
64
+
65
+ const flush = () => {
66
+ if (cur) {
67
+ tasks.push(cur);
68
+ cur = null;
69
+ }
70
+ };
71
+
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const line = lines[i];
74
+
75
+ // Wave heading: "## Wave N — …" (also tolerates "## Wave N -" / "## Wave N:")
76
+ const waveMatch = line.match(/^##\s+Wave\s+(\d+)\b/i);
77
+ if (waveMatch) {
78
+ currentWave = parseInt(waveMatch[1], 10);
79
+ continue;
80
+ }
81
+
82
+ // Task heading: "### M44-D1-T1 — Title" (em-dash, en-dash, hyphen all OK)
83
+ const taskMatch = line.match(/^###\s+([A-Z]\d+-D\d+-T\d+)\s*[—–\-]?\s*(.*)$/);
84
+ if (taskMatch) {
85
+ flush();
86
+ cur = {
87
+ id: taskMatch[1],
88
+ domain: domainName,
89
+ wave: currentWave,
90
+ title: (taskMatch[2] || "").trim(),
91
+ status: "pending",
92
+ deps: [],
93
+ touches: null, // null = unset (will fall back to scope.md); [] = explicit empty
94
+ statusWarning: null,
95
+ };
96
+ continue;
97
+ }
98
+
99
+ if (!cur) continue;
100
+
101
+ // Field lines look like: "- **Status**: [ ] pending"
102
+ const fieldMatch = line.match(/^\s*-\s+\*\*([A-Za-z][\w\s]*?)\*\*\s*:\s*(.*)$/);
103
+ if (!fieldMatch) continue;
104
+ const key = fieldMatch[1].trim().toLowerCase();
105
+ const val = fieldMatch[2].trim();
106
+
107
+ if (key === "status") {
108
+ const m = val.match(/\[(.)\]/);
109
+ if (m) {
110
+ const marker = m[1];
111
+ if (STATUS_MAP[marker]) {
112
+ cur.status = STATUS_MAP[marker];
113
+ } else {
114
+ cur.status = "pending";
115
+ cur.statusWarning = `unknown status marker '[${marker}]' on ${cur.id} — treating as pending`;
116
+ }
117
+ }
118
+ } else if (key === "dependencies" || key === "deps") {
119
+ cur.deps = parseDepList(val);
120
+ } else if (key === "touches" || key === "files touched" || key === "touched") {
121
+ cur.touches = parseFileList(val);
122
+ }
123
+ }
124
+ flush();
125
+
126
+ for (const t of tasks) {
127
+ if (t.statusWarning) warnings.push(t.statusWarning);
128
+ delete t.statusWarning;
129
+ }
130
+ return { tasks, warnings };
131
+ }
132
+
133
+ /**
134
+ * Parse a dep list like "M44-D1-T2, M44-D7-T1" or "none".
135
+ * Strips parenthetical comments: "M44-D1-T5 (D1 complete)" → "M44-D1-T5".
136
+ */
137
+ function parseDepList(raw) {
138
+ if (!raw || /^none$/i.test(raw.trim())) return [];
139
+ return raw
140
+ .split(",")
141
+ .map((s) => s.trim())
142
+ .map((s) => s.replace(/\s*\(.*?\)\s*$/, "")) // drop "(D1 complete)" trailers
143
+ .map((s) => {
144
+ // Extract first token that looks like a task id
145
+ const m = s.match(/[A-Z]\d+-D\d+-T\d+/);
146
+ return m ? m[0] : s;
147
+ })
148
+ .filter((s) => /^[A-Z]\d+-D\d+-T\d+$/.test(s));
149
+ }
150
+
151
+ /**
152
+ * Parse a comma-separated file list. Strips backticks and parentheticals.
153
+ */
154
+ function parseFileList(raw) {
155
+ if (!raw) return [];
156
+ return raw
157
+ .split(",")
158
+ .map((s) => s.trim())
159
+ .map((s) => s.replace(/^`|`$/g, ""))
160
+ .map((s) => s.replace(/\s*\(.*?\)\s*$/, "")) // drop "(new)" trailers
161
+ .map((s) => s.replace(/^`|`$/g, ""))
162
+ .filter(Boolean);
163
+ }
164
+
165
+ // ─── scope.md fallback parser (Files Owned section) ──────────────────────
166
+
167
+ /**
168
+ * Parse the "## Files Owned" section of a domain's scope.md and return the
169
+ * list of paths mentioned in bullet entries. Each bullet is normally:
170
+ * - `path/to/file.cjs` — description
171
+ * but the parser is lenient: any backticked path or bare path-looking token
172
+ * at the start of a `-` bullet counts.
173
+ */
174
+ function parseScopeFilesOwned(absPath) {
175
+ let src;
176
+ try {
177
+ src = fs.readFileSync(absPath, "utf8");
178
+ } catch {
179
+ return [];
180
+ }
181
+ const lines = src.split(/\r?\n/);
182
+ const out = [];
183
+ let inSection = false;
184
+ for (const line of lines) {
185
+ if (/^##\s+Files\s+Owned\b/i.test(line)) {
186
+ inSection = true;
187
+ continue;
188
+ }
189
+ if (inSection && /^##\s+/.test(line)) break; // next H2 ends the section
190
+ if (!inSection) continue;
191
+ const bullet = line.match(/^\s*-\s+(.*)$/);
192
+ if (!bullet) continue;
193
+ const text = bullet[1].trim();
194
+ // Prefer backticked path
195
+ const back = text.match(/`([^`\s]+)`/);
196
+ if (back) {
197
+ out.push(back[1]);
198
+ continue;
199
+ }
200
+ // Fallback: first whitespace-delimited token that contains a slash or dot
201
+ const tok = text.split(/\s+/)[0];
202
+ if (tok && (tok.includes("/") || tok.includes("."))) {
203
+ out.push(tok);
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // ─── Cycle detection (iterative DFS — three-color) ───────────────────────
210
+
211
+ function detectCycle(byId) {
212
+ const WHITE = 0, GRAY = 1, BLACK = 2;
213
+ const color = new Map();
214
+ const parent = new Map();
215
+ for (const id of Object.keys(byId)) color.set(id, WHITE);
216
+
217
+ const ids = Object.keys(byId).sort(); // deterministic
218
+ for (const start of ids) {
219
+ if (color.get(start) !== WHITE) continue;
220
+ // iterative DFS using an explicit stack of {id, depIdx}
221
+ const stack = [{ id: start, depIdx: 0 }];
222
+ color.set(start, GRAY);
223
+ parent.set(start, null);
224
+ while (stack.length) {
225
+ const top = stack[stack.length - 1];
226
+ const node = byId[top.id];
227
+ const deps = node ? node.deps : [];
228
+ if (top.depIdx >= deps.length) {
229
+ color.set(top.id, BLACK);
230
+ stack.pop();
231
+ continue;
232
+ }
233
+ const next = deps[top.depIdx++];
234
+ if (!byId[next]) {
235
+ // unknown dep — not a cycle, skip (D4 reports unmet)
236
+ continue;
237
+ }
238
+ const c = color.get(next);
239
+ if (c === WHITE) {
240
+ color.set(next, GRAY);
241
+ parent.set(next, top.id);
242
+ stack.push({ id: next, depIdx: 0 });
243
+ } else if (c === GRAY) {
244
+ // back-edge → cycle. Reconstruct path from `next` up via parent chain.
245
+ const cyc = [next];
246
+ let p = top.id;
247
+ while (p && p !== next) {
248
+ cyc.push(p);
249
+ p = parent.get(p);
250
+ }
251
+ cyc.push(next); // close the loop visually
252
+ cyc.reverse();
253
+ throw new TaskGraphCycleError(cyc);
254
+ }
255
+ // BLACK → already fully explored, skip
256
+ }
257
+ }
258
+ }
259
+
260
+ // ─── Public API ───────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Build the task graph from .gsd-t/domains/<domain>/tasks.md (+ scope.md
264
+ * fallback for touches). Synchronous. Throws TaskGraphCycleError on cycle.
265
+ *
266
+ * @param {{projectDir: string}} opts
267
+ * @returns {{nodes: object[], edges: object[], ready: string[],
268
+ * byId: Object<string, object>, warnings: string[]}}
269
+ */
270
+ function buildTaskGraph(opts) {
271
+ const projectDir = (opts && opts.projectDir) || process.cwd();
272
+ const domainsRoot = path.join(projectDir, ".gsd-t", "domains");
273
+ const warnings = [];
274
+ const nodes = [];
275
+ const edges = [];
276
+ const byId = Object.create(null);
277
+
278
+ let domainDirs = [];
279
+ try {
280
+ domainDirs = fs.readdirSync(domainsRoot, { withFileTypes: true })
281
+ .filter((d) => d.isDirectory())
282
+ .map((d) => d.name)
283
+ .sort();
284
+ } catch {
285
+ return { nodes, edges, ready: [], byId, warnings: [`domains dir missing: ${domainsRoot}`] };
286
+ }
287
+
288
+ // Pass 1: parse tasks.md for each domain
289
+ const scopeCache = new Map(); // domainName → string[] (Files Owned)
290
+ for (const domain of domainDirs) {
291
+ const tasksPath = path.join(domainsRoot, domain, "tasks.md");
292
+ if (!fs.existsSync(tasksPath)) continue;
293
+ const { tasks, warnings: ws } = parseTasksMd(tasksPath, domain);
294
+ for (const w of ws) warnings.push(w);
295
+ for (const t of tasks) {
296
+ if (byId[t.id]) {
297
+ warnings.push(`duplicate task id ${t.id} (domain ${domain}) — first wins`);
298
+ continue;
299
+ }
300
+ byId[t.id] = t;
301
+ nodes.push(t);
302
+ }
303
+ }
304
+
305
+ // Pass 2: touch-list fallback from scope.md when task didn't declare touches
306
+ for (const t of nodes) {
307
+ if (t.touches !== null) continue; // explicit declaration (incl. [])
308
+ if (!scopeCache.has(t.domain)) {
309
+ const scopePath = path.join(domainsRoot, t.domain, "scope.md");
310
+ scopeCache.set(t.domain, parseScopeFilesOwned(scopePath));
311
+ }
312
+ const fallback = scopeCache.get(t.domain);
313
+ if (fallback && fallback.length) {
314
+ t.touches = fallback.slice();
315
+ } else {
316
+ t.touches = [];
317
+ warnings.push(`no touch-list for ${t.id}: tasks.md missing **Touches** and scope.md has no Files Owned entries — set to []`);
318
+ }
319
+ }
320
+
321
+ // Pass 3: edges
322
+ for (const t of nodes) {
323
+ for (const d of t.deps) {
324
+ edges.push({ from: t.id, to: d });
325
+ }
326
+ }
327
+
328
+ // Pass 4: cycle detection (throws on cycle)
329
+ detectCycle(byId);
330
+
331
+ // Pass 5: ready mask
332
+ const ready = [];
333
+ for (const t of nodes) {
334
+ if (t.status !== "pending") continue;
335
+ let allDone = true;
336
+ for (const d of t.deps) {
337
+ const dep = byId[d];
338
+ if (!dep || dep.status !== "done") {
339
+ allDone = false;
340
+ break;
341
+ }
342
+ }
343
+ if (allDone) ready.push(t.id);
344
+ }
345
+
346
+ return { nodes, edges, ready, byId, warnings };
347
+ }
348
+
349
+ /**
350
+ * Convenience: return the list of ready TaskNode objects.
351
+ */
352
+ function getReadyTasks(graph) {
353
+ if (!graph || !Array.isArray(graph.ready) || !graph.byId) return [];
354
+ return graph.ready.map((id) => graph.byId[id]).filter(Boolean);
355
+ }
356
+
357
+ module.exports = {
358
+ buildTaskGraph,
359
+ getReadyTasks,
360
+ TaskGraphCycleError,
361
+ // Internals exposed for unit tests:
362
+ _parseTasksMd: parseTasksMd,
363
+ _parseScopeFilesOwned: parseScopeFilesOwned,
364
+ _parseDepList: parseDepList,
365
+ _parseFileList: parseFileList,
366
+ };
@@ -94,7 +94,7 @@ function _appendJsonlRecord(jsonlPath, record) {
94
94
  fs.appendFileSync(jsonlPath, JSON.stringify(record) + '\n');
95
95
  }
96
96
 
97
- function _buildJsonlRecord({ command, step, model, startedAt, endedAt, durationSec, usage, domain, task, notes, ctxPct, milestone, source, sessionId, turnId, sessionType, toolAttribution, compactionPressure }) {
97
+ function _buildJsonlRecord({ command, step, model, startedAt, endedAt, durationSec, usage, domain, task, notes, ctxPct, milestone, source, sessionId, turnId, sessionType, toolAttribution, compactionPressure, cw_id }) {
98
98
  const u = usage || {};
99
99
  const cost = (typeof u.total_cost_usd === 'number') ? u.total_cost_usd : (typeof u.cost_usd === 'number' ? u.cost_usd : null);
100
100
  const rec = {
@@ -124,6 +124,10 @@ function _buildJsonlRecord({ command, step, model, startedAt, endedAt, durationS
124
124
  if (sessionType != null) rec.sessionType = sessionType;
125
125
  if (Array.isArray(toolAttribution) && toolAttribution.length) rec.tool_attribution = toolAttribution;
126
126
  if (compactionPressure && typeof compactionPressure === 'object') rec.compaction_pressure = compactionPressure;
127
+ // M44 D7 v2.1.0: optional per-Context-Window attribution key.
128
+ // Omitted (not null, not "") when the caller does not supply it, so
129
+ // pre-D7 callers continue to produce byte-identical rows.
130
+ if (cw_id != null && cw_id !== '') rec.cw_id = String(cw_id);
127
131
  return rec;
128
132
  }
129
133
 
@@ -174,6 +178,9 @@ function _parseStartedAt(s) {
174
178
  * @param {'in-session'|'headless'} [opts.sessionType] v2 — channel classifier
175
179
  * @param {Array} [opts.toolAttribution] v2 — D2 joiner output; usually omitted by spawn callers
176
180
  * @param {object} [opts.compactionPressure] v2 — D5 runway snapshot; usually omitted by spawn callers
181
+ * @param {string} [opts.cw_id] v2.1.0 — per-Context-Window attribution key (M44 D7).
182
+ * Pass-through only; the wrapper does not derive it.
183
+ * Omitted from the row when absent (NOT null, NOT "").
177
184
  * @returns {{tokenLogPath: string, jsonlPath: string}}
178
185
  */
179
186
  function recordSpawnRow(opts) {
@@ -191,19 +198,25 @@ function recordSpawnRow(opts) {
191
198
  const task = (opts.task == null || opts.task === '') ? '-' : String(opts.task);
192
199
  const ctxPct = (opts.ctxPct == null) ? 'N/A' : String(opts.ctxPct);
193
200
 
194
- _appendTokenLogRow(tokenLogPath, {
195
- startedAt: opts.startedAt,
196
- endedAt: opts.endedAt,
197
- command: opts.command,
198
- step: opts.step,
199
- model: opts.model,
200
- durationSec,
201
- tokensCell,
202
- notes,
203
- domain,
204
- task,
205
- ctxPct,
206
- });
201
+ // skipMarkdownLog: JSONL is canonical (D3). The markdown log is a legacy view
202
+ // kept in sync for human-readable tailing; high-frequency producers (D1
203
+ // per-turn in-session rows, D2 joiner) should write JSONL-only and rely on
204
+ // `gsd-t tokens --regenerate-log` for the markdown rendering.
205
+ if (!opts.skipMarkdownLog) {
206
+ _appendTokenLogRow(tokenLogPath, {
207
+ startedAt: opts.startedAt,
208
+ endedAt: opts.endedAt,
209
+ command: opts.command,
210
+ step: opts.step,
211
+ model: opts.model,
212
+ durationSec,
213
+ tokensCell,
214
+ notes,
215
+ domain,
216
+ task,
217
+ ctxPct,
218
+ });
219
+ }
207
220
 
208
221
  const milestone = opts.milestone || _inferMilestone(projectDir);
209
222
  _appendJsonlRecord(jsonlPath, _buildJsonlRecord({
@@ -225,6 +238,7 @@ function recordSpawnRow(opts) {
225
238
  sessionType: opts.sessionType,
226
239
  toolAttribution: opts.toolAttribution,
227
240
  compactionPressure: opts.compactionPressure,
241
+ cw_id: opts.cw_id,
228
242
  }));
229
243
 
230
244
  return { tokenLogPath, jsonlPath };
@@ -287,6 +301,7 @@ async function captureSpawn(opts) {
287
301
  sessionType: opts.sessionType,
288
302
  toolAttribution: opts.toolAttribution,
289
303
  compactionPressure: opts.compactionPressure,
304
+ cw_id: opts.cw_id,
290
305
  });
291
306
 
292
307
  if (caught) throw caught;
@@ -307,12 +307,47 @@ function aggregateSync(opts) {
307
307
  return agg;
308
308
  }
309
309
 
310
+ /**
311
+ * Render a "Top 10 tools by cost" section by invoking the D2 attribution
312
+ * library and its CLI renderer. Used by `gsd-t tokens --show-tool-costs`.
313
+ *
314
+ * @param {object} opts
315
+ * @param {string} opts.projectDir
316
+ * @param {string} [opts.since]
317
+ * @param {string} [opts.milestone]
318
+ * @param {'table'|'json'} [opts.format]
319
+ * @returns {string} rendered section (multi-line string)
320
+ */
321
+ function renderToolCostsSection(opts) {
322
+ const projectDir = opts.projectDir || '.';
323
+ const toolCost = require('./gsd-t-tool-cost.cjs');
324
+ const attribution = require('./gsd-t-tool-attribution.cjs');
325
+ const turnsPath = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
326
+ const eventsGlob = path.join(projectDir, '.gsd-t', 'events');
327
+ if (!fs.existsSync(turnsPath)) {
328
+ return '── Top 10 tools by cost ──\n (no data)';
329
+ }
330
+ const joined = attribution.joinTurnsAndEvents({
331
+ turnsPath,
332
+ eventsGlob,
333
+ since: opts.since || undefined,
334
+ milestone: opts.milestone || undefined,
335
+ });
336
+ const agg = attribution.aggregateByTool(joined).slice(0, 10);
337
+ if (opts.format === 'json') {
338
+ return '\n── Top 10 tools by cost (JSON) ──\n' + agg.map((r) => JSON.stringify(r)).join('\n');
339
+ }
340
+ return '\n── Top 10 tools by cost ──\n' +
341
+ toolCost.renderTable(agg, { groupBy: 'tool', since: opts.since, milestone: opts.milestone });
342
+ }
343
+
310
344
  module.exports = {
311
345
  aggregate,
312
346
  aggregateSync,
313
347
  renderTable,
314
348
  renderJson,
315
349
  renderStatusBlock,
350
+ renderToolCostsSection,
316
351
  _safeParse,
317
352
  _day,
318
353
  };