@tekyzinc/gsd-t 3.18.13 → 3.18.17
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 +45 -0
- package/bin/gsd-t-parallel-probe.cjs +132 -0
- package/bin/gsd-t-parallel.cjs +242 -9
- package/bin/gsd-t-task-graph.cjs +80 -19
- package/bin/gsd-t-unattended.cjs +210 -9
- package/bin/headless-auto-spawn.cjs +17 -1
- package/bin/headless-exit-codes.cjs +36 -18
- package/bin/m44-proof-measure.cjs +285 -0
- package/bin/parallelism-report.cjs +535 -0
- package/commands/gsd-t-debug.md +10 -14
- package/commands/gsd-t-execute.md +10 -16
- package/commands/gsd-t-help.md +1 -0
- package/commands/gsd-t-integrate.md +8 -14
- package/commands/gsd-t-quick.md +10 -14
- package/commands/gsd-t-unattended-watch.md +58 -1
- package/commands/gsd-t-visualize.md +15 -12
- package/commands/gsd-t-wave.md +2 -11
- package/docs/architecture.md +66 -0
- package/package.json +1 -1
- package/scripts/gsd-t-compact-detector.js +51 -8
- package/scripts/gsd-t-dashboard-server.js +138 -85
- package/scripts/gsd-t-transcript.html +152 -1
- package/scripts/hooks/gsd-t-conversation-capture.js +258 -0
- package/templates/CLAUDE-global.md +54 -0
package/commands/gsd-t-quick.md
CHANGED
|
@@ -184,24 +184,20 @@ Proceed.
|
|
|
184
184
|
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-quick --step 3 --step-label "Execute" 2>/dev/null || true
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
###
|
|
187
|
+
### Parallel Dispatch (MANDATORY — single instrument)
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
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").
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
**If BOTH conditions hold** — dispatch the ready batch via `gsd-t parallel` (mode auto-detected from `GSD_T_UNATTENDED=1`; do not hardcode `--mode`):
|
|
191
|
+
```bash
|
|
192
|
+
node bin/gsd-t.js parallel --command gsd-t-quick && exit 0 || true
|
|
193
|
+
# Exit 0 → fan-out happened; detached children handle the work.
|
|
194
|
+
# Exit 2+ → sequential (N<2 or quick is single-task — the common case).
|
|
195
|
+
# Fall through to the single-subagent path in Step 0.1.
|
|
196
|
+
```
|
|
198
197
|
|
|
199
|
-
-
|
|
200
|
-
- 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 via `captureSpawn`. D3 adds no new spawn machinery.
|
|
201
|
-
- `[unattended]` — D2 enforces the zero-compaction contract by splitting tasks when D6 estimates > 60% per-worker CW.
|
|
202
|
-
- `[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).
|
|
198
|
+
`runDispatch` inside `bin/gsd-t-parallel.cjs` owns the probe + D4/D5/D6 gate math + 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 (per headless-default-contract v2.0.0).
|
|
203
199
|
|
|
204
|
-
Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
|
|
200
|
+
Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0; `.gsd-t/contracts/headless-default-contract.md` v2.0.0.
|
|
205
201
|
|
|
206
202
|
### Deviation Rules
|
|
207
203
|
|
|
@@ -207,7 +207,64 @@ out('DOMAIN_SUMMARY', domainSummary.join(' | '));
|
|
|
207
207
|
|
|
208
208
|
If `PID_FILE_EXISTS=false`:
|
|
209
209
|
|
|
210
|
-
The supervisor has finalized cleanly and removed its own PID file.
|
|
210
|
+
The supervisor has finalized cleanly and removed its own PID file.
|
|
211
|
+
|
|
212
|
+
### Step 3a: Reconciliation probe
|
|
213
|
+
|
|
214
|
+
Before emitting a report, check whether the supervisor's final `status` agrees with the on-disk milestone evidence. A `status=failed` with a **fresh matching milestone archive** is the M45-class false-failed-marker pathology: the worker completed the milestone (including the archive rename under `.gsd-t/milestones/`) but a polarity-unsound exit-code matcher mapped the worker's clean exit to `1`, which the supervisor then finalized as `status=failed`.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
node -e "
|
|
218
|
+
const fs = require('fs');
|
|
219
|
+
const path = require('path');
|
|
220
|
+
const state = JSON.parse(fs.readFileSync('.gsd-t/.unattended/state.json', 'utf8'));
|
|
221
|
+
const milestone = (state.milestone || '').toLowerCase();
|
|
222
|
+
let reconciled = false;
|
|
223
|
+
if (state.status === 'failed' && milestone) {
|
|
224
|
+
// Look for a fresh archive directory matching the milestone slug.
|
|
225
|
+
// Pattern: .gsd-t/milestones/{slug}-YYYY-MM-DD where slug contains the
|
|
226
|
+
// milestone identifier (e.g. 'M45' → 'm45-conversation-stream-observability-2026-04-23').
|
|
227
|
+
try {
|
|
228
|
+
const archives = fs.readdirSync('.gsd-t/milestones')
|
|
229
|
+
.filter(n => n.toLowerCase().includes(milestone));
|
|
230
|
+
if (archives.length > 0) {
|
|
231
|
+
// Check the most recent archive's mtime is within the supervisor's run window.
|
|
232
|
+
const recent = archives
|
|
233
|
+
.map(n => ({ n, mtime: fs.statSync(path.join('.gsd-t/milestones', n)).mtimeMs }))
|
|
234
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
235
|
+
const runStart = Date.parse(state.startedAt || 0);
|
|
236
|
+
if (recent && recent.mtime >= runStart) {
|
|
237
|
+
reconciled = true;
|
|
238
|
+
console.log('RECONCILED=true');
|
|
239
|
+
console.log('RECONCILED_ARCHIVE=' + recent.n);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (_) {}
|
|
243
|
+
}
|
|
244
|
+
if (!reconciled) console.log('RECONCILED=false');
|
|
245
|
+
"
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Step 3b: Render reconciled or raw report
|
|
249
|
+
|
|
250
|
+
If `RECONCILED=true` AND `STATUS=failed`, the archive is the authority — render the reconciled success report:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
✅ Unattended supervisor finalized — milestone completed (auto-reconciled).
|
|
254
|
+
|
|
255
|
+
Session: {SESSION}
|
|
256
|
+
Milestone: {MILESTONE} — archived as {RECONCILED_ARCHIVE}
|
|
257
|
+
Supervisor flag: status=failed (not trusted — archive present)
|
|
258
|
+
Iterations: {ITER} / {MAX_ITER}
|
|
259
|
+
Total elapsed: {Hh Mm — formatted from ELAPSED_MS}
|
|
260
|
+
Log: {LOG_PATH}
|
|
261
|
+
State: .gsd-t/.unattended/state.json
|
|
262
|
+
|
|
263
|
+
Note: the worker completed the milestone, but a polarity-unsound exit-code
|
|
264
|
+
matcher mapped clean exit → failed. Treat the archive as the source of truth.
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Otherwise (no reconciliation — `STATUS` is genuinely terminal `done`, `failed` without archive, or `stopped`), render the raw final report:
|
|
211
268
|
|
|
212
269
|
```
|
|
213
270
|
✅ Unattended supervisor finalized cleanly.
|
|
@@ -55,49 +55,52 @@ If `.gsd-t/graph/index.json` exists, the dashboard can render entity-relationshi
|
|
|
55
55
|
|
|
56
56
|
If `$ARGUMENTS` contains "stop", skip to **Step 5**. Otherwise continue to Step 3.
|
|
57
57
|
|
|
58
|
-
## Step 3: Check if Server is Already Running
|
|
58
|
+
## Step 3: Resolve Port + Check if Server is Already Running
|
|
59
|
+
|
|
60
|
+
The dashboard server binds to a **per-project hashed port** in `[7433, 7532]` (see `projectScopedDefaultPort` in `gsd-t-dashboard-server.js`). Resolve it once and reuse:
|
|
59
61
|
|
|
60
|
-
Run via Bash:
|
|
61
62
|
```bash
|
|
63
|
+
PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
|
|
62
64
|
if [ -f .gsd-t/dashboard.pid ]; then
|
|
63
65
|
PID=$(cat .gsd-t/dashboard.pid)
|
|
64
|
-
curl -sf http://localhost
|
|
66
|
+
curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
|
|
65
67
|
else
|
|
66
68
|
echo "SERVER_RUNNING=false"
|
|
67
69
|
fi
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4.
|
|
72
|
+
If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4. Carry `$PORT` forward to subsequent steps.
|
|
71
73
|
|
|
72
74
|
### Step 3a: Start Server if Not Running
|
|
73
75
|
|
|
74
|
-
Run via Bash:
|
|
76
|
+
Run via Bash (reuses `$PORT` from Step 3):
|
|
75
77
|
```bash
|
|
76
78
|
node ~/.claude/scripts/gsd-t-dashboard-server.js --detach || true
|
|
77
79
|
for i in 1 2 3 4 5; do
|
|
78
|
-
curl -sf http://localhost
|
|
80
|
+
curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && break
|
|
79
81
|
sleep 1
|
|
80
82
|
done
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
## Step 4: Open Browser
|
|
84
86
|
|
|
85
|
-
Run via Bash:
|
|
87
|
+
Run via Bash (reuses `$PORT` from Step 3):
|
|
86
88
|
```bash
|
|
87
|
-
|
|
89
|
+
URL="http://localhost:$PORT"
|
|
90
|
+
node -e "const {execFileSync}=require('child_process'); const url=process.argv[1]; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" "$URL" || true
|
|
88
91
|
```
|
|
89
92
|
|
|
90
|
-
Report to the user: "Dashboard is running at
|
|
93
|
+
Report to the user: "Dashboard is running at $URL — browser opened."
|
|
91
94
|
|
|
92
95
|
## Step 5: Stop Handler
|
|
93
96
|
|
|
94
|
-
Run only when `$ARGUMENTS` contains "stop".
|
|
97
|
+
Run only when `$ARGUMENTS` contains "stop". Resolves the same hashed port used at start:
|
|
95
98
|
|
|
96
|
-
Run via Bash:
|
|
97
99
|
```bash
|
|
100
|
+
PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
|
|
98
101
|
if [ -f .gsd-t/dashboard.pid ]; then
|
|
99
102
|
PID=$(cat .gsd-t/dashboard.pid)
|
|
100
|
-
curl -sf http://localhost
|
|
103
|
+
curl -sf http://localhost:$PORT/stop 2>/dev/null || kill $PID 2>/dev/null || true
|
|
101
104
|
rm -f .gsd-t/dashboard.pid
|
|
102
105
|
echo "Dashboard server stopped"
|
|
103
106
|
else
|
package/commands/gsd-t-wave.md
CHANGED
|
@@ -211,18 +211,9 @@ Spawn agent → `commands/gsd-t-impact.md`
|
|
|
211
211
|
Spawn agent → `commands/gsd-t-execute.md`
|
|
212
212
|
- This is the heaviest phase. The execute agent uses **task-level dispatch** (fresh-dispatch-contract.md): one Task subagent per task within each domain, each receiving only scope.md + relevant contracts + single task + graph context + up to 5 prior summaries. The execute agent handles domain task-dispatching and QA internally.
|
|
213
213
|
|
|
214
|
-
#####
|
|
214
|
+
##### Parallel Dispatch (M44 D9, single instrument)
|
|
215
215
|
|
|
216
|
-
The spawned execute agent
|
|
217
|
-
|
|
218
|
-
- If `.gsd-t/domains/` contains more than one pending task passing D4/D5/D6 gates, the execute agent dispatches via `gsd-t parallel` instead of sequentially.
|
|
219
|
-
- Mode is auto-detected from `GSD_T_UNATTENDED=1` — the wave orchestrator inherits this env from its own spawn. Do not hardcode `--mode`.
|
|
220
|
-
- Fallback is silent: any gate veto, unprovable disjointness, or single-task scope drops back to the sequential execute path without a user prompt.
|
|
221
|
-
- 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.
|
|
222
|
-
- `[unattended]` — D2 enforces the zero-compaction contract by splitting tasks when D6 estimates per-worker CW > 60%.
|
|
223
|
-
- `[in-session]` — NEVER interrupts the user with pause/resume. If headroom is tight, D2 reduces the worker count (floor N=1) and emits `parallelism_reduced`.
|
|
224
|
-
|
|
225
|
-
No wave-orchestrator-level flag exists to opt out; the parallel-vs-sequential decision is owned by the execute agent and the D2 gating math. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
|
|
216
|
+
The spawned execute agent delegates the parallel-vs-sequential decision to the single instrument `gsd-t parallel --command gsd-t-execute` (see `commands/gsd-t-execute.md` → "Parallel Dispatch"). The wave orchestrator does not need to configure this — it is automatic, deterministic, and inherited via `GSD_T_UNATTENDED` env. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.2.0 §Single Instrument.
|
|
226
217
|
|
|
227
218
|
- **Adaptive replanning**: After each domain completes, the execute agent runs a replan check (per `adaptive-replan-contract.md`). If a completed domain's task summaries reveal new constraints (e.g., deprecated API, wrong column name, incompatible library), the execute agent checks remaining domains' `tasks.md` files for invalidated assumptions and revises them on disk before dispatching the next domain. Maximum 2 replan cycles per execute run — if exceeded, execution pauses for user input. All replan decisions are logged to the Decision Log in `progress.md`. The wave phase summary includes any replan actions taken.
|
|
228
219
|
- **Team/parallel mode**: If the plan defines parallel domains (same wave), the execute agent dispatches each domain teammate with `isolation: "worktree"` (per worktree-isolation-contract.md). Each domain works in an isolated git worktree. After all domains complete, the execute agent runs the Sequential Merge Protocol: merge domain A → test → merge domain B → test. Per-domain rollback if tests fail. Worktrees are cleaned up after all merges complete.
|
package/docs/architecture.md
CHANGED
|
@@ -978,6 +978,72 @@ means in-flight.
|
|
|
978
978
|
`test/m44-d8-dashboard-spawn-plans-endpoint.test.js`,
|
|
979
979
|
`test/m44-d8-transcript-renderer-panel.test.js` — 36 tests total.
|
|
980
980
|
|
|
981
|
+
## Parallelism Panel (M44 D9, v3.19.0+)
|
|
982
|
+
|
|
983
|
+
Pure-observer readout answering two questions:
|
|
984
|
+
|
|
985
|
+
> *Is the orchestrator actually fanning out, or serializing despite parallelism being available?*
|
|
986
|
+
|
|
987
|
+
> *When this wave finishes, did it hit the parallelism factor D6 estimated?*
|
|
988
|
+
|
|
989
|
+
Zero added LLM token cost — every number is derived from files the other
|
|
990
|
+
M44 domains already write: D8 spawn-plan files, D4/D5/D6 event rows, and
|
|
991
|
+
D7 `cw_id` columns in `.gsd-t/token-log.md`.
|
|
992
|
+
|
|
993
|
+
**Module**: `bin/parallelism-report.cjs` —
|
|
994
|
+
`computeParallelismMetrics({projectDir, wave?, now?})` returns the shape
|
|
995
|
+
defined in `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
|
|
996
|
+
`buildFullReport(...)` returns a post-mortem markdown string with Summary,
|
|
997
|
+
Per-spawn timeline, Per-gate decisions, Per-worker Gantt, Token cost, and
|
|
998
|
+
Notes sections.
|
|
999
|
+
|
|
1000
|
+
**Data flow**:
|
|
1001
|
+
|
|
1002
|
+
```
|
|
1003
|
+
.gsd-t/spawns/*.json ─┐ (D8 writer)
|
|
1004
|
+
.gsd-t/events/*.jsonl ─┼─▶ bin/parallelism-report.cjs
|
|
1005
|
+
.gsd-t/domains/*/tasks.md ┤ │
|
|
1006
|
+
.gsd-t/token-log.md ─┘ (pure I/O — never writes, never spawns)
|
|
1007
|
+
│
|
|
1008
|
+
▼
|
|
1009
|
+
scripts/gsd-t-dashboard-server.js
|
|
1010
|
+
(5s in-memory cache per wave param)
|
|
1011
|
+
├── GET /api/parallelism → JSON metrics
|
|
1012
|
+
├── GET /api/parallelism/report → markdown
|
|
1013
|
+
└── POST /api/unattended-stop → writes sentinel
|
|
1014
|
+
│
|
|
1015
|
+
▼
|
|
1016
|
+
scripts/gsd-t-transcript.html
|
|
1017
|
+
`<aside class="spawn-panel"> .parallelism-panel`
|
|
1018
|
+
polls /api/parallelism every 5s
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
**Color thresholds** (worst-of across five per-signal colors, from contract §color_state):
|
|
1022
|
+
|
|
1023
|
+
| Signal | Green | Yellow | Red |
|
|
1024
|
+
|--------|-------|--------|-----|
|
|
1025
|
+
| activeWorkers / readyTasks | ≥80% | 50–80% | <50% AND >10 min since last spawn |
|
|
1026
|
+
| Gate veto rate (D4) | <10% | 10–30% | >30% |
|
|
1027
|
+
| parallelism_factor vs. D6 estimate | ≥80% | 50–80% | <50% |
|
|
1028
|
+
| Spawn age (any active worker) | <30 min | 30–45 min | >45 min |
|
|
1029
|
+
| Time since last `spawn_started` (when ready>0) | <5 min | 5–10 min | >10 min |
|
|
1030
|
+
|
|
1031
|
+
Special: `color_state === "dimmed"` when no spawn-plan files exist (idle
|
|
1032
|
+
project) — never red.
|
|
1033
|
+
|
|
1034
|
+
**Silent-fail invariant**: malformed spawn-plan JSON, corrupt JSONL event
|
|
1035
|
+
lines, missing `.gsd-t/domains/`, missing `.gsd-t/token-log.md` — every
|
|
1036
|
+
case logs a note to `metrics.notes` / Full Report `## Notes` and continues
|
|
1037
|
+
with partial data. Observer must never throw when watching a live system.
|
|
1038
|
+
|
|
1039
|
+
**Contract**: `.gsd-t/contracts/parallelism-report-contract.md` v1.0.0.
|
|
1040
|
+
|
|
1041
|
+
**Tests**: `test/m44-d9-parallelism.test.js` — 16 tests covering metric
|
|
1042
|
+
shape, parallelism_factor math (live 1-worker, 4-worker, mixed-duration,
|
|
1043
|
+
post-wave), color-state thresholds, silent-fail on malformed inputs, Full
|
|
1044
|
+
Report markdown sections, and `/api/parallelism*` endpoint shape + 5s
|
|
1045
|
+
cache behaviour.
|
|
1046
|
+
|
|
981
1047
|
## Planned Architecture Changes (M23-M24)
|
|
982
1048
|
|
|
983
1049
|
**M23: Headless Mode**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "3.18.
|
|
3
|
+
"version": "3.18.17",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -131,7 +131,20 @@ function writeRow(payload) {
|
|
|
131
131
|
/**
|
|
132
132
|
* Find the most-recently-modified .ndjson in <cwd>/.gsd-t/transcripts/.
|
|
133
133
|
* Returns the absolute path, or null if none exists or the directory is absent.
|
|
134
|
+
*
|
|
135
|
+
* Target-selection rules (M45 D2):
|
|
136
|
+
* 1. Default: prefer the most-recently-modified SPAWN NDJSON (any file whose
|
|
137
|
+
* basename does NOT start with `in-session-`).
|
|
138
|
+
* 2. Fallback: if no spawn NDJSON has been modified within the last
|
|
139
|
+
* IN_SESSION_FALLBACK_MS (30s) AND a fresh `in-session-*.ndjson` exists,
|
|
140
|
+
* target that file instead. Log the decision to stderr so regressions
|
|
141
|
+
* are obvious during debugging.
|
|
142
|
+
* 3. Last resort: if only in-session NDJSONs exist, pick the newest one
|
|
143
|
+
* (covers the edge case where a user triggers /compact during a pure
|
|
144
|
+
* conversation session with no spawns in flight).
|
|
134
145
|
*/
|
|
146
|
+
const IN_SESSION_FALLBACK_MS = 30 * 1000; // 30 seconds
|
|
147
|
+
|
|
135
148
|
function findActiveTranscript(cwd) {
|
|
136
149
|
const transcriptsDir = path.join(cwd, ".gsd-t", "transcripts");
|
|
137
150
|
let entries;
|
|
@@ -143,21 +156,51 @@ function findActiveTranscript(cwd) {
|
|
|
143
156
|
const ndjsons = entries.filter((e) => e.endsWith(".ndjson"));
|
|
144
157
|
if (!ndjsons.length) return null;
|
|
145
158
|
|
|
146
|
-
let
|
|
147
|
-
let
|
|
159
|
+
let newestSpawn = null;
|
|
160
|
+
let newestSpawnMtime = -1;
|
|
161
|
+
let newestInSession = null;
|
|
162
|
+
let newestInSessionMtime = -1;
|
|
148
163
|
for (const name of ndjsons) {
|
|
149
164
|
const full = path.join(transcriptsDir, name);
|
|
165
|
+
let mtimeMs;
|
|
150
166
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const isInSession = name.indexOf("in-session-") === 0;
|
|
172
|
+
if (isInSession) {
|
|
173
|
+
if (mtimeMs > newestInSessionMtime) {
|
|
174
|
+
newestInSessionMtime = mtimeMs;
|
|
175
|
+
newestInSession = full;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
if (mtimeMs > newestSpawnMtime) {
|
|
179
|
+
newestSpawnMtime = mtimeMs;
|
|
180
|
+
newestSpawn = full;
|
|
155
181
|
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const spawnFresh = newestSpawn != null && (now - newestSpawnMtime) < IN_SESSION_FALLBACK_MS;
|
|
187
|
+
|
|
188
|
+
if (spawnFresh) return newestSpawn;
|
|
189
|
+
|
|
190
|
+
if (newestInSession != null) {
|
|
191
|
+
try {
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
"compact-detector: targeting " + path.basename(newestInSession) + " (fallback)\n"
|
|
194
|
+
);
|
|
156
195
|
} catch {
|
|
157
|
-
//
|
|
196
|
+
// silent — stderr write must never break the hook
|
|
158
197
|
}
|
|
198
|
+
return newestInSession;
|
|
159
199
|
}
|
|
160
|
-
|
|
200
|
+
|
|
201
|
+
// No fresh spawn, no in-session — fall back to the newest spawn (even if stale)
|
|
202
|
+
// so v1.0.0 behavior is preserved for legacy projects with no in-session file.
|
|
203
|
+
return newestSpawn;
|
|
161
204
|
}
|
|
162
205
|
|
|
163
206
|
/**
|
|
@@ -176,102 +176,37 @@ function isValidSpawnId(id) {
|
|
|
176
176
|
return typeof id === "string" && /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
function handleTranscriptsList(req, res, projectDir) {
|
|
179
|
+
function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
180
180
|
const idx = readTranscriptsIndex(projectDir);
|
|
181
181
|
const sorted = idx.spawns
|
|
182
182
|
.slice()
|
|
183
183
|
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
184
184
|
|
|
185
185
|
// Content negotiation: browser navigations send Accept: text/html, fetch()
|
|
186
|
-
// defaults to */*.
|
|
187
|
-
//
|
|
186
|
+
// defaults to */*. For text/html we serve the viewer (same HTML as
|
|
187
|
+
// /transcript/:id) with an empty spawn-id placeholder — the viewer's left
|
|
188
|
+
// rail populates from /api/spawns-index and the main pane defers until the
|
|
189
|
+
// user clicks a spawn. Programmatic clients (fetch's default */* or explicit
|
|
190
|
+
// application/json) continue to get the JSON shape the dashboard JS already
|
|
191
|
+
// consumes.
|
|
188
192
|
const accept = String(req.headers["accept"] || "");
|
|
189
|
-
if (accept.includes("text/html")) {
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
if (accept.includes("text/html") && transcriptHtmlPath) {
|
|
194
|
+
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
195
|
+
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
196
|
+
// Substitute the __SPAWN_ID__ placeholder with an empty string; the
|
|
197
|
+
// viewer's initialId logic falls through to location.hash (also empty)
|
|
198
|
+
// and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
|
|
199
|
+
// the left rail polls /api/spawns-index independently.
|
|
200
|
+
const html = data.toString("utf8").replace(/__SPAWN_ID__/g, "");
|
|
201
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
202
|
+
res.end(html);
|
|
203
|
+
});
|
|
192
204
|
return;
|
|
193
205
|
}
|
|
194
206
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
195
207
|
res.end(JSON.stringify({ spawns: sorted }));
|
|
196
208
|
}
|
|
197
209
|
|
|
198
|
-
function renderTranscriptsHtml(spawns) {
|
|
199
|
-
const escape = (s) => String(s == null ? "" : s)
|
|
200
|
-
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
201
|
-
.replace(/"/g, """);
|
|
202
|
-
const fmtDuration = (a, b) => {
|
|
203
|
-
const start = Date.parse(a); const end = Date.parse(b || "") || Date.now();
|
|
204
|
-
if (!Number.isFinite(start)) return "—";
|
|
205
|
-
const ms = Math.max(0, end - start);
|
|
206
|
-
const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const r = s % 60;
|
|
207
|
-
return m > 0 ? `${m}m ${r}s` : `${r}s`;
|
|
208
|
-
};
|
|
209
|
-
const fmtTime = (s) => { const d = new Date(s); return Number.isFinite(d.getTime()) ? d.toLocaleString() : "—"; };
|
|
210
|
-
const liveStatuses = new Set(["initializing", "running"]);
|
|
211
|
-
const isLive = (s) => liveStatuses.has(s);
|
|
212
|
-
|
|
213
|
-
const rows = spawns.map((s) => {
|
|
214
|
-
const live = isLive(s.status);
|
|
215
|
-
const statusBadge = `<span class="status status-${escape(s.status || 'unknown')}">${escape(s.status || 'unknown')}</span>`;
|
|
216
|
-
return `<tr class="${live ? 'row-live' : ''}">
|
|
217
|
-
<td><a href="/transcript/${encodeURIComponent(s.spawnId)}">${escape(s.spawnId)}</a></td>
|
|
218
|
-
<td>${escape(s.command || '—')}</td>
|
|
219
|
-
<td>${escape(s.description || '—')}</td>
|
|
220
|
-
<td>${statusBadge}</td>
|
|
221
|
-
<td>${escape(fmtTime(s.startedAt))}</td>
|
|
222
|
-
<td>${escape(fmtDuration(s.startedAt, s.endedAt))}</td>
|
|
223
|
-
</tr>`;
|
|
224
|
-
}).join("");
|
|
225
|
-
|
|
226
|
-
const empty = `<div class="empty">
|
|
227
|
-
<h2>No spawn transcripts yet</h2>
|
|
228
|
-
<p>Transcripts appear here as soon as the first agent spawns. Run any GSD-T command (for example <code>/gsd-t-quick</code>) to generate one.</p>
|
|
229
|
-
<p><a href="/">← Back to dashboard</a></p>
|
|
230
|
-
</div>`;
|
|
231
|
-
|
|
232
|
-
const table = spawns.length ? `<table>
|
|
233
|
-
<thead><tr><th>Spawn ID</th><th>Command</th><th>Description</th><th>Status</th><th>Started</th><th>Duration</th></tr></thead>
|
|
234
|
-
<tbody>${rows}</tbody>
|
|
235
|
-
</table>` : empty;
|
|
236
|
-
|
|
237
|
-
return `<!DOCTYPE html>
|
|
238
|
-
<html lang="en"><head><meta charset="UTF-8"><title>GSD-T Transcripts</title>
|
|
239
|
-
<style>
|
|
240
|
-
:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--muted:#7d8590;
|
|
241
|
-
--green:#3fb950;--green-bg:#1a3a1e;--blue:#388bfd;--blue-bg:#1f3a5f;--yellow:#d29922;--red:#f85149;
|
|
242
|
-
--font:'SF Mono','Fira Code',Menlo,monospace;}
|
|
243
|
-
*{box-sizing:border-box;margin:0;padding:0}
|
|
244
|
-
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;padding:20px;line-height:1.5}
|
|
245
|
-
.hdr{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:12px;border-bottom:1px solid var(--border)}
|
|
246
|
-
.logo{color:var(--blue);font-weight:bold;font-size:14px}
|
|
247
|
-
.hright{margin-left:auto;color:var(--muted);font-size:11px}
|
|
248
|
-
a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
|
|
249
|
-
table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
250
|
-
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);font-size:12px}
|
|
251
|
-
th{background:#1c2128;color:var(--muted);text-transform:uppercase;font-size:10px;letter-spacing:0.5px}
|
|
252
|
-
tbody tr:last-child td{border-bottom:none}
|
|
253
|
-
tbody tr:hover{background:#1c2128}
|
|
254
|
-
tr.row-live{background:var(--green-bg)}
|
|
255
|
-
.status{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;border:1px solid}
|
|
256
|
-
.status-running,.status-initializing{background:var(--green-bg);color:var(--green);border-color:var(--green)}
|
|
257
|
-
.status-done{background:var(--blue-bg);color:var(--blue);border-color:var(--blue)}
|
|
258
|
-
.status-stopped{background:#2a2a2a;color:var(--muted);border-color:var(--border)}
|
|
259
|
-
.status-failed,.status-crashed{background:#3a1a1a;color:var(--red);border-color:var(--red)}
|
|
260
|
-
.status-unknown{color:var(--muted);border-color:var(--border)}
|
|
261
|
-
.empty{text-align:center;padding:60px 20px;color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:6px}
|
|
262
|
-
.empty h2{color:var(--text);margin-bottom:12px;font-size:16px}
|
|
263
|
-
.empty p{margin-bottom:8px}
|
|
264
|
-
.empty code{background:#1c2128;padding:2px 6px;border-radius:3px;color:var(--green)}
|
|
265
|
-
</style></head><body>
|
|
266
|
-
<div class="hdr">
|
|
267
|
-
<span class="logo">GSD-T Transcripts</span>
|
|
268
|
-
<a href="/" style="font-size:11px">← Dashboard</a>
|
|
269
|
-
<span class="hright">${spawns.length} spawn${spawns.length === 1 ? '' : 's'}</span>
|
|
270
|
-
</div>
|
|
271
|
-
${table}
|
|
272
|
-
</body></html>`;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
210
|
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
|
|
276
211
|
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
277
212
|
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
@@ -578,6 +513,116 @@ function listActiveSpawnPlans(projectDir) {
|
|
|
578
513
|
return listAllSpawnPlans(projectDir).filter((p) => p && p.endedAt == null);
|
|
579
514
|
}
|
|
580
515
|
|
|
516
|
+
// ── M44 D9 — Parallelism observability ──────────────────────────────────────
|
|
517
|
+
// Additive endpoints powered by bin/parallelism-report.cjs (v1.0.0 contract).
|
|
518
|
+
// Pure read-only observer; never writes, never spawns. 5-second per-response
|
|
519
|
+
// cache so rapid panel polls don't hammer the filesystem.
|
|
520
|
+
|
|
521
|
+
const PARALLELISM_CACHE_MS = 5000;
|
|
522
|
+
const _parallelismCache = { metrics: { at: 0, body: null, wave: null }, report: new Map() };
|
|
523
|
+
|
|
524
|
+
function _loadParallelismReporter() {
|
|
525
|
+
try {
|
|
526
|
+
// Resolve at call-time so tests that don't install the module don't break
|
|
527
|
+
// unrelated endpoints. Require is cached by Node after first success.
|
|
528
|
+
return require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"));
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return { _loadError: err && err.message || String(err) };
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function handleParallelism(req, res, projectDir) {
|
|
535
|
+
const urlObj = req.url ? req.url.split("?") : ["", ""];
|
|
536
|
+
const qs = urlObj[1] || "";
|
|
537
|
+
const params = new URLSearchParams(qs);
|
|
538
|
+
const wave = params.get("wave") || null;
|
|
539
|
+
const now = Date.now();
|
|
540
|
+
const cache = _parallelismCache.metrics;
|
|
541
|
+
if (cache.body && cache.wave === wave && (now - cache.at) < PARALLELISM_CACHE_MS) {
|
|
542
|
+
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
|
|
543
|
+
res.end(cache.body);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const reporter = _loadParallelismReporter();
|
|
547
|
+
if (reporter._loadError) {
|
|
548
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
549
|
+
res.end(JSON.stringify({ error: "parallelism-report module unavailable", detail: reporter._loadError }));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
let metrics;
|
|
553
|
+
try {
|
|
554
|
+
metrics = reporter.computeParallelismMetrics({ projectDir, wave: wave || undefined });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// Contract says silent-fail; a thrown error here means contract regression.
|
|
557
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
558
|
+
res.end(JSON.stringify({ error: "computeParallelismMetrics threw", detail: err && err.message || String(err) }));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const body = JSON.stringify(metrics);
|
|
562
|
+
cache.at = now;
|
|
563
|
+
cache.wave = wave;
|
|
564
|
+
cache.body = body;
|
|
565
|
+
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
|
|
566
|
+
res.end(body);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function handleParallelismReport(req, res, projectDir) {
|
|
570
|
+
const urlObj = req.url ? req.url.split("?") : ["", ""];
|
|
571
|
+
const qs = urlObj[1] || "";
|
|
572
|
+
const params = new URLSearchParams(qs);
|
|
573
|
+
const wave = params.get("wave") || null;
|
|
574
|
+
const cacheKey = wave || "__all__";
|
|
575
|
+
const now = Date.now();
|
|
576
|
+
const cached = _parallelismCache.report.get(cacheKey);
|
|
577
|
+
if (cached && (now - cached.at) < PARALLELISM_CACHE_MS) {
|
|
578
|
+
res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
|
|
579
|
+
res.end(cached.body);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const reporter = _loadParallelismReporter();
|
|
583
|
+
if (reporter._loadError) {
|
|
584
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
585
|
+
res.end("parallelism-report module unavailable: " + reporter._loadError);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
let md;
|
|
589
|
+
try {
|
|
590
|
+
md = reporter.buildFullReport({ projectDir, wave: wave || undefined });
|
|
591
|
+
} catch (err) {
|
|
592
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
593
|
+
res.end("buildFullReport threw: " + (err && err.message || String(err)));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
_parallelismCache.report.set(cacheKey, { at: now, body: md });
|
|
597
|
+
res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
|
|
598
|
+
res.end(md);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// POST /api/unattended-stop — proxies to the existing stop-sentinel flow so
|
|
602
|
+
// the transcript panel's "Stop Supervisor" button reuses the canonical
|
|
603
|
+
// kill path. Writes `.gsd-t/.unattended/stop` sentinel; supervisor polls
|
|
604
|
+
// it and self-exits. Contract reminder: D9 does NOT implement its own
|
|
605
|
+
// stop logic, does NOT PID-kill.
|
|
606
|
+
function handleUnattendedStop(req, res, projectDir) {
|
|
607
|
+
if (req.method !== "POST") {
|
|
608
|
+
res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
|
|
609
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const stopDir = path.join(projectDir, ".gsd-t", ".unattended");
|
|
613
|
+
const stopFile = path.join(stopDir, "stop");
|
|
614
|
+
try {
|
|
615
|
+
fs.mkdirSync(stopDir, { recursive: true });
|
|
616
|
+
fs.writeFileSync(stopFile, new Date().toISOString() + "\n");
|
|
617
|
+
} catch (err) {
|
|
618
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
619
|
+
res.end(JSON.stringify({ error: "failed to write stop sentinel", detail: err && err.message || String(err) }));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
623
|
+
res.end(JSON.stringify({ ok: true, sentinel: stopFile }));
|
|
624
|
+
}
|
|
625
|
+
|
|
581
626
|
function handleSpawnPlans(req, res, projectDir) {
|
|
582
627
|
const plans = listActiveSpawnPlans(projectDir).sort((a, b) => {
|
|
583
628
|
const ta = Date.parse(a.startedAt) || 0;
|
|
@@ -637,10 +682,15 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
637
682
|
if (url === "/metrics") return handleMetrics(req, res, projDir);
|
|
638
683
|
if (url === "/ping") return handlePing(req, res, port);
|
|
639
684
|
if (url === "/stop") return handleStop(req, res, server);
|
|
640
|
-
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir);
|
|
685
|
+
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
|
|
641
686
|
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
642
687
|
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
643
688
|
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
|
|
689
|
+
// M44 D9 — parallelism observability (additive, read-only)
|
|
690
|
+
if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
|
|
691
|
+
if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
|
|
692
|
+
// M44 D9 — stop-supervisor proxy (POST only; reuses existing sentinel flow)
|
|
693
|
+
if (url === "/api/unattended-stop") return handleUnattendedStop(req, res, projDir);
|
|
644
694
|
// POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
|
|
645
695
|
const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
|
|
646
696
|
if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
|
|
@@ -674,7 +724,6 @@ module.exports = {
|
|
|
674
724
|
readIndexEntry,
|
|
675
725
|
isValidSpawnId,
|
|
676
726
|
handleTranscriptsList,
|
|
677
|
-
renderTranscriptsHtml,
|
|
678
727
|
handleTranscriptStream,
|
|
679
728
|
handleTranscriptPage,
|
|
680
729
|
handleTranscriptKill,
|
|
@@ -692,6 +741,10 @@ module.exports = {
|
|
|
692
741
|
handleSpawnPlanUpdates,
|
|
693
742
|
readSpawnPlanFile,
|
|
694
743
|
spawnsDir,
|
|
744
|
+
// M44 D9 — parallelism observability
|
|
745
|
+
handleParallelism,
|
|
746
|
+
handleParallelismReport,
|
|
747
|
+
handleUnattendedStop,
|
|
695
748
|
};
|
|
696
749
|
|
|
697
750
|
if (require.main === module) {
|