@tekyzinc/gsd-t 3.16.11 → 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 +28 -7
  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,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) merged.maxParallel = parseIntStrict(cliFlags.maxParallel, '--max-parallel');
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 = { loadConfig, DEFAULTS, MAX_PARALLEL_CEILING };
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 3, max 15).',
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.',