@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.
- package/CHANGELOG.md +61 -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-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
|
@@ -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
|