@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
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-compact-detector.js
4
+ *
5
+ * SessionStart hook that records compaction events.
6
+ *
7
+ * Claude Code fires SessionStart with `source: "compact"` immediately after an
8
+ * auto-compaction. By contrast, a fresh launch fires with `source: "startup"`
9
+ * and a resumed session fires with `source: "resume"`. Only `compact` is
10
+ * recorded — it's the transition marker that separates one Context Window
11
+ * from the next.
12
+ *
13
+ * Without this hook, Context Window boundaries are invisible: every iter
14
+ * silently looks like a single CW. That breaks the canonical measurement
15
+ * hierarchy (Run → Iter → Context Window → Turn → Tool call).
16
+ *
17
+ * Behavior:
18
+ * - Zero-dep. Reads stdin JSON, silently fails on any error. Always exits 0 —
19
+ * throwing here would break Claude Code session startup.
20
+ * - Only acts when `source === "compact"`.
21
+ * - Appends one NDJSON row to `<cwd>/.gsd-t/metrics/compactions.jsonl`.
22
+ * - Appends one compact_marker frame to the most-recently-modified
23
+ * `<cwd>/.gsd-t/transcripts/*.ndjson` (the live transcript). No-ops
24
+ * silently when no transcript exists.
25
+ * - 1 MiB stdin cap (defense in depth; real payloads are tiny).
26
+ * - Path-traversal guard: refuses any cwd that doesn't let the resolved
27
+ * output path stay under `<cwd>/.gsd-t/metrics/`.
28
+ * - Off switch: if `<cwd>/.gsd-t/` does not exist, silent no-op. Creating it
29
+ * is opt-in; deleting it disables the hook without having to edit
30
+ * settings.json.
31
+ *
32
+ * Contract: .gsd-t/contracts/compaction-events-contract.md
33
+ */
34
+ "use strict";
35
+
36
+ const fs = require("fs");
37
+ const path = require("path");
38
+
39
+ const MAX_STDIN = 1024 * 1024; // 1 MiB
40
+ const SCHEMA_VERSION = 1;
41
+
42
+ let input = "";
43
+ let aborted = false;
44
+
45
+ process.stdin.setEncoding("utf8");
46
+ process.stdin.on("data", (chunk) => {
47
+ input += chunk;
48
+ if (input.length > MAX_STDIN) {
49
+ aborted = true;
50
+ try { process.stdin.destroy(); } catch { /* noop */ }
51
+ }
52
+ });
53
+ process.stdin.on("error", () => { /* silent */ });
54
+ process.stdin.on("end", () => {
55
+ if (aborted) { exitClean(); return; }
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(input);
59
+ } catch {
60
+ exitClean();
61
+ return;
62
+ }
63
+
64
+ if (!payload || typeof payload !== "object") { exitClean(); return; }
65
+
66
+ // Only record `compact`. Startup / resume are no-ops.
67
+ if (payload.source !== "compact") { exitClean(); return; }
68
+
69
+ try {
70
+ writeRow(payload);
71
+ } catch {
72
+ // silent — never throw
73
+ }
74
+ try {
75
+ writeTranscriptMarker(payload);
76
+ } catch {
77
+ // silent — never throw
78
+ }
79
+ exitClean();
80
+ });
81
+
82
+ function writeRow(payload) {
83
+ // `cwd` must be absolute when present. An invalid value is NOT silently
84
+ // coerced to process.cwd() — that would let a malformed payload write
85
+ // into whatever dir the hook happened to be spawned from.
86
+ let cwd;
87
+ if (typeof payload.cwd === "string") {
88
+ if (!path.isAbsolute(payload.cwd)) return; // invalid — no-op
89
+ cwd = payload.cwd;
90
+ } else if (payload.cwd === undefined || payload.cwd === null) {
91
+ cwd = process.cwd();
92
+ } else {
93
+ return; // non-string cwd — no-op
94
+ }
95
+
96
+ // `.gsd-t/` must exist — acts as the off-switch.
97
+ const gsdDir = path.join(cwd, ".gsd-t");
98
+ if (!fs.existsSync(gsdDir)) return;
99
+
100
+ const metricsDir = path.join(gsdDir, "metrics");
101
+ const outPath = path.join(metricsDir, "compactions.jsonl");
102
+
103
+ // Path-traversal guard.
104
+ const resolvedOut = path.resolve(outPath);
105
+ const resolvedMetrics = path.resolve(metricsDir) + path.sep;
106
+ if (!resolvedOut.startsWith(resolvedMetrics)) return;
107
+
108
+ try {
109
+ fs.mkdirSync(metricsDir, { recursive: true });
110
+ } catch {
111
+ return;
112
+ }
113
+
114
+ const row = {
115
+ ts: new Date().toISOString(),
116
+ schemaVersion: SCHEMA_VERSION,
117
+ session_id: typeof payload.session_id === "string" ? payload.session_id : null,
118
+ prior_session_id: typeof payload.prior_session_id === "string"
119
+ ? payload.prior_session_id
120
+ : (typeof payload.previous_session_id === "string"
121
+ ? payload.previous_session_id
122
+ : null),
123
+ source: "compact",
124
+ cwd,
125
+ hook: "SessionStart",
126
+ };
127
+
128
+ fs.appendFileSync(outPath, JSON.stringify(row) + "\n", "utf8");
129
+ }
130
+
131
+ /**
132
+ * Find the most-recently-modified .ndjson in <cwd>/.gsd-t/transcripts/.
133
+ * Returns the absolute path, or null if none exists or the directory is absent.
134
+ */
135
+ function findActiveTranscript(cwd) {
136
+ const transcriptsDir = path.join(cwd, ".gsd-t", "transcripts");
137
+ let entries;
138
+ try {
139
+ entries = fs.readdirSync(transcriptsDir);
140
+ } catch {
141
+ return null;
142
+ }
143
+ const ndjsons = entries.filter((e) => e.endsWith(".ndjson"));
144
+ if (!ndjsons.length) return null;
145
+
146
+ let newest = null;
147
+ let newestMtime = -1;
148
+ for (const name of ndjsons) {
149
+ const full = path.join(transcriptsDir, name);
150
+ try {
151
+ const stat = fs.statSync(full);
152
+ if (stat.mtimeMs > newestMtime) {
153
+ newestMtime = stat.mtimeMs;
154
+ newest = full;
155
+ }
156
+ } catch {
157
+ // skip unreadable entries
158
+ }
159
+ }
160
+ return newest;
161
+ }
162
+
163
+ /**
164
+ * Append a compact_marker frame to the active transcript NDJSON.
165
+ * No-ops silently when no transcript exists.
166
+ */
167
+ function writeTranscriptMarker(payload) {
168
+ let cwd;
169
+ if (typeof payload.cwd === "string") {
170
+ if (!path.isAbsolute(payload.cwd)) return;
171
+ cwd = payload.cwd;
172
+ } else if (payload.cwd === undefined || payload.cwd === null) {
173
+ cwd = process.cwd();
174
+ } else {
175
+ return;
176
+ }
177
+
178
+ const gsdDir = path.join(cwd, ".gsd-t");
179
+ if (!fs.existsSync(gsdDir)) return;
180
+
181
+ const transcriptPath = findActiveTranscript(cwd);
182
+ if (!transcriptPath) return;
183
+
184
+ // Path-traversal guard: resolved transcript path must stay under <cwd>/.gsd-t/transcripts/
185
+ const transcriptsDir = path.join(gsdDir, "transcripts") + path.sep;
186
+ if (!path.resolve(transcriptPath).startsWith(path.resolve(transcriptsDir))) return;
187
+
188
+ const marker = {
189
+ type: "compact_marker",
190
+ ts: new Date().toISOString(),
191
+ source: "compact",
192
+ session_id: typeof payload.session_id === "string" ? payload.session_id : null,
193
+ prior_session_id: typeof payload.prior_session_id === "string"
194
+ ? payload.prior_session_id
195
+ : (typeof payload.previous_session_id === "string"
196
+ ? payload.previous_session_id
197
+ : null),
198
+ };
199
+
200
+ // Include optional fields when present.
201
+ if (typeof payload.trigger === "string") marker.trigger = payload.trigger;
202
+ if (typeof payload.preTokens === "number") marker.preTokens = payload.preTokens;
203
+ if (typeof payload.postTokens === "number") marker.postTokens = payload.postTokens;
204
+ // Also check nested compactMetadata (scanner shape).
205
+ if (payload.compactMetadata && typeof payload.compactMetadata === "object") {
206
+ if (typeof payload.compactMetadata.trigger === "string" && !marker.trigger) {
207
+ marker.trigger = payload.compactMetadata.trigger;
208
+ }
209
+ if (typeof payload.compactMetadata.preTokens === "number" && marker.preTokens == null) {
210
+ marker.preTokens = payload.compactMetadata.preTokens;
211
+ }
212
+ if (typeof payload.compactMetadata.postTokens === "number" && marker.postTokens == null) {
213
+ marker.postTokens = payload.compactMetadata.postTokens;
214
+ }
215
+ }
216
+
217
+ fs.appendFileSync(transcriptPath, JSON.stringify(marker) + "\n", "utf8");
218
+ }
219
+
220
+ function exitClean() {
221
+ try { process.stdout.write(""); } catch { /* noop */ }
222
+ process.exit(0);
223
+ }
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-compaction-scanner.js
4
+ *
5
+ * Historical backfill for compaction events.
6
+ *
7
+ * Claude Code writes one JSONL file per session under
8
+ * `~/.claude/projects/<cwd-slug>/`. Each auto-compaction emits a single row
9
+ * of shape:
10
+ *
11
+ * { "type": "system", "subtype": "compact_boundary",
12
+ * "timestamp": "…", "sessionId": "…", "cwd": "…",
13
+ * "compactMetadata": { "trigger": "auto",
14
+ * "preTokens": …, "postTokens": …,
15
+ * "durationMs": … } }
16
+ *
17
+ * This tool scans those session files, extracts the boundaries, dedups
18
+ * against the existing `<projectDir>/.gsd-t/metrics/compactions.jsonl`, and
19
+ * — only when `--write` is passed — appends the missing rows with
20
+ * `source: "compact-backfill"`.
21
+ *
22
+ * Defaults to DRY-RUN. The `--write` flag is required to mutate state.
23
+ *
24
+ * Contract: .gsd-t/contracts/compaction-events-contract.md
25
+ *
26
+ * Usage:
27
+ * node scripts/gsd-t-compaction-scanner.js [--write] [--project-dir DIR]
28
+ * [--sessions-root DIR]
29
+ * [--limit N]
30
+ */
31
+ "use strict";
32
+
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const os = require("os");
36
+ const readline = require("readline");
37
+
38
+ const SCHEMA_VERSION = 1;
39
+
40
+ /* ───────────────────────── arg parsing ───────────────────────── */
41
+
42
+ function parseArgs(argv) {
43
+ const args = { write: false, projectDir: process.cwd(), sessionsRoot: null, limit: null, help: false };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === "--write") args.write = true;
47
+ else if (a === "--project-dir") args.projectDir = argv[++i];
48
+ else if (a === "--sessions-root") args.sessionsRoot = argv[++i];
49
+ else if (a === "--limit") args.limit = parseInt(argv[++i], 10) || null;
50
+ else if (a === "-h" || a === "--help") args.help = true;
51
+ }
52
+ return args;
53
+ }
54
+
55
+ function printHelp() {
56
+ process.stdout.write(
57
+ [
58
+ "gsd-t-compaction-scanner — backfill .gsd-t/metrics/compactions.jsonl",
59
+ "",
60
+ "Usage: node scripts/gsd-t-compaction-scanner.js [flags]",
61
+ "",
62
+ " --write Actually write. Default is dry-run.",
63
+ " --project-dir DIR Project root (default: cwd).",
64
+ " --sessions-root DIR Override sessions root (default: derive from",
65
+ " ~/.claude/projects/<slug-of-project-dir>).",
66
+ " --limit N Stop after scanning N files (diagnostic).",
67
+ " -h, --help Show this help.",
68
+ "",
69
+ "Default is DRY-RUN. Pass --write to mutate compactions.jsonl.",
70
+ "",
71
+ ].join("\n")
72
+ );
73
+ }
74
+
75
+ /* ───────────────── sessions-root slug helper ───────────────── */
76
+
77
+ /**
78
+ * Claude Code encodes project directories in `~/.claude/projects/` by
79
+ * replacing `/` with `-`. For `/Users/david/projects/GSD-T` → slug is
80
+ * `-Users-david-projects-GSD-T`. Leading dash is intentional.
81
+ */
82
+ function deriveSessionsRoot(projectDir) {
83
+ const abs = path.resolve(projectDir);
84
+ const slug = abs.replace(/\//g, "-");
85
+ return path.join(os.homedir(), ".claude", "projects", slug);
86
+ }
87
+
88
+ /* ───────────────── scanner ───────────────── */
89
+
90
+ /**
91
+ * Scan one session JSONL file and yield compact_boundary rows. Returns an
92
+ * array (sessions rarely exceed tens of MB of NDJSON; a synchronous pass
93
+ * would still be fine but we use streaming to be safe on huge files).
94
+ *
95
+ * Silent-fail on any read/parse error per line (the goal is best-effort
96
+ * historical extraction, not validation of Claude Code's archive format).
97
+ *
98
+ * @returns {Promise<Array<object>>}
99
+ */
100
+ async function scanSessionFile(filePath) {
101
+ const out = [];
102
+ if (!fs.existsSync(filePath)) return out;
103
+
104
+ let stream;
105
+ try {
106
+ stream = fs.createReadStream(filePath, { encoding: "utf8" });
107
+ } catch {
108
+ return out;
109
+ }
110
+
111
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
112
+ for await (const line of rl) {
113
+ if (!line || line.length < 2) continue;
114
+ let obj;
115
+ try {
116
+ obj = JSON.parse(line);
117
+ } catch {
118
+ continue;
119
+ }
120
+ if (obj && obj.type === "system" && obj.subtype === "compact_boundary") {
121
+ out.push(obj);
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Convert a compact_boundary JSONL row into our canonical compaction row.
129
+ */
130
+ function boundaryToRow(b) {
131
+ const row = {
132
+ ts: typeof b.timestamp === "string" ? b.timestamp : new Date().toISOString(),
133
+ schemaVersion: SCHEMA_VERSION,
134
+ session_id: typeof b.sessionId === "string" ? b.sessionId : null,
135
+ // `logicalParentUuid` is the last message in the pre-compact window — as
136
+ // close as the archive gets to a "prior session id" for in-place
137
+ // compactions. It's NOT a true session id, but it's a stable boundary
138
+ // anchor that consumers can join on.
139
+ prior_session_id:
140
+ typeof b.logicalParentUuid === "string" ? b.logicalParentUuid : null,
141
+ source: "compact-backfill",
142
+ cwd: typeof b.cwd === "string" ? b.cwd : null,
143
+ hook: "SessionStart",
144
+ };
145
+
146
+ const meta = b.compactMetadata || {};
147
+ if (typeof meta.trigger === "string") row.trigger = meta.trigger;
148
+ if (Number.isFinite(meta.preTokens)) row.preTokens = meta.preTokens;
149
+ if (Number.isFinite(meta.postTokens)) row.postTokens = meta.postTokens;
150
+ if (Number.isFinite(meta.durationMs)) row.durationMs = meta.durationMs;
151
+
152
+ return row;
153
+ }
154
+
155
+ /**
156
+ * Load existing compactions.jsonl (if any) and build a dedup key set.
157
+ * Key = `${ts}\t${session_id}`.
158
+ */
159
+ function loadExistingKeys(compactionsPath) {
160
+ const keys = new Set();
161
+ if (!fs.existsSync(compactionsPath)) return keys;
162
+ let raw;
163
+ try {
164
+ raw = fs.readFileSync(compactionsPath, "utf8");
165
+ } catch {
166
+ return keys;
167
+ }
168
+ for (const line of raw.split("\n")) {
169
+ if (!line.trim()) continue;
170
+ try {
171
+ const obj = JSON.parse(line);
172
+ keys.add(`${obj.ts || ""}\t${obj.session_id || ""}`);
173
+ } catch {
174
+ /* ignore malformed historical rows */
175
+ }
176
+ }
177
+ return keys;
178
+ }
179
+
180
+ function rowKey(row) {
181
+ return `${row.ts}\t${row.session_id || ""}`;
182
+ }
183
+
184
+ /* ───────────────── orchestrator ───────────────── */
185
+
186
+ async function run({ write, projectDir, sessionsRoot, limit, _sessionFiles, _scanSessionFile, _stdout }) {
187
+ const root = sessionsRoot || deriveSessionsRoot(projectDir);
188
+ const stdout = _stdout || ((s) => process.stdout.write(s));
189
+ const scan = _scanSessionFile || scanSessionFile;
190
+
191
+ let files;
192
+ if (Array.isArray(_sessionFiles)) {
193
+ files = _sessionFiles;
194
+ } else if (!fs.existsSync(root)) {
195
+ stdout(`Sessions root does not exist: ${root}\nNothing to do.\n`);
196
+ return { scanned: 0, found: 0, newRows: 0, wrote: 0 };
197
+ } else {
198
+ try {
199
+ files = fs
200
+ .readdirSync(root)
201
+ .filter((f) => f.endsWith(".jsonl"))
202
+ .map((f) => path.join(root, f));
203
+ } catch {
204
+ files = [];
205
+ }
206
+ }
207
+ if (limit && files.length > limit) files = files.slice(0, limit);
208
+
209
+ const metricsDir = path.join(projectDir, ".gsd-t", "metrics");
210
+ const outPath = path.join(metricsDir, "compactions.jsonl");
211
+ const existing = loadExistingKeys(outPath);
212
+
213
+ stdout(`Scanning ${files.length} session file(s) from ${root}\n`);
214
+ stdout(`Target sink: ${outPath} (${existing.size} existing row(s))\n`);
215
+ stdout(write ? "Mode: WRITE\n\n" : "Mode: DRY-RUN (pass --write to mutate)\n\n");
216
+
217
+ const newRows = [];
218
+ let scanned = 0;
219
+ let found = 0;
220
+
221
+ for (const file of files) {
222
+ scanned++;
223
+ let boundaries;
224
+ try {
225
+ boundaries = await scan(file);
226
+ } catch {
227
+ boundaries = [];
228
+ }
229
+ for (const b of boundaries) {
230
+ found++;
231
+ const row = boundaryToRow(b);
232
+ const key = rowKey(row);
233
+ if (existing.has(key)) continue;
234
+ existing.add(key);
235
+ newRows.push(row);
236
+ }
237
+ }
238
+
239
+ // Sort by ts ascending for a stable, chronologically-ordered append.
240
+ newRows.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
241
+
242
+ stdout(`Scanned: ${scanned} file(s)\n`);
243
+ stdout(`Compact boundaries found: ${found}\n`);
244
+ stdout(`New rows (post-dedup): ${newRows.length}\n\n`);
245
+
246
+ if (newRows.length > 0) {
247
+ stdout("Sample (first up to 5):\n");
248
+ for (const r of newRows.slice(0, 5)) {
249
+ stdout(` ${r.ts} session=${r.session_id || "?"} ` +
250
+ `trigger=${r.trigger || "?"} ` +
251
+ `pre=${r.preTokens ?? "?"} post=${r.postTokens ?? "?"}\n`);
252
+ }
253
+ stdout("\n");
254
+ }
255
+
256
+ let wrote = 0;
257
+ if (write && newRows.length > 0) {
258
+ try {
259
+ fs.mkdirSync(metricsDir, { recursive: true });
260
+ const payload = newRows.map((r) => JSON.stringify(r)).join("\n") + "\n";
261
+ fs.appendFileSync(outPath, payload, "utf8");
262
+ wrote = newRows.length;
263
+ stdout(`Wrote ${wrote} row(s) to ${outPath}\n`);
264
+ } catch (e) {
265
+ stdout(`Write failed: ${e && e.message ? e.message : e}\n`);
266
+ }
267
+ } else if (!write) {
268
+ stdout("Dry-run — no changes made. Re-run with --write to persist.\n");
269
+ }
270
+
271
+ return { scanned, found, newRows: newRows.length, wrote };
272
+ }
273
+
274
+ /* ───────────────── CLI ───────────────── */
275
+
276
+ async function main() {
277
+ const args = parseArgs(process.argv.slice(2));
278
+ if (args.help) {
279
+ printHelp();
280
+ process.exit(0);
281
+ }
282
+ try {
283
+ const result = await run(args);
284
+ // Success even when nothing new — exit 0. Exit 1 only on catastrophic
285
+ // error (caught below).
286
+ process.exit(0);
287
+ return result;
288
+ } catch (e) {
289
+ process.stderr.write(`scanner error: ${e && e.message ? e.message : e}\n`);
290
+ process.exit(1);
291
+ }
292
+ }
293
+
294
+ module.exports = {
295
+ run,
296
+ scanSessionFile,
297
+ boundaryToRow,
298
+ deriveSessionsRoot,
299
+ loadExistingKeys,
300
+ rowKey,
301
+ };
302
+
303
+ if (require.main === module) {
304
+ main();
305
+ }
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+ /**
3
+ * GSD-T Dashboard Autostart (M43 D6-T4)
4
+ *
5
+ * Idempotent, silent starter for the transcript/dashboard server. Called at
6
+ * every spawn start path in `bin/headless-auto-spawn.cjs` so the URL banner
7
+ * printed next to it (D6-T3) always resolves to a live listener.
8
+ *
9
+ * Design notes:
10
+ * - The port is resolved via `projectScopedDefaultPort(projectDir)` from
11
+ * the multi-project isolation quick (df34eb2) — each project has its
12
+ * own deterministic default port.
13
+ * - We probe the port with `net.createServer().listen(...)` — if it binds
14
+ * the port was free and we fork-detach the dashboard server. If the
15
+ * probe fails with EADDRINUSE, we assume the server is already running
16
+ * (or some other process grabbed the port; we defer to the user in that
17
+ * case and return alreadyRunning:true — the banner link still points
18
+ * there, and if it's foreign the user sees a friendlier failure than
19
+ * a stacktrace).
20
+ * - PID file lives at `.gsd-t/.dashboard.pid` relative to projectDir.
21
+ * Distinct from M38's `.gsd-t/dashboard.pid` (hyphen vs. dot) so the
22
+ * two lifecycles don't collide.
23
+ *
24
+ * Zero deps.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const net = require('net');
29
+ const path = require('path');
30
+ const { spawn, spawnSync } = require('child_process');
31
+
32
+ const PID_REL = path.join('.gsd-t', '.dashboard.pid');
33
+
34
+ function _pidPath(projectDir) {
35
+ return path.join(projectDir || '.', PID_REL);
36
+ }
37
+
38
+ /**
39
+ * Check if a port is already bound by someone. Resolves `{ busy: bool, reason? }`.
40
+ *
41
+ * @param {number} port
42
+ * @param {string} [host='127.0.0.1']
43
+ * @returns {Promise<{busy: boolean, reason?: string}>}
44
+ */
45
+ function _probePort(port, host) {
46
+ return new Promise((resolve) => {
47
+ const srv = net.createServer();
48
+ let settled = false;
49
+ const finish = (result) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ try { srv.close(); } catch (_) { /* ok */ }
53
+ resolve(result);
54
+ };
55
+ srv.once('error', (err) => {
56
+ if (err && err.code === 'EADDRINUSE') finish({ busy: true, reason: 'EADDRINUSE' });
57
+ else finish({ busy: true, reason: String(err && err.code ? err.code : err) });
58
+ });
59
+ srv.once('listening', () => {
60
+ finish({ busy: false });
61
+ });
62
+ try {
63
+ srv.listen(port, host || '127.0.0.1');
64
+ } catch (err) {
65
+ finish({ busy: true, reason: String(err && err.code ? err.code : err) });
66
+ }
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Synchronous port-busy probe. Forks a tiny node child that attempts to
72
+ * `net.createServer().listen(port)` and exits 1 if EADDRINUSE, else
73
+ * closes the server and exits 0. This gives us a definitive answer
74
+ * in O(50ms) from a synchronous caller — Node's `net.Server#listen` is
75
+ * async, which makes a purely in-process synchronous probe impossible
76
+ * without busy-looping the event loop (which doesn't advance during JS
77
+ * execution).
78
+ *
79
+ * Important: we probe with **no host** to match the dashboard server's
80
+ * `server.listen(port)` (which binds to the IPv6 wildcard `::`). On macOS
81
+ * dual-stack, binding to `127.0.0.1` alongside a wildcard on `::` is
82
+ * permitted and would make a host-specific probe falsely report "free".
83
+ * The `host` parameter is accepted for backward compatibility but, when
84
+ * omitted, we use the host-less form. Passing an explicit host preserves
85
+ * legacy semantics for callers that rely on it.
86
+ *
87
+ * Returns `true` if the port is in use (EADDRINUSE), `false` otherwise.
88
+ * On spawn failure we conservatively return `false` so the caller tries
89
+ * to start the server — it will then fail fast and the caller falls back
90
+ * to the "assume running" path via its own handling.
91
+ */
92
+ function _isPortBusySync(port, host) {
93
+ const listenArgs = host
94
+ ? `${JSON.stringify(port)}, ${JSON.stringify(host)}`
95
+ : `${JSON.stringify(port)}`;
96
+ const script = `
97
+ const net = require('net');
98
+ const srv = net.createServer();
99
+ srv.once('error', (e) => { if (e && e.code === 'EADDRINUSE') process.exit(1); process.exit(2); });
100
+ srv.once('listening', () => { srv.close(() => process.exit(0)); });
101
+ srv.listen(${listenArgs});
102
+ `;
103
+ try {
104
+ const r = spawnSync(process.execPath, ['-e', script], { timeout: 10000, stdio: 'ignore' });
105
+ return r.status === 1;
106
+ } catch (_) {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Ensure the dashboard server is running on the project's scoped port.
113
+ *
114
+ * Idempotent: safe to call on every spawn. Silent: no stdout/stderr writes
115
+ * on the happy path; the detached child's stdio is ignored.
116
+ *
117
+ * **Synchronous contract**: returns immediately with `{port, pid, alreadyRunning}`.
118
+ * - If the port is already bound, returns `alreadyRunning: true`.
119
+ * - Otherwise fork-detaches the dashboard server and records its pid.
120
+ *
121
+ * @param {object} opts
122
+ * @param {string} [opts.projectDir='.']
123
+ * @param {number} [opts.port]
124
+ * @param {string} [opts.host='127.0.0.1']
125
+ * @returns {{port: number, pid: number|null, alreadyRunning: boolean}}
126
+ */
127
+ function ensureDashboardRunning(opts) {
128
+ const projectDir = (opts && opts.projectDir) || '.';
129
+ const host = (opts && opts.host) || '127.0.0.1';
130
+ let port = opts && opts.port;
131
+ if (!port) {
132
+ const srv = require('./gsd-t-dashboard-server.js');
133
+ port = srv.projectScopedDefaultPort(projectDir);
134
+ }
135
+
136
+ // Probe with no host to match the dashboard server's listen(port) — which
137
+ // binds to the IPv6 wildcard. Probing a specific host would falsely report
138
+ // "free" on macOS dual-stack. `host` is retained only for the spawn env.
139
+ const busy = _isPortBusySync(port);
140
+ if (busy) {
141
+ return { port, pid: null, alreadyRunning: true };
142
+ }
143
+
144
+ // Port was free — fork-detach the dashboard server.
145
+ const serverScript = path.join(__dirname, 'gsd-t-dashboard-server.js');
146
+ const child = spawn(process.execPath, [serverScript, '--port', String(port)], {
147
+ cwd: projectDir,
148
+ detached: true,
149
+ stdio: 'ignore',
150
+ env: Object.assign({}, process.env, {
151
+ GSD_T_PROJECT_DIR: path.resolve(projectDir),
152
+ }),
153
+ });
154
+ child.unref();
155
+
156
+ // Record pid. Best-effort; does not throw on filesystem failure.
157
+ const pidFile = _pidPath(projectDir);
158
+ try {
159
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
160
+ fs.writeFileSync(pidFile, String(child.pid));
161
+ } catch (_) { /* pid-file writing is best-effort */ }
162
+
163
+ return { port, pid: child.pid || null, alreadyRunning: false };
164
+ }
165
+
166
+ module.exports = {
167
+ ensureDashboardRunning,
168
+ _probePort,
169
+ _isPortBusySync,
170
+ _pidPath,
171
+ PID_REL,
172
+ };