@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +67 -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-dashboard.html +3 -3
  46. package/scripts/gsd-t-heartbeat.js +50 -2
  47. package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
  48. package/scripts/gsd-t-transcript.html +546 -43
  49. package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
  50. package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
  51. package/templates/CLAUDE-global.md +8 -3
  52. package/templates/CLAUDE-project.md +17 -14
  53. package/templates/hooks/post-commit-spawn-plan.sh +85 -0
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD-T Spawn Plan Derive (M44 D8 T4)
5
+ *
6
+ * Deterministic projection of `.gsd-t/partition.md` + `.gsd-t/domains/*\/tasks.md`
7
+ * into the current "incomplete tasks" slice. No LLM calls, no prompts, no
8
+ * heuristics — just parsing + slicing.
9
+ *
10
+ * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
11
+ *
12
+ * Consumed by: `bin/spawn-plan-writer.cjs` (when caller does NOT pass an
13
+ * explicit `tasks: [...]` array).
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ /**
20
+ * Derive a `{milestone, wave, domains, tasks}` slice from on-disk partition
21
+ * + tasks. The "current incomplete slice" is the next contiguous run of
22
+ * tasks whose checkboxes are still unchecked (`[ ]`) across all domains in
23
+ * the current wave.
24
+ *
25
+ * Gracefully returns an empty slice when partition.md is absent or no
26
+ * incomplete tasks remain. Never throws for missing files.
27
+ *
28
+ * @param {object} [opts]
29
+ * @param {string} [opts.projectDir='.']
30
+ * @param {string} [opts.milestone] filter hint (e.g. 'M44'); defaults to parsed from partition
31
+ * @param {string|number} [opts.currentIter] unused today; reserved for future iter-slicing
32
+ * @returns {{ milestone: string|null, wave: string|null, domains: string[], tasks: Array<{id:string,title:string,status:string}> }}
33
+ */
34
+ function derivePlanFromPartition(opts) {
35
+ const projectDir = (opts && opts.projectDir) || '.';
36
+ const result = { milestone: null, wave: null, domains: [], tasks: [] };
37
+
38
+ const partitionPath = path.join(projectDir, '.gsd-t', 'partition.md');
39
+ if (!fs.existsSync(partitionPath)) {
40
+ return result;
41
+ }
42
+
43
+ let partitionText;
44
+ try {
45
+ partitionText = fs.readFileSync(partitionPath, 'utf8');
46
+ } catch (_) {
47
+ return result;
48
+ }
49
+
50
+ // Parse the milestone id (first `M\d+` in heading) and the wave we care
51
+ // about. For now we pick the first wave with any incomplete tasks.
52
+ const milestoneMatch = partitionText.match(/\b(M\d+)\b/);
53
+ if (milestoneMatch) result.milestone = milestoneMatch[1];
54
+ if (opts && opts.milestone) result.milestone = String(opts.milestone);
55
+
56
+ // Enumerate domain dirs under .gsd-t/domains/
57
+ const domainsRoot = path.join(projectDir, '.gsd-t', 'domains');
58
+ const domains = _listDomains(domainsRoot, result.milestone);
59
+ if (!domains.length) return result;
60
+
61
+ // Collect incomplete tasks from each domain's tasks.md, grouped by wave.
62
+ // If no explicit wave header is present, "unknown" groups them.
63
+ const byWave = new Map();
64
+ for (const d of domains) {
65
+ const tasks = _parseTasks(path.join(domainsRoot, d, 'tasks.md'));
66
+ for (const t of tasks) {
67
+ if (!byWave.has(t.wave)) byWave.set(t.wave, []);
68
+ byWave.get(t.wave).push({ ...t, domain: d });
69
+ }
70
+ }
71
+
72
+ // Pick the wave with the most incomplete tasks; ties: first seen.
73
+ let pickWave = null;
74
+ let pickCount = -1;
75
+ for (const [w, arr] of byWave) {
76
+ const incomplete = arr.filter((t) => t.status !== 'done').length;
77
+ if (incomplete > pickCount) {
78
+ pickCount = incomplete;
79
+ pickWave = w;
80
+ }
81
+ }
82
+ if (pickWave == null) return result;
83
+
84
+ const waveTasks = byWave.get(pickWave) || [];
85
+ result.wave = pickWave;
86
+ const seenDomains = new Set();
87
+ for (const t of waveTasks) {
88
+ if (t.domain) seenDomains.add(t.domain);
89
+ result.tasks.push({
90
+ id: t.id,
91
+ title: t.title,
92
+ status: t.status,
93
+ });
94
+ }
95
+ result.domains = [...seenDomains];
96
+ return result;
97
+ }
98
+
99
+ // ── internal parsers ──────────────────────────────────────────────────────
100
+
101
+ function _listDomains(dir, milestone) {
102
+ if (!fs.existsSync(dir)) return [];
103
+ let entries;
104
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
105
+ const names = [];
106
+ for (const e of entries) {
107
+ if (!e.isDirectory()) continue;
108
+ if (milestone) {
109
+ // Normalize to lowercase m-prefix so we match `m44-d8-…`
110
+ const prefix = String(milestone).toLowerCase() + '-';
111
+ if (!e.name.toLowerCase().startsWith(prefix)) continue;
112
+ }
113
+ names.push(e.name);
114
+ }
115
+ return names.sort();
116
+ }
117
+
118
+ /**
119
+ * Parse a domain tasks.md into a list of `{id, title, status, wave}`.
120
+ * Recognizes:
121
+ * `- [ ] **M44-D8-T1** — title…`
122
+ * `- [x] **M44-D8-T1** — title…`
123
+ * `- [x] done (2026-04-23 · commit abc123) **M44-D8-T1** — title…`
124
+ * Task-id pattern is `M\d+-D\d+-T\d+`. Wave comes from the nearest
125
+ * preceding `## Wave N` header.
126
+ */
127
+ function _parseTasks(tasksPath) {
128
+ if (!fs.existsSync(tasksPath)) return [];
129
+ let text;
130
+ try { text = fs.readFileSync(tasksPath, 'utf8'); } catch (_) { return []; }
131
+
132
+ const out = [];
133
+ let currentWave = 'unknown';
134
+ const lines = text.split('\n');
135
+ for (const line of lines) {
136
+ const waveMatch = line.match(/^##\s+Wave\s+(\d+)\b/i);
137
+ if (waveMatch) {
138
+ currentWave = 'wave-' + waveMatch[1];
139
+ continue;
140
+ }
141
+ const m = line.match(/^-\s*\[([ xX])\][^\*]*\*\*(M\d+-D\d+-T\d+)\*\*\s*[—-]\s*(.+)$/);
142
+ if (!m) continue;
143
+ const checked = m[1].toLowerCase() === 'x';
144
+ const id = m[2];
145
+ let title = m[3].trim();
146
+ // Strip trailing " — sub-note" if present; keep only the primary title.
147
+ // The task title may contain em-dashes; safer to just use the first line.
148
+ title = title.split(/\s{2,}/)[0].trim();
149
+ out.push({
150
+ id,
151
+ title,
152
+ status: checked ? 'done' : 'pending',
153
+ wave: currentWave,
154
+ });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ module.exports = {
160
+ derivePlanFromPartition,
161
+ _parseTasks,
162
+ _listDomains,
163
+ };
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD-T Spawn Plan Status Updater (M44 D8 T2)
5
+ *
6
+ * Pure module — patches spawn-plan files written by spawn-plan-writer.cjs.
7
+ * Called by the post-commit git hook and by `captureSpawn` on completion.
8
+ *
9
+ * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
10
+ *
11
+ * Hard rules:
12
+ * - Atomic rewrites only (temp file + rename)
13
+ * - No-op on unknown spawnId or unknown taskId — observability is best-effort
14
+ * - Never throw for missing files; callers rely on silent-fail behavior
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const SPAWNS_SUBDIR = path.join('.gsd-t', 'spawns');
21
+
22
+ /**
23
+ * Patch `{spawnId}.json`: find the task with the given id and flip its
24
+ * status to `done`, recording the commit SHA and (optional) token
25
+ * attribution. If the next pending task exists, promote it to
26
+ * `in_progress` so the single-active-task invariant is preserved.
27
+ *
28
+ * @param {object} opts
29
+ * @param {string} opts.spawnId
30
+ * @param {string} opts.taskId
31
+ * @param {string} [opts.commit] short or full SHA
32
+ * @param {object|null} [opts.tokens] `{in,out,cr,cc,cost_usd}` or null
33
+ * @param {string} [opts.projectDir='.']
34
+ * @returns {{ patched: boolean, path: string }}
35
+ */
36
+ function markTaskDone(opts) {
37
+ if (!opts || typeof opts !== 'object') return { patched: false, path: null };
38
+ const { spawnId, taskId } = opts;
39
+ if (!spawnId || !taskId) return { patched: false, path: null };
40
+
41
+ const projectDir = opts.projectDir || '.';
42
+ const fp = _planPath(projectDir, spawnId);
43
+ const plan = _readPlan(fp);
44
+ if (!plan) return { patched: false, path: fp };
45
+
46
+ const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
47
+ const idx = tasks.findIndex((t) => t && t.id === taskId);
48
+ if (idx < 0) return { patched: false, path: fp };
49
+
50
+ const task = tasks[idx];
51
+ task.status = 'done';
52
+ if (typeof opts.commit === 'string' && opts.commit) task.commit = opts.commit;
53
+ // Token attribution is "null when absent" per constraints §Format rules.
54
+ // The caller passes `null` or an object; we never silently overwrite
55
+ // an already-populated tokens field with null.
56
+ if (Object.prototype.hasOwnProperty.call(opts, 'tokens')) {
57
+ if (opts.tokens && typeof opts.tokens === 'object') {
58
+ task.tokens = _normalizeTokens(opts.tokens);
59
+ } else if (task.tokens == null) {
60
+ task.tokens = null;
61
+ }
62
+ }
63
+
64
+ // Promote the next pending task to in_progress (single-active invariant).
65
+ const anyInProgress = tasks.some((t) => t && t.status === 'in_progress');
66
+ if (!anyInProgress) {
67
+ for (let j = idx + 1; j < tasks.length; j++) {
68
+ if (tasks[j] && tasks[j].status === 'pending') {
69
+ tasks[j].status = 'in_progress';
70
+ break;
71
+ }
72
+ }
73
+ }
74
+
75
+ _atomicWriteJson(fp, plan);
76
+ return { patched: true, path: fp };
77
+ }
78
+
79
+ /**
80
+ * Mark a spawn plan as ended. Sets `endedAt` to now-ISO and records
81
+ * `endedReason` ('success' | 'error' | caller-supplied). No-op on missing
82
+ * file. Atomic rewrite.
83
+ *
84
+ * @param {object} opts
85
+ * @param {string} opts.spawnId
86
+ * @param {string} [opts.endedReason='success']
87
+ * @param {string} [opts.projectDir='.']
88
+ * @param {Date} [opts.now]
89
+ * @returns {{ patched: boolean, path: string }}
90
+ */
91
+ function markSpawnEnded(opts) {
92
+ if (!opts || typeof opts !== 'object') return { patched: false, path: null };
93
+ const { spawnId } = opts;
94
+ if (!spawnId) return { patched: false, path: null };
95
+
96
+ const projectDir = opts.projectDir || '.';
97
+ const fp = _planPath(projectDir, spawnId);
98
+ const plan = _readPlan(fp);
99
+ if (!plan) return { patched: false, path: fp };
100
+
101
+ const now = opts.now instanceof Date ? opts.now : new Date();
102
+ plan.endedAt = now.toISOString();
103
+ plan.endedReason = opts.endedReason || 'success';
104
+ _atomicWriteJson(fp, plan);
105
+ return { patched: true, path: fp };
106
+ }
107
+
108
+ /**
109
+ * Enumerate active plan files (those where `endedAt === null`). Returns
110
+ * absolute file paths, sorted by startedAt descending.
111
+ *
112
+ * @param {string} [projectDir='.']
113
+ * @returns {string[]}
114
+ */
115
+ function listActivePlans(projectDir) {
116
+ const dir = path.join(projectDir || '.', SPAWNS_SUBDIR);
117
+ if (!fs.existsSync(dir)) return [];
118
+ let files;
119
+ try { files = fs.readdirSync(dir); } catch (_) { return []; }
120
+ const results = [];
121
+ for (const f of files) {
122
+ if (!f.endsWith('.json')) continue;
123
+ const fp = path.resolve(path.join(dir, f));
124
+ const plan = _readPlan(fp);
125
+ if (plan && plan.endedAt == null) results.push({ fp, plan });
126
+ }
127
+ results.sort((a, b) => {
128
+ const ta = Date.parse(a.plan && a.plan.startedAt || '') || 0;
129
+ const tb = Date.parse(b.plan && b.plan.startedAt || '') || 0;
130
+ return tb - ta;
131
+ });
132
+ return results.map((r) => r.fp);
133
+ }
134
+
135
+ // ── token-log attribution lookup ───────────────────────────────────────────
136
+
137
+ /**
138
+ * Parse `.gsd-t/token-log.md` and return the sum of `{in,out,cr,cc,cost_usd}`
139
+ * across all rows whose `Task` column matches the given id and whose
140
+ * `Datetime-start` is >= the spawn's startedAt.
141
+ *
142
+ * Returns `null` when no matching rows exist (per the "zero is a
143
+ * measurement, dash is acknowledged gap" rule).
144
+ *
145
+ * @param {object} opts
146
+ * @param {string} opts.projectDir
147
+ * @param {string} opts.taskId
148
+ * @param {string} opts.spawnStartedAt ISO-8601 (matches plan.startedAt)
149
+ * @param {string} [opts.tokenLogPath] override for tests
150
+ * @returns {{in:number, out:number, cr:number, cc:number, cost_usd:number}|null}
151
+ */
152
+ function sumTokensForTask(opts) {
153
+ if (!opts || typeof opts !== 'object') return null;
154
+ const { projectDir, taskId } = opts;
155
+ if (!projectDir || !taskId) return null;
156
+
157
+ const fp = opts.tokenLogPath || path.join(projectDir, '.gsd-t', 'token-log.md');
158
+ if (!fs.existsSync(fp)) return null;
159
+
160
+ const startMs = _parseDatetime(opts.spawnStartedAt);
161
+
162
+ let text;
163
+ try { text = fs.readFileSync(fp, 'utf8'); } catch (_) { return null; }
164
+
165
+ const lines = text.split('\n');
166
+ // Find header row to locate column indices.
167
+ const headerIdx = lines.findIndex((l) => /^\|\s*Datetime-start\s*\|/.test(l));
168
+ if (headerIdx < 0) return null;
169
+ const header = lines[headerIdx].split('|').map((s) => s.trim());
170
+ const iStart = header.indexOf('Datetime-start');
171
+ const iTokens = header.indexOf('Tokens');
172
+ const iTask = header.indexOf('Task');
173
+ if (iStart < 0 || iTask < 0) return null;
174
+
175
+ const sum = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
176
+ let matched = 0;
177
+ for (let i = headerIdx + 2; i < lines.length; i++) {
178
+ const row = lines[i];
179
+ if (!row || !row.startsWith('|')) continue;
180
+ const cells = row.split('|').map((s) => s.trim());
181
+ if (cells.length < header.length) continue;
182
+ const startedAt = cells[iStart] || '';
183
+ const task = cells[iTask] || '';
184
+ if (task !== taskId) continue;
185
+ // Only rows whose Datetime-start >= spawn.startedAt count.
186
+ if (startMs != null) {
187
+ const rowMs = _parseDatetime(startedAt);
188
+ if (rowMs != null && rowMs < startMs) continue;
189
+ }
190
+ const tokensCell = iTokens >= 0 ? (cells[iTokens] || '') : '';
191
+ const parsed = _parseTokensCell(tokensCell);
192
+ if (parsed) {
193
+ sum.in += parsed.in;
194
+ sum.out += parsed.out;
195
+ sum.cr += parsed.cr;
196
+ sum.cc += parsed.cc;
197
+ sum.cost_usd += parsed.cost_usd;
198
+ matched++;
199
+ } else {
200
+ // No token data in the row; still counts as a "match" for the task
201
+ // but contributes zero. Keep going.
202
+ matched++;
203
+ }
204
+ }
205
+ if (matched === 0) return null;
206
+ // Round cost to 2 decimals for determinism.
207
+ sum.cost_usd = Math.round(sum.cost_usd * 100) / 100;
208
+ return sum;
209
+ }
210
+
211
+ // ── internal helpers ───────────────────────────────────────────────────────
212
+
213
+ function _planPath(projectDir, spawnId) {
214
+ return path.resolve(path.join(projectDir, SPAWNS_SUBDIR, spawnId + '.json'));
215
+ }
216
+
217
+ function _readPlan(fp) {
218
+ try {
219
+ const raw = fs.readFileSync(fp, 'utf8');
220
+ const parsed = JSON.parse(raw);
221
+ if (!parsed || typeof parsed !== 'object') return null;
222
+ return parsed;
223
+ } catch (_) {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ function _atomicWriteJson(targetPath, obj) {
229
+ const dir = path.dirname(targetPath);
230
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
231
+ const tmp = targetPath + '.tmp-' + process.pid + '-' + Date.now();
232
+ const json = JSON.stringify(obj, null, 2) + '\n';
233
+ fs.writeFileSync(tmp, json);
234
+ fs.renameSync(tmp, targetPath);
235
+ }
236
+
237
+ function _normalizeTokens(t) {
238
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : Number(v) || 0);
239
+ return {
240
+ in: num(t.in != null ? t.in : t.input_tokens),
241
+ out: num(t.out != null ? t.out : t.output_tokens),
242
+ cr: num(t.cr != null ? t.cr : t.cache_read_input_tokens),
243
+ cc: num(t.cc != null ? t.cc : t.cache_creation_input_tokens),
244
+ cost_usd: num(
245
+ t.cost_usd != null
246
+ ? t.cost_usd
247
+ : (t.total_cost_usd != null ? t.total_cost_usd : 0)
248
+ ),
249
+ };
250
+ }
251
+
252
+ function _parseDatetime(s) {
253
+ if (!s) return null;
254
+ if (typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
255
+ const [d, t] = s.split(' ');
256
+ const ms = Date.parse(`${d}T${t}:00`);
257
+ return Number.isFinite(ms) ? ms : null;
258
+ }
259
+ const ms = Date.parse(s);
260
+ return Number.isFinite(ms) ? ms : null;
261
+ }
262
+
263
+ /**
264
+ * Parse a tokens cell of the form `in=N out=N cr=N cc=N $X.XX` or `—`
265
+ * or a mixed form. Returns null when no numeric data is present.
266
+ */
267
+ function _parseTokensCell(cell) {
268
+ if (!cell || cell === '—' || cell === '-') return null;
269
+ const num = (re) => {
270
+ const m = cell.match(re);
271
+ return m ? Number(m[1]) : 0;
272
+ };
273
+ const inp = num(/\bin=(\d+)/);
274
+ const out = num(/\bout=(\d+)/);
275
+ const cr = num(/\bcr=(\d+)/);
276
+ const cc = num(/\bcc=(\d+)/);
277
+ const costM = cell.match(/\$(\d+(?:\.\d+)?)/);
278
+ const cost_usd = costM ? Number(costM[1]) : 0;
279
+ if (!inp && !out && !cr && !cc && !cost_usd) return null;
280
+ return { in: inp, out, cr, cc, cost_usd };
281
+ }
282
+
283
+ module.exports = {
284
+ markTaskDone,
285
+ markSpawnEnded,
286
+ listActivePlans,
287
+ sumTokensForTask,
288
+ // test-only
289
+ _parseTokensCell,
290
+ _parseDatetime,
291
+ _normalizeTokens,
292
+ };
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD-T Spawn Plan Writer (M44 D8 T1)
5
+ *
6
+ * Pure module — writes a spawn-plan JSON file under `.gsd-t/spawns/{spawnId}.json`
7
+ * at every spawn chokepoint (captureSpawn, autoSpawnHeadless, unattended-worker
8
+ * resume Step 0). The plan file answers exactly one question:
9
+ *
10
+ * "Of the tasks that were supposed to happen in this spawn, which are done,
11
+ * which are in flight, which are pending?"
12
+ *
13
+ * Hard rules (see .gsd-t/domains/m44-d8-spawn-plan-visibility/constraints.md):
14
+ * 1. Writer DERIVES, never decides — no LLM calls, no prompts, no heuristics
15
+ * beyond reading partition.md + tasks.md.
16
+ * 2. Spawn must launch even if writer fails — callers wrap in try/catch.
17
+ * 3. Atomic writes only (temp file + rename).
18
+ *
19
+ * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
20
+ *
21
+ * Zero external deps. `.cjs` so it loads in both ESM-default and CJS projects.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const SPAWNS_SUBDIR = path.join('.gsd-t', 'spawns');
28
+ const SPAWN_PLAN_SCHEMA_VERSION = 1;
29
+
30
+ /**
31
+ * Write a spawn-plan file at `.gsd-t/spawns/{spawnId}.json` using an atomic
32
+ * temp-file + rename. Creates the `.gsd-t/spawns/` directory and its
33
+ * `.gitkeep` sentinel if missing.
34
+ *
35
+ * If `tasks` or `domains` are not explicitly supplied, the writer tries
36
+ * to derive them from `.gsd-t/partition.md` + `.gsd-t/domains/*\/tasks.md`
37
+ * via the companion `spawn-plan-derive.cjs` module. When derivation fails
38
+ * (no partition file, malformed tasks.md, ENOENT), the writer falls back to
39
+ * `{tasks: [], note: "no-partition"}` and STILL writes the file — the
40
+ * observability panel's job is to render whatever is present; never to block.
41
+ *
42
+ * @param {object} opts
43
+ * @param {string} opts.spawnId filesystem-safe id
44
+ * @param {'unattended-worker'|'headless-detached'|'in-session-subagent'} opts.kind
45
+ * @param {string} [opts.milestone] e.g. 'M44'
46
+ * @param {string} [opts.wave] e.g. 'wave-3'
47
+ * @param {string[]} [opts.domains] domain names involved
48
+ * @param {Array<{id: string, title: string, status?: string}>} [opts.tasks]
49
+ * explicit task list
50
+ * (bypasses derivation)
51
+ * @param {string} [opts.projectDir='.']
52
+ * @param {Date} [opts.now] injection for tests
53
+ * @returns {string} absolute path written
54
+ */
55
+ function writeSpawnPlan(opts) {
56
+ if (!opts || typeof opts !== 'object') {
57
+ throw new Error('writeSpawnPlan: opts is required');
58
+ }
59
+ const spawnId = _sanitizeSpawnId(opts.spawnId);
60
+ if (!spawnId) {
61
+ throw new Error('writeSpawnPlan: spawnId is required and must be filesystem-safe');
62
+ }
63
+ const kind = opts.kind || 'in-session-subagent';
64
+ const projectDir = opts.projectDir || '.';
65
+ const now = opts.now instanceof Date ? opts.now : new Date();
66
+
67
+ const spawnsDir = path.join(projectDir, SPAWNS_SUBDIR);
68
+ _ensureDir(spawnsDir);
69
+ _ensureGitkeep(spawnsDir);
70
+
71
+ // Derive or accept explicit plan slice. Explicit wins.
72
+ let plan = _buildPlanSkeleton({ spawnId, kind, now });
73
+ plan.milestone = opts.milestone || null;
74
+ plan.wave = opts.wave || null;
75
+ plan.domains = Array.isArray(opts.domains) ? opts.domains.slice() : [];
76
+ plan.tasks = Array.isArray(opts.tasks)
77
+ ? opts.tasks.map(_normalizeTask)
78
+ : [];
79
+
80
+ // Only auto-derive when caller did NOT explicitly supply tasks. A caller
81
+ // can pass `tasks: []` to bypass derivation too.
82
+ if (!Array.isArray(opts.tasks)) {
83
+ try {
84
+ const derive = require('./spawn-plan-derive.cjs');
85
+ const derived = derive.derivePlanFromPartition({
86
+ projectDir,
87
+ milestone: opts.milestone,
88
+ currentIter: opts.currentIter,
89
+ });
90
+ if (derived && typeof derived === 'object') {
91
+ if (!plan.milestone && derived.milestone) plan.milestone = derived.milestone;
92
+ if (!plan.wave && derived.wave) plan.wave = derived.wave;
93
+ if (!plan.domains.length && Array.isArray(derived.domains)) plan.domains = derived.domains.slice();
94
+ if (Array.isArray(derived.tasks)) plan.tasks = derived.tasks.map(_normalizeTask);
95
+ }
96
+ } catch (err) {
97
+ // Derivation failures NEVER block the spawn. The plan file still
98
+ // gets written with `{tasks: [], note: "no-partition"}` shape.
99
+ plan.note = 'no-partition';
100
+ try { process.stderr.write(`[spawn-plan-writer] derive failed (continuing): ${err && err.message || err}\n`); } catch (_) { /* silent */ }
101
+ }
102
+ }
103
+
104
+ // Mark first incomplete task as in_progress if every predecessor is done.
105
+ // Only one task per spawn may be in_progress at a time (see constraints §Format rules).
106
+ _applyInProgressHint(plan.tasks);
107
+
108
+ const targetPath = path.join(spawnsDir, spawnId + '.json');
109
+ const absTarget = path.resolve(targetPath);
110
+ _atomicWriteJson(absTarget, plan);
111
+ return absTarget;
112
+ }
113
+
114
+ /**
115
+ * Return the `.gsd-t/spawns/` directory for the given project.
116
+ */
117
+ function spawnsDirFor(projectDir) {
118
+ return path.join(projectDir || '.', SPAWNS_SUBDIR);
119
+ }
120
+
121
+ // ── Internal helpers ────────────────────────────────────────────────────────
122
+
123
+ function _sanitizeSpawnId(id) {
124
+ if (id == null) return null;
125
+ const s = String(id);
126
+ // Filesystem-safe: alphanumerics, dash, underscore, dot. See constraints.md §Format rules.
127
+ if (!/^[A-Za-z0-9._-]{1,200}$/.test(s)) return null;
128
+ return s;
129
+ }
130
+
131
+ function _ensureDir(d) {
132
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
133
+ }
134
+
135
+ function _ensureGitkeep(dir) {
136
+ const gk = path.join(dir, '.gitkeep');
137
+ if (!fs.existsSync(gk)) {
138
+ try { fs.writeFileSync(gk, ''); } catch (_) { /* best-effort */ }
139
+ }
140
+ }
141
+
142
+ function _buildPlanSkeleton({ spawnId, kind, now }) {
143
+ return {
144
+ schemaVersion: SPAWN_PLAN_SCHEMA_VERSION,
145
+ spawnId,
146
+ kind,
147
+ startedAt: now.toISOString(),
148
+ endedAt: null,
149
+ milestone: null,
150
+ wave: null,
151
+ domains: [],
152
+ tasks: [],
153
+ endedReason: null,
154
+ };
155
+ }
156
+
157
+ function _normalizeTask(t) {
158
+ if (!t || typeof t !== 'object') return null;
159
+ const id = typeof t.id === 'string' ? t.id : null;
160
+ const title = typeof t.title === 'string' ? t.title : '';
161
+ const statusIn = typeof t.status === 'string' ? t.status : 'pending';
162
+ const status = ['pending', 'in_progress', 'done'].includes(statusIn) ? statusIn : 'pending';
163
+ const normalized = {
164
+ id,
165
+ title,
166
+ status,
167
+ };
168
+ if (typeof t.commit === 'string' && t.commit) normalized.commit = t.commit;
169
+ if (t.tokens && typeof t.tokens === 'object') {
170
+ normalized.tokens = t.tokens;
171
+ } else {
172
+ normalized.tokens = null;
173
+ }
174
+ return normalized;
175
+ }
176
+
177
+ function _applyInProgressHint(tasks) {
178
+ if (!Array.isArray(tasks) || tasks.length === 0) return;
179
+ // Already an in_progress? Leave as-is.
180
+ if (tasks.some((t) => t && t.status === 'in_progress')) return;
181
+ for (const t of tasks) {
182
+ if (!t) continue;
183
+ if (t.status === 'pending') { t.status = 'in_progress'; return; }
184
+ // Skip already-done; continue to next.
185
+ }
186
+ }
187
+
188
+ function _atomicWriteJson(targetPath, obj) {
189
+ const tmp = targetPath + '.tmp-' + process.pid + '-' + Date.now();
190
+ const json = JSON.stringify(obj, null, 2) + '\n';
191
+ fs.writeFileSync(tmp, json);
192
+ fs.renameSync(tmp, targetPath);
193
+ }
194
+
195
+ module.exports = {
196
+ writeSpawnPlan,
197
+ spawnsDirFor,
198
+ SPAWN_PLAN_SCHEMA_VERSION,
199
+ // Exposed for unit-test reach-in only; not part of the public contract.
200
+ _sanitizeSpawnId,
201
+ _normalizeTask,
202
+ _applyInProgressHint,
203
+ _atomicWriteJson,
204
+ };