@tekyzinc/gsd-t 3.16.12 → 3.18.11

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 (52) hide show
  1. package/CHANGELOG.md +61 -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-heartbeat.js +50 -2
  46. package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
  47. package/scripts/gsd-t-transcript.html +546 -43
  48. package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
  49. package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
  50. package/templates/CLAUDE-global.md +8 -3
  51. package/templates/CLAUDE-project.md +17 -14
  52. package/templates/hooks/post-commit-spawn-plan.sh +85 -0
@@ -222,6 +222,78 @@ function tailTranscriptFile(filePath, callback) {
222
222
  return () => fs.unwatchFile(filePath, processNewData);
223
223
  }
224
224
 
225
+ // ── M43 D6 — per-spawn tool-cost + usage routes ────────────────────────────
226
+
227
+ /**
228
+ * Load per-turn usage rows for a given spawn-id from
229
+ * `.gsd-t/metrics/token-usage.jsonl`.
230
+ *
231
+ * Rows emitted by M41/M43 include either `spawn_id` OR `session_id`. We match
232
+ * against both so the route works both for headless spawns (which tag rows
233
+ * with `spawn_id` at emit time) and in-session D1 captures (which tag with
234
+ * the session's `session_id`).
235
+ *
236
+ * @param {string} projectDir
237
+ * @param {string} spawnId
238
+ * @returns {Array<object>}
239
+ */
240
+ function readSpawnUsageRows(projectDir, spawnId) {
241
+ const fp = path.join(projectDir, ".gsd-t", "metrics", "token-usage.jsonl");
242
+ if (!fs.existsSync(fp)) return [];
243
+ const lines = safeReadJsonl(fp);
244
+ return lines.filter((row) => {
245
+ if (!row || typeof row !== "object") return false;
246
+ return row.spawn_id === spawnId || row.session_id === spawnId;
247
+ });
248
+ }
249
+
250
+ function handleTranscriptUsage(req, res, spawnId, projectDir) {
251
+ if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
252
+ const rows = readSpawnUsageRows(projectDir, spawnId);
253
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
254
+ res.end(JSON.stringify({ spawn_id: spawnId, rows, generated_at: new Date().toISOString() }));
255
+ }
256
+
257
+ /**
258
+ * Proxy to D2's `aggregateByTool`. D2 is co-deployed in Wave 2 — if the
259
+ * library isn't on disk yet, we return HTTP 503 with a machine-readable
260
+ * body so the viewer panel can display "Tool attribution not yet available"
261
+ * without breaking.
262
+ */
263
+ function handleTranscriptToolCost(req, res, spawnId, projectDir) {
264
+ if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
265
+ let attribution;
266
+ try {
267
+ // TODO(D6→D2): remove the try/catch once D2 lands. Keep the call shape.
268
+ // eslint-disable-next-line global-require
269
+ attribution = require("../bin/gsd-t-tool-attribution.cjs");
270
+ } catch (_) {
271
+ res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
272
+ res.end(JSON.stringify({ error: "tool-attribution library not yet available" }));
273
+ return;
274
+ }
275
+ const rows = readSpawnUsageRows(projectDir, spawnId);
276
+ let tools = [];
277
+ try {
278
+ if (typeof attribution.aggregateByTool !== "function") {
279
+ res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
280
+ res.end(JSON.stringify({ error: "aggregateByTool not exported by tool-attribution library" }));
281
+ return;
282
+ }
283
+ tools = attribution.aggregateByTool(rows) || [];
284
+ } catch (err) {
285
+ res.writeHead(500, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
286
+ res.end(JSON.stringify({ error: String(err && err.message ? err.message : err) }));
287
+ return;
288
+ }
289
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
290
+ res.end(JSON.stringify({
291
+ spawn_id: spawnId,
292
+ tools,
293
+ generated_at: new Date().toISOString(),
294
+ }));
295
+ }
296
+
225
297
  // ── M42 D3 — kill per-spawn ────────────────────────────────────────────────
226
298
 
227
299
  function writeTranscriptsIndex(projectDir, idx) {
@@ -380,6 +452,94 @@ function handleTranscriptStream(req, res, spawnId, projectDir) {
380
452
  req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); });
381
453
  }
382
454
 
455
+ // ── M44 D8 — spawn-plan endpoint + SSE channel ─────────────────────────────
456
+ //
457
+ // Additive; does not change existing endpoints or SSE streams. Reads plan
458
+ // files atomically written by `bin/spawn-plan-writer.cjs` under
459
+ // `.gsd-t/spawns/{spawnId}.json`. A plan is "active" when `endedAt === null`.
460
+ // Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
461
+
462
+ const SPAWNS_SUBDIR = path.join(".gsd-t", "spawns");
463
+
464
+ function spawnsDir(projectDir) {
465
+ return path.join(projectDir, SPAWNS_SUBDIR);
466
+ }
467
+
468
+ function readSpawnPlanFile(fp) {
469
+ try {
470
+ const raw = fs.readFileSync(fp, "utf8");
471
+ const parsed = JSON.parse(raw);
472
+ if (!parsed || typeof parsed !== "object") return null;
473
+ return parsed;
474
+ } catch { return null; }
475
+ }
476
+
477
+ function listAllSpawnPlans(projectDir) {
478
+ const dir = spawnsDir(projectDir);
479
+ if (!fs.existsSync(dir)) return [];
480
+ let files;
481
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { return []; }
482
+ const out = [];
483
+ for (const f of files) {
484
+ const plan = readSpawnPlanFile(path.join(dir, f));
485
+ if (plan) out.push(plan);
486
+ }
487
+ return out;
488
+ }
489
+
490
+ function listActiveSpawnPlans(projectDir) {
491
+ return listAllSpawnPlans(projectDir).filter((p) => p && p.endedAt == null);
492
+ }
493
+
494
+ function handleSpawnPlans(req, res, projectDir) {
495
+ const plans = listActiveSpawnPlans(projectDir).sort((a, b) => {
496
+ const ta = Date.parse(a.startedAt) || 0;
497
+ const tb = Date.parse(b.startedAt) || 0;
498
+ return tb - ta;
499
+ });
500
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
501
+ res.end(JSON.stringify({ plans, generated_at: new Date().toISOString() }));
502
+ }
503
+
504
+ function handleSpawnPlanUpdates(req, res, projectDir) {
505
+ res.writeHead(200, SSE_HEADERS);
506
+ // Initial snapshot: every current active plan.
507
+ const initial = listActiveSpawnPlans(projectDir);
508
+ for (const plan of initial) {
509
+ try { res.write("data: " + JSON.stringify({ spawnId: plan.spawnId, plan }) + "\n\n"); } catch { /* gone */ }
510
+ }
511
+
512
+ const dir = spawnsDir(projectDir);
513
+ let dirWatcher = null;
514
+ const emittedCache = new Map(); // spawnId → last-mtime-ms, dedup rapid rename events
515
+
516
+ function pushChange(spawnId) {
517
+ if (!spawnId) return;
518
+ const fp = path.join(dir, spawnId + ".json");
519
+ let mtime = 0;
520
+ try { mtime = fs.statSync(fp).mtimeMs || 0; } catch { /* missing */ }
521
+ // Dedup rapid-fire rename events — only emit when mtime advances.
522
+ if (emittedCache.get(spawnId) === mtime && mtime > 0) return;
523
+ emittedCache.set(spawnId, mtime);
524
+ const plan = readSpawnPlanFile(fp);
525
+ if (!plan) return;
526
+ try { res.write("data: " + JSON.stringify({ spawnId, plan }) + "\n\n"); } catch { /* gone */ }
527
+ }
528
+
529
+ try {
530
+ if (fs.existsSync(dir)) {
531
+ dirWatcher = fs.watch(dir, (eventType, filename) => {
532
+ if (!filename || !filename.endsWith(".json")) return;
533
+ const spawnId = filename.slice(0, -5);
534
+ pushChange(spawnId);
535
+ });
536
+ }
537
+ } catch { /* dir may not exist yet; skip watch */ }
538
+
539
+ const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
540
+ req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
541
+ }
542
+
383
543
  function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath) {
384
544
  const projDir = projectDir || path.resolve(eventsDir, "..", "..");
385
545
  const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
@@ -391,9 +551,18 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
391
551
  if (url === "/ping") return handlePing(req, res, port);
392
552
  if (url === "/stop") return handleStop(req, res, server);
393
553
  if (url === "/transcripts") return handleTranscriptsList(req, res, projDir);
554
+ // M44 D8 — spawn plans: GET list + SSE change stream
555
+ if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
556
+ if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
394
557
  // POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
395
558
  const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
396
559
  if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
560
+ // M43 D6 — /transcript/:spawnId/tool-cost — D2 attribution proxy
561
+ const toolCostMatch = url.match(/^\/transcript\/([^/]+)\/tool-cost$/);
562
+ if (toolCostMatch) return handleTranscriptToolCost(req, res, decodeURIComponent(toolCostMatch[1]), projDir);
563
+ // M43 D6 — /transcript/:spawnId/usage — per-turn rows for this spawn
564
+ const usageMatch = url.match(/^\/transcript\/([^/]+)\/usage$/);
565
+ if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
397
566
  // /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
398
567
  const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
399
568
  if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
@@ -421,10 +590,20 @@ module.exports = {
421
590
  handleTranscriptStream,
422
591
  handleTranscriptPage,
423
592
  handleTranscriptKill,
593
+ handleTranscriptToolCost,
594
+ handleTranscriptUsage,
595
+ readSpawnUsageRows,
424
596
  transcriptsDir,
425
597
  DEFAULT_PORT,
426
598
  projectScopedDefaultPort,
427
599
  resolvePort,
600
+ // M44 D8 — spawn-plan visibility
601
+ listAllSpawnPlans,
602
+ listActiveSpawnPlans,
603
+ handleSpawnPlans,
604
+ handleSpawnPlanUpdates,
605
+ readSpawnPlanFile,
606
+ spawnsDir,
428
607
  };
429
608
 
430
609
  if (require.main === module) {
@@ -19,7 +19,7 @@ const SAFE_SID = /^[a-zA-Z0-9_-]+$/; // Allowlist for session_id — blocks path
19
19
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — auto-cleanup threshold
20
20
 
21
21
  // ─── Exports (for testing) ───────────────────────────────────────────────────
22
- module.exports = { scrubSecrets, scrubUrl, buildEvent, summarize, shortPath, buildEventStreamEntry, appendToEventsFile };
22
+ module.exports = { scrubSecrets, scrubUrl, buildEvent, summarize, shortPath, buildEventStreamEntry, appendToEventsFile, resolveTurnIdFromTranscript };
23
23
 
24
24
  // ─── Main (stdin processing) ─────────────────────────────────────────────────
25
25
  if (require.main === module) {
@@ -222,13 +222,61 @@ function buildEventStreamEntry(hook) {
222
222
  reasoning: null, outcome: null };
223
223
  }
224
224
  if (n === "PostToolUse") {
225
+ // M43 D2-fix: resolve turn_id (parent assistant message id) from the
226
+ // transcript via the tool_use_id lookup so downstream tool attribution
227
+ // can join on (session_id, turn_id) directly instead of the lossy
228
+ // timestamp-window heuristic. Fast (~0ms) in practice — the matching
229
+ // tool_use is always near the end of the transcript.
230
+ const tuid = hook.tool_use_id || null;
231
+ const turn_id = resolveTurnIdFromTranscript(hook.transcript_path, tuid);
225
232
  return { ...base, event_type: "tool_call",
226
233
  agent_id: hook.agent_id || hook.session_id || null, parent_agent_id: null,
227
- reasoning: hook.tool_name || null, outcome: null };
234
+ reasoning: hook.tool_name || null, outcome: null,
235
+ turn_id: turn_id, tool_use_id: tuid };
228
236
  }
229
237
  return null;
230
238
  }
231
239
 
240
+ // ─── Turn-id resolution ──────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Given a transcript path and a tool_use_id, scan the transcript backward to
244
+ * find the assistant turn (message) that produced this tool_use, and return
245
+ * its message.id (the canonical turn_id used by per-turn token capture).
246
+ *
247
+ * Returns `null` on any failure — never throws. Hooks must not break on a
248
+ * missing/unreadable transcript.
249
+ *
250
+ * Performance note: real-world transcripts are a few MB and the matching
251
+ * tool_use is always the most recent one the LLM just emitted, so scanning
252
+ * from the tail finds the match in the first handful of lines (~0ms).
253
+ */
254
+ function resolveTurnIdFromTranscript(transcriptPath, toolUseId) {
255
+ if (!transcriptPath || !toolUseId) return null;
256
+ try {
257
+ if (!fs.existsSync(transcriptPath)) return null;
258
+ const text = fs.readFileSync(transcriptPath, "utf8");
259
+ const lines = text.split("\n");
260
+ for (let i = lines.length - 1; i >= 0; i--) {
261
+ const line = lines[i];
262
+ if (!line) continue;
263
+ // Cheap string prefilter — skip lines that can't possibly contain the id.
264
+ if (line.indexOf(toolUseId) === -1) continue;
265
+ try {
266
+ const j = JSON.parse(line);
267
+ if (j && j.type === "assistant" && j.message && Array.isArray(j.message.content)) {
268
+ for (const c of j.message.content) {
269
+ if (c && c.type === "tool_use" && c.id === toolUseId) {
270
+ return j.message.id || null;
271
+ }
272
+ }
273
+ }
274
+ } catch { /* malformed line — skip */ }
275
+ }
276
+ } catch { /* transcript unreadable — bail */ }
277
+ return null;
278
+ }
279
+
232
280
  function appendToEventsFile(gsdtDir, entry) {
233
281
  try {
234
282
  const date = new Date().toISOString().slice(0, 10);
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # GSD-T Post-Commit Spawn-Plan Hook (M44 D8 T2)
3
+ #
4
+ # Greps the latest commit message for every `[M\d+-D\d+-T\d+]` task id and
5
+ # flips the matching task in every ACTIVE spawn plan under `.gsd-t/spawns/`
6
+ # to `done` (with commit SHA + token attribution from `.gsd-t/token-log.md`).
7
+ #
8
+ # Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
9
+ #
10
+ # HARD RULE: silent-fail. This hook must NEVER break the user's commit.
11
+ # Every error path logs to stderr and exits 0.
12
+
13
+ set +e # never bail on error — silent-fail is mandatory
14
+
15
+ # Resolve project root (the dir git operates from).
16
+ PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null)"
17
+ if [ -z "$PROJECT_DIR" ] || [ ! -d "$PROJECT_DIR/.gsd-t/spawns" ]; then
18
+ exit 0
19
+ fi
20
+
21
+ UPDATER="$PROJECT_DIR/bin/spawn-plan-status-updater.cjs"
22
+ if [ ! -f "$UPDATER" ]; then
23
+ exit 0
24
+ fi
25
+
26
+ # Require node; log once and continue if absent.
27
+ if ! command -v node >/dev/null 2>&1; then
28
+ echo "[spawn-plan-hook] node not found — skipping" 1>&2
29
+ exit 0
30
+ fi
31
+
32
+ COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null)"
33
+ COMMIT_MSG="$(git log -1 --format=%B 2>/dev/null)"
34
+ if [ -z "$COMMIT_SHA" ] || [ -z "$COMMIT_MSG" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ # Extract all [M\d+-D\d+-T\d+] ids (unique, preserved order).
39
+ TASK_IDS="$(printf '%s' "$COMMIT_MSG" | grep -oE '\[M[0-9]+-D[0-9]+-T[0-9]+\]' | sed 's/[][]//g' | awk '!seen[$0]++')"
40
+ if [ -z "$TASK_IDS" ]; then
41
+ exit 0
42
+ fi
43
+
44
+ # Delegate patching to node. One invocation handles every active plan ×
45
+ # every task id. Pipes the task-id list via stdin; silent-fails on any
46
+ # error so the commit is never broken.
47
+ printf '%s\n' "$TASK_IDS" | node -e '
48
+ "use strict";
49
+ try {
50
+ const path = require("path");
51
+ const fs = require("fs");
52
+ const projectDir = process.argv[1];
53
+ const commit = process.argv[2];
54
+ let raw = "";
55
+ process.stdin.setEncoding("utf8");
56
+ process.stdin.on("data", (c) => { raw += c; });
57
+ process.stdin.on("end", () => {
58
+ try {
59
+ const taskIds = raw.split("\n").map((s) => s.trim()).filter(Boolean);
60
+ if (!taskIds.length) process.exit(0);
61
+ const updater = require(path.join(projectDir, "bin", "spawn-plan-status-updater.cjs"));
62
+ const activePaths = updater.listActivePlans(projectDir);
63
+ if (!activePaths.length) process.exit(0);
64
+ for (const fp of activePaths) {
65
+ let plan;
66
+ try { plan = JSON.parse(fs.readFileSync(fp, "utf8")); } catch (_) { continue; }
67
+ const spawnId = plan && plan.spawnId;
68
+ const spawnStartedAt = plan && plan.startedAt;
69
+ if (!spawnId) continue;
70
+ const planTaskIds = new Set((plan.tasks || []).map((t) => t && t.id).filter(Boolean));
71
+ for (const taskId of taskIds) {
72
+ if (!planTaskIds.has(taskId)) continue;
73
+ const tokens = updater.sumTokensForTask({ projectDir, taskId, spawnStartedAt });
74
+ updater.markTaskDone({ spawnId, taskId, commit, tokens, projectDir });
75
+ }
76
+ }
77
+ } catch (err) {
78
+ try { process.stderr.write("[spawn-plan-hook] " + String(err && err.message || err) + "\n"); } catch (_) { /* silent */ }
79
+ }
80
+ });
81
+ } catch (err) {
82
+ try { process.stderr.write("[spawn-plan-hook] " + String(err && err.message || err) + "\n"); } catch (_) { /* silent */ }
83
+ }
84
+ ' "$PROJECT_DIR" "$COMMIT_SHA" 2>/dev/null
85
+
86
+ exit 0