@tekyzinc/gsd-t 3.18.13 → 3.19.0

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.
@@ -0,0 +1,535 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD-T Parallelism Report (M44 D9 T1)
5
+ *
6
+ * Pure read-only observer that answers two questions:
7
+ *
8
+ * "Is the orchestrator actually fanning out, or serializing despite
9
+ * parallelism being available?"
10
+ *
11
+ * "When this wave finishes, did it hit the parallelism factor D6 estimated?"
12
+ *
13
+ * Contract: .gsd-t/contracts/parallelism-report-contract.md v1.0.0
14
+ *
15
+ * Hard rules:
16
+ * 1. NEVER writes a file.
17
+ * 2. NEVER calls an LLM or spawns a subprocess.
18
+ * 3. Silent-fail on malformed inputs — skip the bad file, note it, continue.
19
+ * 4. Zero external deps. `.cjs` so it loads in both ESM and CJS projects.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const SCHEMA_VERSION = 1;
26
+
27
+ // ── Public API ──────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.projectDir='.']
32
+ * @param {string} [opts.wave]
33
+ * @param {Date} [opts.now] injection for tests
34
+ * @returns {object} Metrics (see contract §Metrics shape)
35
+ */
36
+ function computeParallelismMetrics(opts) {
37
+ opts = opts || {};
38
+ const projectDir = opts.projectDir || '.';
39
+ const wave = opts.wave || null;
40
+ const now = opts.now instanceof Date ? opts.now : new Date();
41
+ const notes = [];
42
+
43
+ const spawnPlans = _readSpawnPlans(projectDir, notes);
44
+ const active = spawnPlans.filter((p) => p && p.endedAt === null);
45
+ const lastSpawnAt = _computeLastSpawnAt(spawnPlans);
46
+
47
+ const activeSpawnAges_s = active
48
+ .map((p) => {
49
+ const started = _safeDate(p.startedAt);
50
+ if (!started) return null;
51
+ return Math.max(0, Math.floor((now.getTime() - started.getTime()) / 1000));
52
+ })
53
+ .filter((x) => x !== null)
54
+ .sort((a, b) => b - a);
55
+
56
+ const readyTasks = _countReadyTasks(projectDir, notes);
57
+
58
+ const gate = _computeGateDecisions(projectDir, now, notes);
59
+
60
+ const factor = _computeParallelismFactor({ active, spawnPlans, wave, now });
61
+
62
+ const colorState = _computeColorState({
63
+ activeWorkers: active.length,
64
+ readyTasks,
65
+ gate,
66
+ factor,
67
+ activeSpawnAges_s,
68
+ lastSpawnAt,
69
+ now,
70
+ noSpawns: spawnPlans.length === 0,
71
+ });
72
+
73
+ const out = {
74
+ schemaVersion: SCHEMA_VERSION,
75
+ generatedAt: now.toISOString(),
76
+ wave,
77
+ activeWorkers: active.length,
78
+ readyTasks,
79
+ parallelism_factor: factor.value,
80
+ parallelism_factor_mode: factor.mode,
81
+ gate_decisions: gate,
82
+ color_state: colorState,
83
+ lastSpawnAt,
84
+ activeSpawnAges_s,
85
+ };
86
+ if (notes.length) out.notes = notes;
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * @param {object} [opts]
92
+ * @param {string} [opts.projectDir='.']
93
+ * @param {string} [opts.wave]
94
+ * @param {Date} [opts.now]
95
+ * @returns {string} markdown post-mortem
96
+ */
97
+ function buildFullReport(opts) {
98
+ opts = opts || {};
99
+ const projectDir = opts.projectDir || '.';
100
+ const wave = opts.wave || null;
101
+ const now = opts.now instanceof Date ? opts.now : new Date();
102
+ const notes = [];
103
+
104
+ const m = computeParallelismMetrics({ projectDir, wave, now });
105
+ const spawnPlans = _readSpawnPlans(projectDir, notes);
106
+
107
+ const parts = [];
108
+ parts.push('# Parallelism Report — ' + (wave || 'all'));
109
+ parts.push('');
110
+
111
+ // §Summary
112
+ parts.push('## Summary');
113
+ parts.push('');
114
+ parts.push('| Field | Value |');
115
+ parts.push('|-------|-------|');
116
+ parts.push('| wave | ' + (wave || '—') + ' |');
117
+ parts.push('| generatedAt | ' + m.generatedAt + ' |');
118
+ parts.push('| activeWorkers | ' + m.activeWorkers + ' |');
119
+ parts.push('| readyTasks | ' + m.readyTasks + ' |');
120
+ parts.push('| parallelism_factor | ' + m.parallelism_factor.toFixed(2) + ' (' + m.parallelism_factor_mode + ') |');
121
+ parts.push('| color_state | ' + m.color_state + ' |');
122
+ parts.push('| lastSpawnAt | ' + (m.lastSpawnAt || '—') + ' |');
123
+ parts.push('');
124
+
125
+ // §Per-spawn timeline
126
+ parts.push('## Per-spawn timeline');
127
+ parts.push('');
128
+ parts.push('| spawnId | kind | startedAt | endedAt | duration_s | tasks | status |');
129
+ parts.push('|---------|------|-----------|---------|------------|-------|--------|');
130
+ const filteredSpawns = spawnPlans
131
+ .filter((p) => p && (!wave || p.wave === wave))
132
+ .sort((a, b) => String(a.startedAt || '').localeCompare(String(b.startedAt || '')));
133
+ if (filteredSpawns.length === 0) {
134
+ parts.push('| _no spawn-plan files_ | — | — | — | — | — | — |');
135
+ } else {
136
+ for (const p of filteredSpawns) {
137
+ const dur = _durationSeconds(p, now);
138
+ const ntasks = Array.isArray(p.tasks) ? p.tasks.length : 0;
139
+ const status = p.endedAt ? 'ended' : 'active';
140
+ parts.push(
141
+ '| ' + (p.spawnId || '—') +
142
+ ' | ' + (p.kind || '—') +
143
+ ' | ' + (p.startedAt || '—') +
144
+ ' | ' + (p.endedAt || '—') +
145
+ ' | ' + (dur == null ? '—' : dur) +
146
+ ' | ' + ntasks +
147
+ ' | ' + status + ' |'
148
+ );
149
+ }
150
+ }
151
+ parts.push('');
152
+
153
+ // §Per-gate decisions
154
+ parts.push('## Per-gate decisions');
155
+ parts.push('');
156
+ const dg = m.gate_decisions;
157
+ parts.push('### Depgraph gate (dep_gate_veto)');
158
+ parts.push('- count: ' + dg.dep_gate_veto.count);
159
+ parts.push('- last reasons: ' + (dg.dep_gate_veto.last_reasons.length ? dg.dep_gate_veto.last_reasons.join('; ') : '—'));
160
+ parts.push('');
161
+ parts.push('### Disjointness gate (disjointness_fallback)');
162
+ parts.push('- count: ' + dg.disjointness_fallback.count);
163
+ parts.push('- last reasons: ' + (dg.disjointness_fallback.last_reasons.length ? dg.disjointness_fallback.last_reasons.join('; ') : '—'));
164
+ parts.push('');
165
+ parts.push('### Economics gate (economics_decision)');
166
+ parts.push('- count: ' + dg.economics_decision.count);
167
+ const cd = dg.economics_decision.confidence_distribution;
168
+ parts.push('- confidence distribution: HIGH=' + cd.HIGH + ' MEDIUM=' + cd.MEDIUM + ' LOW=' + cd.LOW + ' FALLBACK=' + cd.FALLBACK);
169
+ parts.push('');
170
+
171
+ // §Per-worker Gantt (ASCII)
172
+ parts.push('## Per-worker Gantt (ASCII)');
173
+ parts.push('');
174
+ const gantt = _renderAsciiGantt(filteredSpawns, now);
175
+ parts.push('```');
176
+ parts.push(gantt);
177
+ parts.push('```');
178
+ parts.push('');
179
+
180
+ // §Token cost vs. D6 estimate
181
+ parts.push('## Token cost vs. D6 estimate');
182
+ parts.push('');
183
+ const tokenRows = _collectTokenRowsForWave(projectDir, filteredSpawns, notes);
184
+ if (!tokenRows.length) {
185
+ parts.push('_no token-log rows found for this wave_');
186
+ } else {
187
+ parts.push('| spawnId | taskId | in | out | cr | cc | cost_usd |');
188
+ parts.push('|---------|--------|----|----|----|----|---------|');
189
+ for (const r of tokenRows) {
190
+ parts.push(
191
+ '| ' + r.spawnId +
192
+ ' | ' + (r.taskId || '—') +
193
+ ' | ' + (r.in == null ? '—' : r.in) +
194
+ ' | ' + (r.out == null ? '—' : r.out) +
195
+ ' | ' + (r.cr == null ? '—' : r.cr) +
196
+ ' | ' + (r.cc == null ? '—' : r.cc) +
197
+ ' | ' + (r.cost_usd == null ? '—' : r.cost_usd) + ' |'
198
+ );
199
+ }
200
+ }
201
+ parts.push('');
202
+
203
+ // §Notes
204
+ parts.push('## Notes');
205
+ parts.push('');
206
+ const allNotes = (m.notes || []).concat(notes);
207
+ if (!allNotes.length) parts.push('_none_');
208
+ else for (const n of allNotes) parts.push('- ' + n);
209
+ parts.push('');
210
+
211
+ return parts.join('\n');
212
+ }
213
+
214
+ // ── Internal helpers ────────────────────────────────────────────────────────
215
+
216
+ function _readSpawnPlans(projectDir, notes) {
217
+ const dir = path.join(projectDir, '.gsd-t', 'spawns');
218
+ let files;
219
+ try {
220
+ if (!fs.existsSync(dir)) return [];
221
+ files = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
222
+ } catch (err) {
223
+ notes.push('spawns dir unreadable: ' + (err && err.message || err));
224
+ return [];
225
+ }
226
+ const plans = [];
227
+ for (const f of files) {
228
+ const full = path.join(dir, f);
229
+ let raw;
230
+ try {
231
+ raw = fs.readFileSync(full, 'utf8');
232
+ } catch (err) {
233
+ notes.push('spawn-plan read failed: ' + full);
234
+ continue;
235
+ }
236
+ let parsed;
237
+ try {
238
+ parsed = JSON.parse(raw);
239
+ } catch (err) {
240
+ notes.push('spawn-plan malformed JSON: ' + f);
241
+ continue;
242
+ }
243
+ if (!parsed || typeof parsed !== 'object') {
244
+ notes.push('spawn-plan not an object: ' + f);
245
+ continue;
246
+ }
247
+ plans.push(parsed);
248
+ }
249
+ return plans;
250
+ }
251
+
252
+ function _computeLastSpawnAt(spawnPlans) {
253
+ let best = null;
254
+ for (const p of spawnPlans) {
255
+ const s = _safeDate(p && p.startedAt);
256
+ if (!s) continue;
257
+ if (!best || s.getTime() > best.getTime()) best = s;
258
+ }
259
+ return best ? best.toISOString() : null;
260
+ }
261
+
262
+ function _safeDate(v) {
263
+ if (!v || typeof v !== 'string') return null;
264
+ const d = new Date(v);
265
+ if (isNaN(d.getTime())) return null;
266
+ return d;
267
+ }
268
+
269
+ function _durationSeconds(p, now) {
270
+ const start = _safeDate(p.startedAt);
271
+ if (!start) return null;
272
+ const end = p.endedAt ? _safeDate(p.endedAt) : now;
273
+ if (!end) return null;
274
+ return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 1000));
275
+ }
276
+
277
+ function _countReadyTasks(projectDir, notes) {
278
+ const domainsDir = path.join(projectDir, '.gsd-t', 'domains');
279
+ let domainNames;
280
+ try {
281
+ if (!fs.existsSync(domainsDir)) return 0;
282
+ domainNames = fs.readdirSync(domainsDir, { withFileTypes: true })
283
+ .filter((e) => e.isDirectory())
284
+ .map((e) => e.name);
285
+ } catch (err) {
286
+ notes.push('domains dir unreadable: ' + (err && err.message || err));
287
+ return 0;
288
+ }
289
+
290
+ let total = 0;
291
+ for (const name of domainNames) {
292
+ const tasksFile = path.join(domainsDir, name, 'tasks.md');
293
+ if (!fs.existsSync(tasksFile)) continue;
294
+ let content;
295
+ try {
296
+ content = fs.readFileSync(tasksFile, 'utf8');
297
+ } catch (err) {
298
+ notes.push('tasks.md read failed: ' + tasksFile);
299
+ continue;
300
+ }
301
+ // Count pending tasks: bullets starting with `- [ ]` (Shape A/C) OR `### Mxx-Dx-Tx` with no `[x]`.
302
+ // For D9's purposes we count `- [ ]` markers — matches the existing task-graph parser.
303
+ const lines = content.split(/\r?\n/);
304
+ for (const line of lines) {
305
+ // Shape C: `- [ ] **M44-D9-T1** — ...`
306
+ if (/^\s*-\s*\[\s\]\s+\*\*[A-Z]+\d+-D\d+-T\d+\*\*/.test(line)) total++;
307
+ // Shape A: `- [ ] D1-T1` (legacy) or `- [ ] T-1:`
308
+ else if (/^\s*-\s*\[\s\]\s+(?:\*\*)?[A-Z]?\d?-?[DT]-?\d+/.test(line)) total++;
309
+ }
310
+ }
311
+ return total;
312
+ }
313
+
314
+ function _computeGateDecisions(projectDir, now, notes) {
315
+ const eventsDir = path.join(projectDir, '.gsd-t', 'events');
316
+ const out = {
317
+ dep_gate_veto: { count: 0, last_reasons: [] },
318
+ disjointness_fallback: { count: 0, last_reasons: [] },
319
+ economics_decision: { count: 0, confidence_distribution: { HIGH: 0, MEDIUM: 0, LOW: 0, FALLBACK: 0 } },
320
+ };
321
+ if (!fs.existsSync(eventsDir)) return out;
322
+
323
+ // Read last 14 days of events.
324
+ const days = [];
325
+ for (let i = 0; i < 14; i++) {
326
+ const d = new Date(now.getTime() - i * 86400000);
327
+ const iso = d.toISOString().slice(0, 10);
328
+ days.push(iso);
329
+ }
330
+
331
+ const rows = [];
332
+ for (const day of days) {
333
+ const f = path.join(eventsDir, day + '.jsonl');
334
+ if (!fs.existsSync(f)) continue;
335
+ let content;
336
+ try {
337
+ content = fs.readFileSync(f, 'utf8');
338
+ } catch (err) {
339
+ notes.push('events file unreadable: ' + f);
340
+ continue;
341
+ }
342
+ const lines = content.split(/\r?\n/).filter((l) => l.length > 0);
343
+ for (const line of lines) {
344
+ let obj;
345
+ try { obj = JSON.parse(line); }
346
+ catch (_) { continue; }
347
+ if (!obj || typeof obj !== 'object') continue;
348
+ rows.push(obj);
349
+ }
350
+ }
351
+
352
+ // Sort by ts ascending; take last 10 of each type.
353
+ rows.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
354
+
355
+ const depRows = rows.filter((r) => r.type === 'dep_gate_veto').slice(-10);
356
+ const disRows = rows.filter((r) => r.type === 'disjointness_fallback').slice(-10);
357
+ const ecoRows = rows.filter((r) => r.type === 'economics_decision').slice(-10);
358
+
359
+ out.dep_gate_veto.count = depRows.length;
360
+ out.dep_gate_veto.last_reasons = depRows.map((r) => r.reason || r.task_id || '—').slice(-5);
361
+
362
+ out.disjointness_fallback.count = disRows.length;
363
+ out.disjointness_fallback.last_reasons = disRows.map((r) => r.reason || r.task_id || '—').slice(-5);
364
+
365
+ out.economics_decision.count = ecoRows.length;
366
+ for (const r of ecoRows) {
367
+ const c = String(r.confidence || 'FALLBACK').toUpperCase();
368
+ if (Object.prototype.hasOwnProperty.call(out.economics_decision.confidence_distribution, c)) {
369
+ out.economics_decision.confidence_distribution[c]++;
370
+ } else {
371
+ out.economics_decision.confidence_distribution.FALLBACK++;
372
+ }
373
+ }
374
+
375
+ return out;
376
+ }
377
+
378
+ function _computeParallelismFactor({ active, spawnPlans, wave, now }) {
379
+ // Live mode: any active spawn.
380
+ if (active.length > 0) {
381
+ const ages = active
382
+ .map((p) => {
383
+ const s = _safeDate(p.startedAt);
384
+ if (!s) return 0;
385
+ return Math.max(1, (now.getTime() - s.getTime()) / 1000);
386
+ });
387
+ const sum = ages.reduce((a, b) => a + b, 0);
388
+ const max = Math.max(...ages);
389
+ if (max <= 0) return { value: 0, mode: 'live' };
390
+ return { value: sum / max, mode: 'live' };
391
+ }
392
+
393
+ // Post-wave mode: wave supplied, compute across all ended spawns in that wave.
394
+ if (wave) {
395
+ const inWave = spawnPlans.filter((p) => p && p.wave === wave);
396
+ if (inWave.length === 0) return { value: 0, mode: 'post-wave' };
397
+ const durations = inWave
398
+ .map((p) => _durationSeconds(p, now))
399
+ .filter((x) => x !== null);
400
+ if (durations.length === 0) return { value: 0, mode: 'post-wave' };
401
+ const starts = inWave.map((p) => _safeDate(p.startedAt)).filter(Boolean).map((d) => d.getTime());
402
+ const ends = inWave.map((p) => _safeDate(p.endedAt) || now).map((d) => d.getTime());
403
+ if (starts.length === 0 || ends.length === 0) return { value: 0, mode: 'post-wave' };
404
+ const span_s = (Math.max(...ends) - Math.min(...starts)) / 1000;
405
+ const sumDur = durations.reduce((a, b) => a + b, 0);
406
+ if (span_s <= 0) return { value: 0, mode: 'post-wave' };
407
+ return { value: sumDur / span_s, mode: 'post-wave' };
408
+ }
409
+
410
+ return { value: 0, mode: 'idle' };
411
+ }
412
+
413
+ function _computeColorState({ activeWorkers, readyTasks, gate, factor, activeSpawnAges_s, lastSpawnAt, now, noSpawns }) {
414
+ if (noSpawns) return 'dimmed';
415
+
416
+ const signals = [];
417
+
418
+ // Signal 1: activeWorkers vs readyTasks
419
+ if (readyTasks > 0) {
420
+ const ratio = activeWorkers / readyTasks;
421
+ const lastSpawn = _safeDate(lastSpawnAt);
422
+ const minutesSinceSpawn = lastSpawn
423
+ ? (now.getTime() - lastSpawn.getTime()) / 60000
424
+ : Infinity;
425
+ if (ratio >= 0.8) signals.push('green');
426
+ else if (ratio >= 0.5) signals.push('yellow');
427
+ else if (minutesSinceSpawn > 10) signals.push('red');
428
+ else signals.push('yellow');
429
+ }
430
+
431
+ // Signal 2: gate veto rate (dep_gate_veto count vs. total gate events)
432
+ const totalGate = gate.dep_gate_veto.count + gate.disjointness_fallback.count + gate.economics_decision.count;
433
+ if (totalGate > 0) {
434
+ const vetoRate = gate.dep_gate_veto.count / totalGate;
435
+ if (vetoRate < 0.1) signals.push('green');
436
+ else if (vetoRate <= 0.3) signals.push('yellow');
437
+ else signals.push('red');
438
+ }
439
+
440
+ // Signal 3: parallelism_factor vs. ideal (use activeWorkers as ideal — D6 estimate not yet wired)
441
+ if (factor.mode === 'live' && activeWorkers > 1) {
442
+ const ideal = activeWorkers;
443
+ const ratio = factor.value / ideal;
444
+ if (ratio >= 0.8) signals.push('green');
445
+ else if (ratio >= 0.5) signals.push('yellow');
446
+ else signals.push('red');
447
+ }
448
+
449
+ // Signal 4: spawn age (any active > 45 min = red, 30-45 yellow)
450
+ if (activeSpawnAges_s.length > 0) {
451
+ const maxAge_m = activeSpawnAges_s[0] / 60;
452
+ if (maxAge_m < 30) signals.push('green');
453
+ else if (maxAge_m <= 45) signals.push('yellow');
454
+ else signals.push('red');
455
+ }
456
+
457
+ // Signal 5: time since last spawn_started when ready>0
458
+ if (readyTasks > 0) {
459
+ const lastSpawn = _safeDate(lastSpawnAt);
460
+ const minutesSinceSpawn = lastSpawn
461
+ ? (now.getTime() - lastSpawn.getTime()) / 60000
462
+ : Infinity;
463
+ if (minutesSinceSpawn < 5) signals.push('green');
464
+ else if (minutesSinceSpawn <= 10) signals.push('yellow');
465
+ else signals.push('red');
466
+ }
467
+
468
+ if (signals.length === 0) return 'green';
469
+ if (signals.includes('red')) return 'red';
470
+ if (signals.includes('yellow')) return 'yellow';
471
+ return 'green';
472
+ }
473
+
474
+ function _renderAsciiGantt(spawns, now) {
475
+ if (spawns.length === 0) return '(no spawns in this wave)';
476
+ const valid = spawns
477
+ .map((p) => {
478
+ const s = _safeDate(p.startedAt);
479
+ if (!s) return null;
480
+ const e = p.endedAt ? _safeDate(p.endedAt) : now;
481
+ if (!e) return null;
482
+ return { id: p.spawnId || '—', start: s.getTime(), end: e.getTime() };
483
+ })
484
+ .filter(Boolean);
485
+ if (!valid.length) return '(no datable spawns)';
486
+
487
+ const minStart = Math.min(...valid.map((v) => v.start));
488
+ const maxEnd = Math.max(...valid.map((v) => v.end));
489
+ const span = Math.max(1, maxEnd - minStart);
490
+ const WIDTH = 60;
491
+
492
+ const lines = [];
493
+ lines.push('t=0' + ' '.repeat(WIDTH - 3) + 't=' + Math.floor(span / 1000) + 's');
494
+ for (const v of valid) {
495
+ const startCol = Math.floor(((v.start - minStart) / span) * WIDTH);
496
+ const endCol = Math.floor(((v.end - minStart) / span) * WIDTH);
497
+ const bar = ' '.repeat(startCol) + '#'.repeat(Math.max(1, endCol - startCol)) + ' '.repeat(Math.max(0, WIDTH - endCol));
498
+ const id = (v.id || '—').slice(0, 24).padEnd(24);
499
+ lines.push(id + ' |' + bar + '|');
500
+ }
501
+ return lines.join('\n');
502
+ }
503
+
504
+ function _collectTokenRowsForWave(projectDir, spawnPlans, notes) {
505
+ const rows = [];
506
+ for (const p of spawnPlans) {
507
+ if (!p || !Array.isArray(p.tasks)) continue;
508
+ for (const t of p.tasks) {
509
+ if (!t || !t.tokens || typeof t.tokens !== 'object') continue;
510
+ rows.push({
511
+ spawnId: p.spawnId || '—',
512
+ taskId: t.id || null,
513
+ in: t.tokens.in == null ? null : t.tokens.in,
514
+ out: t.tokens.out == null ? null : t.tokens.out,
515
+ cr: t.tokens.cr == null ? null : t.tokens.cr,
516
+ cc: t.tokens.cc == null ? null : t.tokens.cc,
517
+ cost_usd: t.tokens.cost_usd == null ? null : t.tokens.cost_usd,
518
+ });
519
+ }
520
+ }
521
+ return rows;
522
+ }
523
+
524
+ module.exports = {
525
+ computeParallelismMetrics,
526
+ buildFullReport,
527
+ SCHEMA_VERSION,
528
+ // Exposed for unit tests only; not part of the public contract.
529
+ _computeLastSpawnAt,
530
+ _countReadyTasks,
531
+ _computeGateDecisions,
532
+ _computeParallelismFactor,
533
+ _computeColorState,
534
+ _renderAsciiGantt,
535
+ };
@@ -41,7 +41,7 @@ const SPAWN_PLAN_SCHEMA_VERSION = 1;
41
41
  *
42
42
  * @param {object} opts
43
43
  * @param {string} opts.spawnId filesystem-safe id
44
- * @param {'unattended-worker'|'headless-detached'|'in-session-subagent'} opts.kind
44
+ * @param {'unattended-worker'|'headless-detached'|'in-session-subagent'|'unattended-worker-sub'} opts.kind
45
45
  * @param {string} [opts.milestone] e.g. 'M44'
46
46
  * @param {string} [opts.wave] e.g. 'wave-3'
47
47
  * @param {string[]} [opts.domains] domain names involved
@@ -316,24 +316,20 @@ Before touching any code:
316
316
  node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-id "${GSD_T_PARENT_AGENT_ID:-null}" --command gsd-t-debug --step 3 --step-label "Debug (Solo or Team)" 2>/dev/null || true
317
317
  ```
318
318
 
319
- ### Optional — Parallel Dispatch (M44, lightweight · conditional-only)
319
+ ### Parallel Dispatch (MANDATORY single instrument)
320
320
 
321
- **This block is a no-op for single-domain debug sessions.** Most debug sessions touch one domain (within-domain bug, Category A in Step 2); for those, the sequential Solo Mode path below runs unchanged.
321
+ Delegate to `gsd-t parallel --command` do NOT re-implement probe-and-branch logic here (M44 D9 Step 3: "create 1 instrument that accomplishes this instead of implementing it in all the commands").
322
322
 
323
- **Trigger conditions (BOTH must hold)**:
324
- 1. This is a multi-domain debug session (Category B contract boundary bug OR Category C contract gap that spans more than one domain), AND
325
- 2. The fix is decomposable into independent tasks across domains that pass D4 depgraph + D5 file-disjointness + D6 economics gates.
326
-
327
- **If either condition fails** — single-domain debug, or a fix that cannot be decomposed cleanly — skip this block entirely. The sequential Solo Mode / Team Mode flow below remains unchanged.
328
-
329
- **If BOTH conditions hold** — dispatch the independent fix tasks via `gsd-t parallel` (mode auto-detected from `GSD_T_UNATTENDED=1`; do not hardcode `--mode`):
323
+ ```bash
324
+ node bin/gsd-t.js parallel --command gsd-t-debug && exit 0 || true
325
+ # Exit 0 → multi-domain debug decomposable; N detached children handle fixes.
326
+ # Exit 2+ → single-domain or non-decomposable (the common case for debug).
327
+ # Fall through to Solo / Team Mode below.
328
+ ```
330
329
 
331
- - Fallback is silent any gate veto drops the affected tasks back to the sequential debug path.
332
- - D2 owns the spawn observability; the parallel path writes the same `.gsd-t/events/YYYY-MM-DD.jsonl` records and `.gsd-t/token-log.md` rows as the sequential path via `captureSpawn`. D3 adds no new spawn machinery.
333
- - `[unattended]` — D2 enforces the zero-compaction contract by splitting tasks when D6 estimates > 60% per-worker CW.
334
- - `[in-session]` — NEVER interrupts the user with a pause/resume prompt. If headroom is tight, D2 reduces the worker count (floor N=1). No opt-out flag exists (consistent with M43 D4: `--in-session` / `--headless` were never shipped).
330
+ `runDispatch` owns the D4/D5/D6 gates + disjoint task-id partitioning + `autoSpawnHeadless()` fan-out. Mode auto-detects from `GSD_T_UNATTENDED=1`. No user prompt. Parallel-when-safe + headless-when-possible are both the default.
335
331
 
336
- Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
332
+ Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0; `.gsd-t/contracts/headless-default-contract.md` v2.0.0.
337
333
 
338
334
  ### Deviation Rules
339
335
 
@@ -125,27 +125,21 @@ If QA found issues, append each to `.gsd-t/qa-issues.md` (create with header `|
125
125
  node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-id "${GSD_T_PARENT_AGENT_ID:-null}" --command gsd-t-execute --step 3 --step-label "Choose Execution Mode" 2>/dev/null || true
126
126
  ```
127
127
 
128
- ### Optional — Parallel Dispatch (M44)
128
+ ### Parallel Dispatch (MANDATORY — single instrument)
129
129
 
130
- **Conditional check** — if `.gsd-t/domains/` contains more than one pending task that passes the D4 depgraph, D5 file-disjointness, and D6 economics gates, dispatch the ready batch via `gsd-t parallel` instead of the sequential task-dispatcher below. If the conditional fails (single pending task, or any gate vetoes, or disjointness is unprovable), fall through silently to the existing sequential path there is no user prompt, no pause, no opt-out flag.
131
-
132
- - **Mode auto-detection** — mode is auto-detected by `bin/gsd-t-parallel.cjs` from `GSD_T_UNATTENDED=1`. Do not hardcode `--mode` in this command file. Explicit `--mode` overrides env; omitted flag falls back to env; missing env defaults to in-session.
133
- - **Fallback** — every gate veto (D4 `dep_gate_veto`, D5 `disjointness_fallback`, D6 estimated > threshold) removes the affected task(s) from the parallel batch. Tasks fall back to the sequential task-dispatcher silently; the dry-run plan table still lists them with decision `sequential` or `veto-deps` so the operator can see why.
134
- - **Observability** — D2 owns the spawn observability. The parallel path writes the same `.gsd-t/events/YYYY-MM-DD.jsonl` event stream (`gate_veto`, `parallelism_reduced`, `task_split`) and the same `.gsd-t/token-log.md` rows that sequential spawns produce via `captureSpawn`. D3 adds no new spawn machinery — integration is purely a dispatch decision.
135
- - **Zero-compaction invariant (unattended)** — for `[unattended]` runs, D2 enforces the zero-compaction contract by splitting tasks when D6 estimates per-worker CW > 60%. Mid-run compaction is not tolerated; the splitter slices before fan-out.
136
- - **In-session invariant** — the parallel path NEVER interrupts the user with a pause/resume prompt. If the in-session headroom check reduces the worker count below the requested N, D2 emits `parallelism_reduced` and proceeds at the reduced count. The final floor is N=1 (sequential). If all gates fail, D2 falls back to sequential silently — no opt-out flag exists (consistent with M43 D4: `--in-session` / `--headless` were never shipped).
137
-
138
- **Dispatch call** (example; resolve `--mode` via env):
130
+ Delegate the parallel-vs-sequential decision to `gsd-t parallel --command` do NOT re-implement probe-and-branch logic here. The CLI is the single instrument per M44 D9 Step 3 (user directive: "create 1 instrument that accomplishes this instead of implementing it in all the commands").
139
131
 
140
132
  ```bash
141
- # Dry-run first if you want to see the plan table without spawning:
142
- gsd-t parallel --milestone {milestone} --dry-run
143
-
144
- # Live dispatch (mode auto-detected from GSD_T_UNATTENDED):
145
- gsd-t parallel --milestone {milestone}
133
+ node bin/gsd-t.js parallel --milestone {milestone} --command gsd-t-execute && exit 0 || true
134
+ # Exit 0 → fan-out happened; N detached headless children are running
135
+ # disjoint task subsets. This agent is done.
136
+ # Exit 2 → sequential (N<2 or gate veto). Fall through to Wave Scheduling below.
137
+ # Exit 3+ → invalid/other; fall through to sequential.
146
138
  ```
147
139
 
148
- `runParallel` produces the validated worker plan; the existing M40 orchestrator machinery owns the actual worker spawn, retry policy, wave barriers, and state-file lifecycle. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0. When the conditional is not met or when every candidate task falls back via veto proceed to the existing Wave Scheduling + Solo/Team Mode flow below.
140
+ The CLI internally runs `runDispatch()` which: (1) probes the planner via `runParallel({dryRun:true})`, (2) if `workerCount 2` with D4/D5/D6 gates green, partitions task ids round-robin and spawns N detached headless children via `autoSpawnHeadless()` each gets `GSD_T_WORKER_TASK_IDS`/`_WORKER_INDEX`/`_WORKER_TOTAL` env vars so the child handles only its assigned subset, (3) else exits 2 so this agent falls through to the legacy single-worker path. Mode is auto-detected from `GSD_T_UNATTENDED=1`. No user prompt. No opt-out flag (parallel-when-safe + headless-when-possible are both the default per headless-default-contract v2.0.0).
141
+
142
+ Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0; `.gsd-t/contracts/headless-default-contract.md` v2.0.0.
149
143
 
150
144
  ### Wave Scheduling (read first)
151
145
 
@@ -319,6 +319,7 @@ Use these when user asks for help on a specific command:
319
319
  - **Creates**: `.gsd-t/dashboard.pid` (when starting server)
320
320
  - **Use when**: Monitoring live agent activity during execute/wave phases; run `gsd-t-visualize stop` to stop the server
321
321
  - **Spawn-plan panel (M44 D8, v3.18.10+)**: the transcript viewer at `/transcript/{spawnId}` now includes a right-side two-layer task panel (Layer 1 project · Layer 2 active spawn). Status icons `☐` pending, `◐` in_progress, `✓` done. Done tasks show a right-aligned token cell `in=12.5k out=1.7k $0.42` (or `—` when attribution unavailable). Token attribution is sourced from `.gsd-t/token-log.md` via the post-commit hook `scripts/gsd-t-post-commit-spawn-plan.sh`. Endpoints: `GET /api/spawn-plans` + SSE `/api/spawn-plans/stream`. Contract: `.gsd-t/contracts/spawn-plan-contract.md` v1.0.0.
322
+ - **Parallelism panel (M44 D9, v3.19.0+)**: below the spawn-plan panel, the transcript viewer shows `activeWorkers`, `readyTasks`, `parallelism_factor`, oldest-spawn age, gate-decision tallies (dep/disjoint/economics), a color-state border (green/yellow/red/dimmed), a `📄 Full Report` button that downloads a post-mortem markdown, and a `Stop Supervisor` button. Polls `GET /api/parallelism` every 5s (5s server-side cache). Endpoints: `GET /api/parallelism`, `GET /api/parallelism/report?wave=N`, `POST /api/unattended-stop`. Pure reader — zero added LLM token cost. Contract: `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
322
323
 
323
324
  ### metrics
324
325
  - **Summary**: View task telemetry, process ELO, signal distribution, domain health, and cross-project comparison (with `--cross-project` flag)