@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.
- package/CHANGELOG.md +67 -0
- package/README.md +13 -3
- package/bin/gsd-t-depgraph-validate.cjs +140 -0
- package/bin/gsd-t-economics.cjs +287 -0
- package/bin/gsd-t-file-disjointness.cjs +227 -0
- package/bin/gsd-t-in-session-usage.cjs +213 -0
- package/bin/gsd-t-orchestrator-config.cjs +100 -3
- package/bin/gsd-t-orchestrator.js +2 -1
- package/bin/gsd-t-parallel.cjs +382 -0
- package/bin/gsd-t-report-tokens.cjs +549 -0
- package/bin/gsd-t-task-graph.cjs +366 -0
- package/bin/gsd-t-token-capture.cjs +29 -14
- package/bin/gsd-t-token-dashboard.cjs +35 -0
- package/bin/gsd-t-tool-attribution.cjs +377 -0
- package/bin/gsd-t-tool-cost.cjs +195 -0
- package/bin/gsd-t-unattended-platform.cjs +7 -1
- package/bin/gsd-t-unattended.cjs +2 -0
- package/bin/gsd-t.js +155 -5
- package/bin/headless-auto-spawn.cjs +69 -49
- package/bin/headless-auto-spawn.js +18 -24
- package/bin/runway-estimator.cjs +212 -0
- package/bin/spawn-plan-derive.cjs +163 -0
- package/bin/spawn-plan-status-updater.cjs +292 -0
- package/bin/spawn-plan-writer.cjs +204 -0
- package/commands/gsd-t-debug.md +26 -7
- package/commands/gsd-t-execute.md +36 -28
- package/commands/gsd-t-help.md +11 -0
- package/commands/gsd-t-integrate.md +27 -7
- package/commands/gsd-t-quick.md +30 -13
- package/commands/gsd-t-scan.md +5 -5
- package/commands/gsd-t-unattended-watch.md +4 -3
- package/commands/gsd-t-unattended.md +9 -3
- package/commands/gsd-t-verify.md +5 -5
- package/commands/gsd-t-wave.md +21 -8
- package/commands/gsd.md +45 -3
- package/docs/GSD-T-README.md +43 -5
- package/docs/architecture.md +423 -3
- package/docs/requirements.md +203 -0
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +256 -0
- package/scripts/gsd-t-compact-detector.js +223 -0
- package/scripts/gsd-t-compaction-scanner.js +305 -0
- package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
- package/scripts/gsd-t-dashboard-server.js +179 -0
- package/scripts/gsd-t-dashboard.html +3 -3
- package/scripts/gsd-t-heartbeat.js +50 -2
- package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
- package/scripts/gsd-t-transcript.html +546 -43
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
- package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
- package/templates/CLAUDE-global.md +8 -3
- package/templates/CLAUDE-project.md +17 -14
- 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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
};
|