@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
|
@@ -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
|
+
};
|