@tekyzinc/gsd-t 3.16.12 → 3.18.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +13 -3
- package/bin/gsd-t-depgraph-validate.cjs +140 -0
- package/bin/gsd-t-economics.cjs +287 -0
- package/bin/gsd-t-file-disjointness.cjs +227 -0
- package/bin/gsd-t-in-session-usage.cjs +213 -0
- package/bin/gsd-t-orchestrator-config.cjs +100 -3
- package/bin/gsd-t-orchestrator.js +2 -1
- package/bin/gsd-t-parallel.cjs +382 -0
- package/bin/gsd-t-report-tokens.cjs +549 -0
- package/bin/gsd-t-task-graph.cjs +366 -0
- package/bin/gsd-t-token-capture.cjs +29 -14
- package/bin/gsd-t-token-dashboard.cjs +35 -0
- package/bin/gsd-t-tool-attribution.cjs +377 -0
- package/bin/gsd-t-tool-cost.cjs +195 -0
- package/bin/gsd-t-unattended-platform.cjs +7 -1
- package/bin/gsd-t-unattended.cjs +2 -0
- package/bin/gsd-t.js +155 -5
- package/bin/headless-auto-spawn.cjs +69 -49
- package/bin/headless-auto-spawn.js +18 -24
- package/bin/runway-estimator.cjs +212 -0
- package/bin/spawn-plan-derive.cjs +163 -0
- package/bin/spawn-plan-status-updater.cjs +292 -0
- package/bin/spawn-plan-writer.cjs +204 -0
- package/commands/gsd-t-debug.md +26 -7
- package/commands/gsd-t-execute.md +36 -28
- package/commands/gsd-t-help.md +11 -0
- package/commands/gsd-t-integrate.md +27 -7
- package/commands/gsd-t-quick.md +30 -13
- package/commands/gsd-t-scan.md +5 -5
- package/commands/gsd-t-unattended-watch.md +4 -3
- package/commands/gsd-t-unattended.md +9 -3
- package/commands/gsd-t-verify.md +5 -5
- package/commands/gsd-t-wave.md +21 -8
- package/commands/gsd.md +45 -3
- package/docs/GSD-T-README.md +43 -5
- package/docs/architecture.md +423 -3
- package/docs/requirements.md +203 -0
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +256 -0
- package/scripts/gsd-t-compact-detector.js +223 -0
- package/scripts/gsd-t-compaction-scanner.js +305 -0
- package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
- package/scripts/gsd-t-dashboard-server.js +179 -0
- package/scripts/gsd-t-dashboard.html +3 -3
- package/scripts/gsd-t-heartbeat.js +50 -2
- package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
- package/scripts/gsd-t-transcript.html +546 -43
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
- package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
- package/templates/CLAUDE-global.md +8 -3
- package/templates/CLAUDE-project.md +17 -14
- package/templates/hooks/post-commit-spawn-plan.sh +85 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-file-disjointness — M44 D5 (T2: core implementation)
|
|
5
|
+
*
|
|
6
|
+
* Pre-spawn file-disjointness prover. Consumes the task-graph DAG from D1 and
|
|
7
|
+
* partitions a candidate parallel set into:
|
|
8
|
+
* - parallel — groups confirmed pairwise-disjoint (safe to spawn together)
|
|
9
|
+
* - sequential — groups sharing ≥1 write target (must serialize)
|
|
10
|
+
* - unprovable — tasks with no touch-list source (routed sequential; safe-default)
|
|
11
|
+
*
|
|
12
|
+
* Contract: .gsd-t/contracts/file-disjointness-contract.md (v1.0.0)
|
|
13
|
+
*
|
|
14
|
+
* Hard rules (from constraints.md):
|
|
15
|
+
* - Unprovable is ALWAYS safe — never assume disjointness
|
|
16
|
+
* - Zero external runtime deps (Node built-ins + git subprocess only)
|
|
17
|
+
* - Never throws (returns result object)
|
|
18
|
+
* - Read-only on all domain files; only writes to .gsd-t/events/YYYY-MM-DD.jsonl
|
|
19
|
+
* - D5 checks WRITE targets only; reads never conflict
|
|
20
|
+
* - Mode-agnostic
|
|
21
|
+
* - Git-history heuristic bounded to 100 commits
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const path = require("node:path");
|
|
26
|
+
const { execSync } = require("node:child_process");
|
|
27
|
+
|
|
28
|
+
// ─── Event writer (append-only JSONL) ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Append a disjointness_fallback event for a task moved to sequential
|
|
32
|
+
* (including unprovable tasks).
|
|
33
|
+
*
|
|
34
|
+
* Event shape (per T2 spec):
|
|
35
|
+
* { type: 'disjointness_fallback', task_id, reason, ts }
|
|
36
|
+
*
|
|
37
|
+
* Best-effort: filesystem errors are swallowed so the prover never throws.
|
|
38
|
+
*/
|
|
39
|
+
function appendFallbackEvent(projectDir, taskId, reason) {
|
|
40
|
+
try {
|
|
41
|
+
const eventsDir = path.join(projectDir, ".gsd-t", "events");
|
|
42
|
+
fs.mkdirSync(eventsDir, { recursive: true });
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const day = now.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
|
|
45
|
+
const file = path.join(eventsDir, `${day}.jsonl`);
|
|
46
|
+
const entry = {
|
|
47
|
+
type: "disjointness_fallback",
|
|
48
|
+
task_id: taskId,
|
|
49
|
+
reason,
|
|
50
|
+
ts: now.toISOString(),
|
|
51
|
+
};
|
|
52
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
53
|
+
} catch {
|
|
54
|
+
// Swallow — observability only; never block the caller.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Git-history fallback (bounded) ──────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Heuristic touch-list source: scan up to the last 100 commits that touched
|
|
62
|
+
* the domain's directory and collect file paths from commits whose subject
|
|
63
|
+
* mentions the task id. Bounded to prevent runaway I/O on large repos.
|
|
64
|
+
*
|
|
65
|
+
* Returns: string[] — file paths (may be empty). Never throws.
|
|
66
|
+
*/
|
|
67
|
+
function gitHistoryTouches(projectDir, domain, taskId) {
|
|
68
|
+
if (!domain || !taskId) return [];
|
|
69
|
+
const domainDir = path.join(".gsd-t", "domains", domain);
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = execSync(
|
|
73
|
+
`git log --name-only --pretty=format:"COMMIT:%H %s" -n 100 -- "${domainDir}"`,
|
|
74
|
+
{ cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (!raw) return [];
|
|
80
|
+
|
|
81
|
+
const files = new Set();
|
|
82
|
+
let capturing = false;
|
|
83
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
84
|
+
if (line.startsWith("COMMIT:")) {
|
|
85
|
+
// Subject is everything after the sha
|
|
86
|
+
const sp = line.indexOf(" ");
|
|
87
|
+
const subject = sp >= 0 ? line.slice(sp + 1) : "";
|
|
88
|
+
capturing = subject.includes(taskId);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!capturing) continue;
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (trimmed) files.add(trimmed);
|
|
94
|
+
}
|
|
95
|
+
return Array.from(files);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Touch-list resolution ───────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the effective touch list for a task by applying the fallback chain:
|
|
102
|
+
* 1. Explicit `touches` populated by D1 (from **Touches** field or scope.md Files Owned)
|
|
103
|
+
* 2. Git-history heuristic — only when touches is [] (D1 couldn't find anything)
|
|
104
|
+
*
|
|
105
|
+
* Returns: { touches: string[], source: 'declared' | 'git' | 'none' }
|
|
106
|
+
*/
|
|
107
|
+
function resolveTouches(task, projectDir) {
|
|
108
|
+
const declared = Array.isArray(task.touches) ? task.touches : [];
|
|
109
|
+
if (declared.length > 0) {
|
|
110
|
+
return { touches: declared.slice(), source: "declared" };
|
|
111
|
+
}
|
|
112
|
+
// D1 emitted an empty list → scope.md was also empty. Try git history.
|
|
113
|
+
const fromGit = gitHistoryTouches(projectDir, task.domain, task.id);
|
|
114
|
+
if (fromGit.length > 0) {
|
|
115
|
+
return { touches: fromGit, source: "git" };
|
|
116
|
+
}
|
|
117
|
+
return { touches: [], source: "none" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Overlap grouping (union-find over the overlap relation) ─────────────
|
|
121
|
+
|
|
122
|
+
function haveOverlap(a, b) {
|
|
123
|
+
if (!a.length || !b.length) return false;
|
|
124
|
+
const set = new Set(a);
|
|
125
|
+
for (const f of b) {
|
|
126
|
+
if (set.has(f)) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Group provable tasks (those with a non-empty touch list from any source)
|
|
133
|
+
* into connected components over the overlap relation. A singleton component
|
|
134
|
+
* with no overlapping partner is safe to parallelize; a component of size ≥ 2
|
|
135
|
+
* must be serialized.
|
|
136
|
+
*/
|
|
137
|
+
function groupByOverlap(items) {
|
|
138
|
+
// items: [{ task, touches }]
|
|
139
|
+
const n = items.length;
|
|
140
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
141
|
+
const find = (i) => {
|
|
142
|
+
while (parent[i] !== i) {
|
|
143
|
+
parent[i] = parent[parent[i]];
|
|
144
|
+
i = parent[i];
|
|
145
|
+
}
|
|
146
|
+
return i;
|
|
147
|
+
};
|
|
148
|
+
const union = (i, j) => {
|
|
149
|
+
const a = find(i), b = find(j);
|
|
150
|
+
if (a !== b) parent[a] = b;
|
|
151
|
+
};
|
|
152
|
+
for (let i = 0; i < n; i++) {
|
|
153
|
+
for (let j = i + 1; j < n; j++) {
|
|
154
|
+
if (haveOverlap(items[i].touches, items[j].touches)) union(i, j);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const groups = new Map();
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
const root = find(i);
|
|
160
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
161
|
+
groups.get(root).push(items[i].task);
|
|
162
|
+
}
|
|
163
|
+
return Array.from(groups.values());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Prove pairwise file-disjointness across a candidate parallel set.
|
|
170
|
+
*
|
|
171
|
+
* @param {{tasks: object[], projectDir: string}} opts
|
|
172
|
+
* @returns {{parallel: object[][], sequential: object[][], unprovable: object[]}}
|
|
173
|
+
*
|
|
174
|
+
* Never throws. Appends a `disjointness_fallback` event to
|
|
175
|
+
* `.gsd-t/events/YYYY-MM-DD.jsonl` for every task routed sequential
|
|
176
|
+
* (including unprovable tasks).
|
|
177
|
+
*/
|
|
178
|
+
function proveDisjointness(opts) {
|
|
179
|
+
const tasks = (opts && Array.isArray(opts.tasks)) ? opts.tasks : [];
|
|
180
|
+
const projectDir = (opts && opts.projectDir) || process.cwd();
|
|
181
|
+
|
|
182
|
+
const parallel = [];
|
|
183
|
+
const sequential = [];
|
|
184
|
+
const unprovable = [];
|
|
185
|
+
|
|
186
|
+
if (tasks.length === 0) {
|
|
187
|
+
return { parallel, sequential, unprovable };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Resolve each task's effective touch list.
|
|
191
|
+
const provable = []; // [{ task, touches }]
|
|
192
|
+
for (const t of tasks) {
|
|
193
|
+
const { touches, source } = resolveTouches(t, projectDir);
|
|
194
|
+
if (source === "none") {
|
|
195
|
+
unprovable.push(t);
|
|
196
|
+
// Unprovable → always sequential (singleton group). Safe-default.
|
|
197
|
+
sequential.push([t]);
|
|
198
|
+
appendFallbackEvent(projectDir, t.id, "unprovable");
|
|
199
|
+
} else {
|
|
200
|
+
provable.push({ task: t, touches });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Group provable tasks by overlap. Singletons → parallel. Multi → sequential.
|
|
205
|
+
const groups = groupByOverlap(provable);
|
|
206
|
+
for (const group of groups) {
|
|
207
|
+
if (group.length === 1) {
|
|
208
|
+
parallel.push(group);
|
|
209
|
+
} else {
|
|
210
|
+
sequential.push(group);
|
|
211
|
+
for (const t of group) {
|
|
212
|
+
appendFallbackEvent(projectDir, t.id, "write-target-overlap");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { parallel, sequential, unprovable };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
proveDisjointness,
|
|
222
|
+
// Internals exposed for unit tests:
|
|
223
|
+
_haveOverlap: haveOverlap,
|
|
224
|
+
_groupByOverlap: groupByOverlap,
|
|
225
|
+
_resolveTouches: resolveTouches,
|
|
226
|
+
_gitHistoryTouches: gitHistoryTouches,
|
|
227
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* GSD-T In-Session Usage Capture (M43 D1)
|
|
4
|
+
*
|
|
5
|
+
* Writes one schema-v2 row per **assistant turn** of an interactive Claude Code
|
|
6
|
+
* session to `.gsd-t/metrics/token-usage.jsonl`, so the dialog channel is
|
|
7
|
+
* observable alongside the headless spawns M41 already covers.
|
|
8
|
+
*
|
|
9
|
+
* Branch B (transcript-sourced). Claude Code hook payloads (Stop / SessionEnd /
|
|
10
|
+
* PostToolUse) do not carry `usage`, but every payload contains
|
|
11
|
+
* `transcript_path` — the on-disk jsonl Claude Code appends assistant turns to.
|
|
12
|
+
* This module reads the transcript, extracts `message.usage` envelopes per
|
|
13
|
+
* assistant turn (keyed by `message.id` for dedup), and hands each new envelope
|
|
14
|
+
* to `recordSpawnRow` from `bin/gsd-t-token-capture.cjs` with
|
|
15
|
+
* `sessionType: 'in-session'`.
|
|
16
|
+
*
|
|
17
|
+
* Dedup state lives per `session_id` in `.gsd-t/.in-session-cursor.json` —
|
|
18
|
+
* tracks the last-seen `message.id` so repeated Stop fires within the same
|
|
19
|
+
* session only append new rows.
|
|
20
|
+
*
|
|
21
|
+
* Zero external deps. `.cjs` for ESM/CJS compat.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const capture = require('./gsd-t-token-capture.cjs');
|
|
28
|
+
|
|
29
|
+
const CURSOR_REL = path.join('.gsd-t', '.in-session-cursor.json');
|
|
30
|
+
|
|
31
|
+
// ── Cursor state ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function _loadCursor(projectDir) {
|
|
34
|
+
const p = path.join(projectDir, CURSOR_REL);
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _saveCursor(projectDir, state) {
|
|
43
|
+
const p = path.join(projectDir, CURSOR_REL);
|
|
44
|
+
const dir = path.dirname(p);
|
|
45
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Transcript parsing ───────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function _parseJsonLineSafe(line) {
|
|
52
|
+
const s = String(line || '').trim();
|
|
53
|
+
if (!s || s[0] !== '{') return null;
|
|
54
|
+
try { return JSON.parse(s); } catch (_) { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Walk a Claude Code transcript jsonl and return assistant-turn usage entries.
|
|
59
|
+
*
|
|
60
|
+
* Each returned entry: { messageId, model, usage, timestamp, turnIndex }
|
|
61
|
+
* - `messageId` from `message.id` (stable per assistant turn)
|
|
62
|
+
* - `model` from `message.model`
|
|
63
|
+
* - `usage` from `message.usage` (the full envelope — schema lines up with
|
|
64
|
+
* Anthropic API usage + Claude Code's additions like `cache_creation`,
|
|
65
|
+
* `service_tier`, `iterations[]`).
|
|
66
|
+
* - `timestamp` from the transcript line's `timestamp` field (ISO-8601)
|
|
67
|
+
* - `turnIndex` 0-based position among usage-bearing lines in the transcript
|
|
68
|
+
*/
|
|
69
|
+
function extractTurns(transcriptPath) {
|
|
70
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return [];
|
|
71
|
+
const text = fs.readFileSync(transcriptPath, 'utf8');
|
|
72
|
+
const lines = text.split(/\r?\n/);
|
|
73
|
+
const out = [];
|
|
74
|
+
let turnIndex = 0;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const j = _parseJsonLineSafe(line);
|
|
77
|
+
if (!j) continue;
|
|
78
|
+
const msg = j.message;
|
|
79
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
80
|
+
if (msg.role && msg.role !== 'assistant') continue;
|
|
81
|
+
const usage = msg.usage;
|
|
82
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
83
|
+
out.push({
|
|
84
|
+
messageId: msg.id || j.uuid || null,
|
|
85
|
+
model: msg.model || null,
|
|
86
|
+
usage,
|
|
87
|
+
timestamp: j.timestamp || null,
|
|
88
|
+
turnIndex: turnIndex++,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Row emission ─────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
97
|
+
function _fmtDateTime(iso) {
|
|
98
|
+
const d = iso ? new Date(iso) : new Date();
|
|
99
|
+
if (isNaN(d.getTime())) return _fmtDateTime(new Date().toISOString());
|
|
100
|
+
return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Capture usage for one in-session turn.
|
|
105
|
+
*
|
|
106
|
+
* Low-level entry point: caller supplies the usage envelope directly. Used by
|
|
107
|
+
* unit tests and by the hook handler after extracting a turn from the
|
|
108
|
+
* transcript.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} opts
|
|
111
|
+
* @param {string} opts.projectDir
|
|
112
|
+
* @param {string} opts.sessionId
|
|
113
|
+
* @param {string|number} opts.turnId unique-per-session identifier
|
|
114
|
+
* @param {object|null} opts.usage Claude usage envelope (may be null)
|
|
115
|
+
* @param {string} [opts.model]
|
|
116
|
+
* @param {string} [opts.command] e.g. 'in-session' or 'dialog'
|
|
117
|
+
* @param {string} [opts.ts] ISO-8601; defaults to now
|
|
118
|
+
* @returns {{jsonlPath: string, tokenLogPath: string}}
|
|
119
|
+
*/
|
|
120
|
+
function captureInSessionUsage(opts) {
|
|
121
|
+
if (!opts || !opts.projectDir) throw new Error('captureInSessionUsage: projectDir required');
|
|
122
|
+
if (opts.sessionId == null) throw new Error('captureInSessionUsage: sessionId required');
|
|
123
|
+
if (opts.turnId == null) throw new Error('captureInSessionUsage: turnId required');
|
|
124
|
+
|
|
125
|
+
const stamp = _fmtDateTime(opts.ts);
|
|
126
|
+
return capture.recordSpawnRow({
|
|
127
|
+
projectDir: opts.projectDir,
|
|
128
|
+
command: opts.command || 'in-session',
|
|
129
|
+
step: 'turn',
|
|
130
|
+
model: opts.model || 'claude',
|
|
131
|
+
startedAt: stamp,
|
|
132
|
+
endedAt: stamp,
|
|
133
|
+
usage: opts.usage || undefined,
|
|
134
|
+
notes: 'in-session turn',
|
|
135
|
+
sessionId: opts.sessionId,
|
|
136
|
+
turnId: opts.turnId,
|
|
137
|
+
sessionType: 'in-session',
|
|
138
|
+
// Canonical sink is JSONL; token-log.md is regenerated via
|
|
139
|
+
// `gsd-t tokens --regenerate-log` (D3). Avoid polluting the markdown
|
|
140
|
+
// log with per-turn rows (one session can have hundreds of turns).
|
|
141
|
+
skipMarkdownLog: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Process a hook payload (Stop / SessionEnd) — read the transcript it points
|
|
147
|
+
* at, emit rows for every assistant turn we haven't seen yet.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} opts
|
|
150
|
+
* @param {string} opts.projectDir
|
|
151
|
+
* @param {object} opts.payload raw hook payload as written by Claude Code
|
|
152
|
+
* @returns {{sessionId: string|null, emitted: number, skipped: number, reason?: string}}
|
|
153
|
+
*/
|
|
154
|
+
function processHookPayload(opts) {
|
|
155
|
+
const projectDir = (opts && opts.projectDir) || '.';
|
|
156
|
+
const payload = opts && opts.payload;
|
|
157
|
+
if (!payload || typeof payload !== 'object') {
|
|
158
|
+
return { sessionId: null, emitted: 0, skipped: 0, reason: 'no-payload' };
|
|
159
|
+
}
|
|
160
|
+
const sessionId = payload.session_id || payload.sessionId || null;
|
|
161
|
+
const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
|
|
162
|
+
if (!sessionId || !transcriptPath) {
|
|
163
|
+
return { sessionId, emitted: 0, skipped: 0, reason: 'missing-session-or-transcript' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const turns = extractTurns(transcriptPath);
|
|
167
|
+
if (!turns.length) {
|
|
168
|
+
return { sessionId, emitted: 0, skipped: 0, reason: 'no-turns' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cursor = _loadCursor(projectDir);
|
|
172
|
+
const lastSeen = cursor[sessionId] && cursor[sessionId].lastMessageId;
|
|
173
|
+
|
|
174
|
+
let startAt = 0;
|
|
175
|
+
if (lastSeen) {
|
|
176
|
+
const idx = turns.findIndex(t => t.messageId === lastSeen);
|
|
177
|
+
if (idx >= 0) startAt = idx + 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let emitted = 0;
|
|
181
|
+
let skipped = startAt;
|
|
182
|
+
for (let i = startAt; i < turns.length; i++) {
|
|
183
|
+
const turn = turns[i];
|
|
184
|
+
const turnId = turn.messageId || `idx-${turn.turnIndex}`;
|
|
185
|
+
captureInSessionUsage({
|
|
186
|
+
projectDir,
|
|
187
|
+
sessionId,
|
|
188
|
+
turnId,
|
|
189
|
+
usage: turn.usage,
|
|
190
|
+
model: turn.model,
|
|
191
|
+
command: 'in-session',
|
|
192
|
+
ts: turn.timestamp,
|
|
193
|
+
});
|
|
194
|
+
emitted++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const newLast = turns[turns.length - 1];
|
|
198
|
+
cursor[sessionId] = {
|
|
199
|
+
lastMessageId: newLast ? newLast.messageId : null,
|
|
200
|
+
lastTurnIndex: newLast ? newLast.turnIndex : -1,
|
|
201
|
+
lastUpdatedAt: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
_saveCursor(projectDir, cursor);
|
|
204
|
+
|
|
205
|
+
return { sessionId, emitted, skipped };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
captureInSessionUsage,
|
|
210
|
+
processHookPayload,
|
|
211
|
+
extractTurns,
|
|
212
|
+
_internal: { _loadCursor, _saveCursor },
|
|
213
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
|
|
6
7
|
const DEFAULTS = Object.freeze({
|
|
@@ -11,6 +12,79 @@ const DEFAULTS = Object.freeze({
|
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
const MAX_PARALLEL_CEILING = 15;
|
|
15
|
+
const WORKER_RAM_BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
|
|
16
|
+
const ADAPTIVE_FLOOR = 3;
|
|
17
|
+
|
|
18
|
+
// ─── M44 D2 — mode-aware gating math ──────────────────────────────────────
|
|
19
|
+
// Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0 §Mode-Aware Gating Math.
|
|
20
|
+
|
|
21
|
+
const IN_SESSION_CW_CEILING_PCT = 85;
|
|
22
|
+
const UNATTENDED_PER_WORKER_CW_PCT = 60;
|
|
23
|
+
const DEFAULT_SUMMARY_SIZE_PCT = 4;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* [in-session] headroom gate.
|
|
27
|
+
*
|
|
28
|
+
* Returns `{ok, reducedCount}`.
|
|
29
|
+
* ok=true iff `ctxPct + workerCount * summarySize ≤ IN_SESSION_CW_CEILING_PCT`.
|
|
30
|
+
* Otherwise reduces N repeatedly; final floor is N=1. NEVER refuses
|
|
31
|
+
* (constraints.md: never throw a pause/resume prompt under any condition).
|
|
32
|
+
*
|
|
33
|
+
* - reducedCount = the largest N ≤ requested workerCount such that the
|
|
34
|
+
* headroom inequality holds. If the inequality fails for every N ≥ 1
|
|
35
|
+
* (e.g. ctxPct already > ceiling), returns { ok: true, reducedCount: 1 }
|
|
36
|
+
* — sequential always remains feasible because the 4% summary is only
|
|
37
|
+
* spent *post*-worker, and one worker is the irreducible floor.
|
|
38
|
+
*/
|
|
39
|
+
function computeInSessionHeadroom(opts) {
|
|
40
|
+
const o = opts || {};
|
|
41
|
+
const ctxPct = Number.isFinite(o.ctxPct) ? o.ctxPct : 0;
|
|
42
|
+
const requested = Number.isFinite(o.workerCount) ? Math.max(0, Math.floor(o.workerCount)) : 0;
|
|
43
|
+
const summarySize = Number.isFinite(o.summarySize) ? o.summarySize : DEFAULT_SUMMARY_SIZE_PCT;
|
|
44
|
+
const ceiling = IN_SESSION_CW_CEILING_PCT;
|
|
45
|
+
|
|
46
|
+
// Direct fit.
|
|
47
|
+
if (ctxPct + requested * summarySize <= ceiling) {
|
|
48
|
+
return { ok: true, reducedCount: requested };
|
|
49
|
+
}
|
|
50
|
+
// Reduce N until it fits or we hit the floor.
|
|
51
|
+
let n = requested - 1;
|
|
52
|
+
while (n > 1) {
|
|
53
|
+
if (ctxPct + n * summarySize <= ceiling) {
|
|
54
|
+
return { ok: true, reducedCount: n };
|
|
55
|
+
}
|
|
56
|
+
n -= 1;
|
|
57
|
+
}
|
|
58
|
+
// Floor: 1 worker (sequential). Never refuses.
|
|
59
|
+
return { ok: true, reducedCount: 1 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* [unattended] per-worker CW gate.
|
|
64
|
+
*
|
|
65
|
+
* Returns `{ok, split}`.
|
|
66
|
+
* ok=true, split=false if `estimatedCwPct ≤ threshold` (default 60).
|
|
67
|
+
* ok=false, split=true otherwise — caller MUST slice the task into
|
|
68
|
+
* multiple `claude -p` iters (actual splitting is scheduled by the
|
|
69
|
+
* caller; this function only signals the split requirement).
|
|
70
|
+
*/
|
|
71
|
+
function computeUnattendedGate(opts) {
|
|
72
|
+
const o = opts || {};
|
|
73
|
+
const estimatedCwPct = Number.isFinite(o.estimatedCwPct) ? o.estimatedCwPct : 0;
|
|
74
|
+
const threshold = Number.isFinite(o.threshold) ? o.threshold : UNATTENDED_PER_WORKER_CW_PCT;
|
|
75
|
+
if (estimatedCwPct > threshold) {
|
|
76
|
+
return { ok: false, split: true };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, split: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function computeAdaptiveMaxParallel(freeBytes) {
|
|
82
|
+
const free = typeof freeBytes === 'number' ? freeBytes : os.freemem();
|
|
83
|
+
if (!Number.isFinite(free) || free <= 0) return ADAPTIVE_FLOOR;
|
|
84
|
+
const byMemory = Math.floor(free / WORKER_RAM_BUDGET_BYTES);
|
|
85
|
+
const clamped = Math.max(ADAPTIVE_FLOOR, Math.min(MAX_PARALLEL_CEILING, byMemory));
|
|
86
|
+
return clamped;
|
|
87
|
+
}
|
|
14
88
|
|
|
15
89
|
function loadConfigFile(projectDir) {
|
|
16
90
|
const p = path.join(projectDir, '.gsd-t', 'orchestrator.config.json');
|
|
@@ -29,19 +103,28 @@ function parseIntStrict(v, name) {
|
|
|
29
103
|
}
|
|
30
104
|
|
|
31
105
|
function loadConfig(opts) {
|
|
32
|
-
const { projectDir, cliFlags = {}, env = process.env } = opts || {};
|
|
106
|
+
const { projectDir, cliFlags = {}, env = process.env, freeMemBytes } = opts || {};
|
|
33
107
|
if (!projectDir) throw new Error('loadConfig requires projectDir');
|
|
34
108
|
|
|
35
109
|
const fileCfg = loadConfigFile(projectDir);
|
|
110
|
+
const fileSetMaxParallel = Object.prototype.hasOwnProperty.call(fileCfg, 'maxParallel');
|
|
36
111
|
const merged = { ...DEFAULTS, ...fileCfg };
|
|
112
|
+
let maxParallelSource = fileSetMaxParallel ? 'config-file' : 'adaptive';
|
|
113
|
+
if (!fileSetMaxParallel) {
|
|
114
|
+
merged.maxParallel = computeAdaptiveMaxParallel(freeMemBytes);
|
|
115
|
+
}
|
|
37
116
|
|
|
38
|
-
if (cliFlags.maxParallel != null)
|
|
117
|
+
if (cliFlags.maxParallel != null) {
|
|
118
|
+
merged.maxParallel = parseIntStrict(cliFlags.maxParallel, '--max-parallel');
|
|
119
|
+
maxParallelSource = 'cli';
|
|
120
|
+
}
|
|
39
121
|
if (cliFlags.workerTimeoutMs != null) merged.workerTimeoutMs = parseIntStrict(cliFlags.workerTimeoutMs, '--worker-timeout');
|
|
40
122
|
if (cliFlags.retryOnFail != null) merged.retryOnFail = !!cliFlags.retryOnFail;
|
|
41
123
|
if (cliFlags.haltOnSecondFail != null) merged.haltOnSecondFail = !!cliFlags.haltOnSecondFail;
|
|
42
124
|
|
|
43
125
|
if (env.GSD_T_MAX_PARALLEL != null && env.GSD_T_MAX_PARALLEL !== '') {
|
|
44
126
|
merged.maxParallel = parseIntStrict(env.GSD_T_MAX_PARALLEL, 'GSD_T_MAX_PARALLEL');
|
|
127
|
+
maxParallelSource = 'env';
|
|
45
128
|
}
|
|
46
129
|
if (env.GSD_T_WORKER_TIMEOUT_MS != null && env.GSD_T_WORKER_TIMEOUT_MS !== '') {
|
|
47
130
|
merged.workerTimeoutMs = parseIntStrict(env.GSD_T_WORKER_TIMEOUT_MS, 'GSD_T_WORKER_TIMEOUT_MS');
|
|
@@ -58,7 +141,21 @@ function loadConfig(opts) {
|
|
|
58
141
|
}
|
|
59
142
|
|
|
60
143
|
merged.projectDir = projectDir;
|
|
144
|
+
merged.maxParallelSource = maxParallelSource;
|
|
61
145
|
return merged;
|
|
62
146
|
}
|
|
63
147
|
|
|
64
|
-
module.exports = {
|
|
148
|
+
module.exports = {
|
|
149
|
+
loadConfig,
|
|
150
|
+
DEFAULTS,
|
|
151
|
+
MAX_PARALLEL_CEILING,
|
|
152
|
+
computeAdaptiveMaxParallel,
|
|
153
|
+
WORKER_RAM_BUDGET_BYTES,
|
|
154
|
+
ADAPTIVE_FLOOR,
|
|
155
|
+
// M44 D2 — mode-aware gating math
|
|
156
|
+
computeInSessionHeadroom,
|
|
157
|
+
computeUnattendedGate,
|
|
158
|
+
IN_SESSION_CW_CEILING_PCT,
|
|
159
|
+
UNATTENDED_PER_WORKER_CW_PCT,
|
|
160
|
+
DEFAULT_SUMMARY_SIZE_PCT,
|
|
161
|
+
};
|
|
@@ -51,7 +51,8 @@ function printHelp() {
|
|
|
51
51
|
'',
|
|
52
52
|
'Options:',
|
|
53
53
|
' --milestone <id> Milestone id (e.g. M40). Required.',
|
|
54
|
-
' --max-parallel <n> Max concurrent workers (default
|
|
54
|
+
' --max-parallel <n> Max concurrent workers (default: adaptive by free RAM,',
|
|
55
|
+
' 1 worker per 2GB, floor 3, ceiling 15).',
|
|
55
56
|
' --worker-timeout <ms> Per-worker timeout in ms (default 270000).',
|
|
56
57
|
' --project-dir <path> Project directory (default cwd).',
|
|
57
58
|
' --resume Resume from .gsd-t/orchestrator/state.json.',
|