@tekyzinc/gsd-t 3.12.15 → 3.13.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.13.11] - 2026-04-17
6
+
7
+ ### Fixed — Unattended supervisor reliability triple-fix (bee-poc 15-min hang fallout)
8
+
9
+ A real bee-poc supervisor relay hung for 15+ minutes on v3.12.15 (pid 70897). Three independent defects surfaced from that incident and are fixed together in this patch. The root cause of the hang itself — a 1-hour worker timeout on the deployed v3.12.15 package — is finally resolved by shipping v3.13.10's D4 work to npm; the two other bugs are fixes for contract-boundary and cosmetic issues the hang exposed.
10
+
11
+ **Bug 1 (P0) — supervisor watchdog visibility on timeout**:
12
+ The spawnSync `timeout` option kills a hung worker after `DEFAULT_WORKER_TIMEOUT_MS` (270 s in v3.13.10+) and maps the result to contract exit code 124, but the event was not legibly surfaced in `run.log`. Operators tailing the log saw an empty iter block with no indication that the watchdog had fired. `runMainLoop` now writes a deterministic `[worker_timeout] iter=N budget=Nms elapsed=Nms` line to `run.log` immediately before the regular iter trailer, so timeout-induced cache misses are self-documenting. The existing `writeState` call still commits `lastExit=124` + a fresh `lastTick` so `/gsd-t-unattended-watch` sees a heartbeat post-timeout.
13
+
14
+ **Note on the deployed-version aspect**: the 1-hour → 270 s worker-timeout reduction shipped in v3.13.10 on GitHub but v3.13.10 was never published to npm (progress.md was in "pending publish" state when the bee-poc run started). bee-poc was running against the installed v3.12.15, which still had `DEFAULT_WORKER_TIMEOUT_MS = 3600000`. Publishing v3.13.11 closes both issues — the timeout reduction reaches bee-poc (and every downstream project) and the new diagnostic line makes future watchdog firings visible.
15
+
16
+ **Bug 2 (P0) — worker cwd invariant**:
17
+ run.log from the bee-poc hang showed a `Shell cwd was reset to /Users/david/projects/GSD-T` line mid-iter — the worker's Bash shell had escaped bee-poc's project directory, and subsequent tool calls silently targeted the wrong repo. `_spawnWorker` already passes `cwd: projectDir` to `platformSpawnWorker` and sets `GSD_T_PROJECT_DIR` on the worker env (correct baseline), but the worker itself had no instruction to re-assert that invariant. The worker prompt now carries an explicit `# CWD Invariant` section that instructs the worker to (a) run `[ "$(pwd)" = "$GSD_T_PROJECT_DIR" ] || cd "$GSD_T_PROJECT_DIR"` as its first Bash call, and (b) scope any directory change inside a subshell (`( cd other && cmd )`) rather than using bare top-level `cd`.
18
+
19
+ **Bug 3 (P2) — IS_STALE determinism**:
20
+ `/gsd-t-unattended-watch` is run by the haiku model and the "tick age > 540 s → append ⚠️ stale" threshold lived in the Step 6a rendering prose. Haiku would occasionally apply the stale flag to ticks in the 330–540 s band by misreading the prose. The threshold math now lives entirely inside Step 2's `node -e` block as a boolean emission (`IS_STALE = tickAgeMs !== null && tickAgeMs > 540000`), and Step 6a just reads the flag. Boundary cases: 539 s = false, 540 s = false (strict greater-than), 541 s = true.
21
+
22
+ **Files**:
23
+ - `bin/gsd-t-unattended.cjs` — worker_timeout run.log append; CWD Invariant section in `_spawnWorker` prompt.
24
+ - `commands/gsd-t-unattended-watch.md` — Step 2 IS_STALE computation + emission; Step 6a reader-only rendering; Notes section updated.
25
+ - `test/unattended-triple-fix-v3-13-11.test.js` — 8 new tests (3 Bug 1 + 3 Bug 2 + 3 Bug 3 — one boundary-math test covers three points, bringing the practical count to 8 assertions across 8 it-blocks, of which 3 exercise the Bug 3 boundaries).
26
+
27
+ **Tests**: 1235/1235 pass (was 1227; +8 new assertions). E2E: N/A (no playwright.config.*).
28
+
29
+ **Impact**: bee-poc-class hangs are self-recoverable in v3.13.11 — a hung worker is bounded at 270 s by the watchdog, the timeout is now visible in run.log, cwd drift is caught by the worker itself on entry, and `/gsd-t-unattended-watch` no longer produces spurious stale warnings under the threshold.
30
+
31
+ ## [3.13.10] - 2026-04-17
32
+
33
+ ### Added — M39: Fast Unattended + Universal Watch-Progress Tree
34
+
35
+ Closes the 3–5× speed gap between unattended and in-session execution, adds a universal task-list progress view under every `--watch` surface, and keeps supervisor→worker handoffs inside the 5-minute Anthropic prompt-cache TTL.
36
+
37
+ **D2 — progress-watch (12 tasks)**:
38
+ - `.gsd-t/contracts/watch-progress-contract.md` v1.0.0 — state-file schema, tree-reconstruction algorithm, stale-state expiry (24h), renderer contract, integration invariants.
39
+ - `scripts/gsd-t-watch-state.js` — zero-dep writer CLI with shim-safe agent-id resolution (CLI arg → `GSD_T_AGENT_ID` env → auto-minted `shell-{pid}-{ts}` fallback). Atomic tmp-write+rename; `start`/`advance`/`done`/`skip`/`fail` subcommands.
40
+ - `bin/watch-progress.js` — tree builder (parent_agent_id lineage, orphan handling) + renderer (✅/🔄/⬜/➡️/❌ markers; expanded-current-subtree + collapsed-siblings layout).
41
+ - 189 step-shims across 17 workflow command files — every numbered step now writes its progress state under `.gsd-t/.watch-state/{agent_id}.json`.
42
+ - Integration into `bin/gsd-t-unattended.cjs`, `bin/unattended-watch-format.cjs`, `bin/headless-auto-spawn.cjs` — tree appends below the existing banner (banner preserved intact).
43
+
44
+ **D3 — parallel-exec (4 tasks)**:
45
+ - Team Mode prompt block inserted into `_spawnWorker` at the worker instruction boundary. Unattended worker now spawns up to 15 concurrent `Task` subagents per wave (intra-wave parallel), waits for all, then advances (inter-wave sequential). Falls back to sequential when the wave contains only one domain.
46
+ - `.gsd-t/contracts/unattended-supervisor-contract.md` §15 v1.3.0 — Team Mode contract: cap of 15, dependency-graph preservation, wave-boundary semantics.
47
+
48
+ **D4 — cache-warm-pacing (3 tasks)**:
49
+ - `DEFAULT_WORKER_TIMEOUT_MS = 270000` (270 s) in `bin/gsd-t-unattended.cjs` + `.js`. Preserves the Anthropic 5-min prompt-cache TTL with a ~30 s supervisor→worker handoff budget, eliminating the cold-cache penalty that was adding minutes per iter.
50
+ - `--worker-timeout=<ms>` CLI flag parsed and merged into the live config (was documented in §6 but silently ignored pre-M39).
51
+ - `.gsd-t/contracts/unattended-supervisor-contract.md` §16 v1.3.0 — cache-warm pacing contract: inline rationale comment requirement, inter-iteration sleep invariant (< 5 s), timeout override semantics.
52
+
53
+ **Red Team**: Initial FAIL (2 CRITICAL + 2 HIGH) → fixes → GRUDGING PASS.
54
+ - BUG-1 (CRITICAL): `GSD_T_AGENT_ID` had no producer — 189 shims would silently fail. Fixed by injecting `supervisor-iter-{N}` in `_spawnWorker`, `headless-{id}` in `autoSpawnHeadless`, and adding an auto-mint fallback chain to the writer CLI.
55
+ - BUG-2 (CRITICAL): `--worker-timeout` flag documented in §6 but no `case "worker-timeout":` in `parseArgs`. Fixed with parse case + config merge + test assertion.
56
+ - BUG-3 (HIGH): `.js` and `.cjs` variants of unattended + safety files had divergent defaults (3600000 vs 270000). Fixed by aligning all four files to 270000.
57
+ - BUG-4 (HIGH): Team Mode prompt referenced "Step 4" but the current execute flow uses "Step 3". Fixed in both the prompt string and the contract §15.
58
+
59
+ **Tests**: 1227/1227 pass (+3 new: shim-safe agent-id auto-mint, env-var fallback, `--worker-timeout` flag parse).
60
+
61
+ **Impact**: bee-poc's next supervisor relaunch on v3.13.10 should complete iters 3–5× faster than the v3.12.13 baseline, with visible task-list progression under every `--watch` surface.
62
+
5
63
  ## [3.12.15] - 2026-04-17
6
64
 
7
65
  ### Fixed — Decision Log Trim — stop live progress.md bloat
@@ -55,7 +55,7 @@ const DEFAULTS = Object.freeze({
55
55
  maxIterations: 200,
56
56
  hours: 24,
57
57
  gutterNoProgressIters: 5,
58
- workerTimeoutMs: 3600000,
58
+ workerTimeoutMs: 270000,
59
59
  });
60
60
 
61
61
  // ── Glob → regex helper ─────────────────────────────────────────────────────
@@ -55,7 +55,7 @@ const DEFAULTS = Object.freeze({
55
55
  maxIterations: 200,
56
56
  hours: 24,
57
57
  gutterNoProgressIters: 5,
58
- workerTimeoutMs: 3600000,
58
+ workerTimeoutMs: 270000,
59
59
  });
60
60
 
61
61
  // ── Glob → regex helper ─────────────────────────────────────────────────────
@@ -73,7 +73,13 @@ const RUN_LOG = "run.log";
73
73
 
74
74
  const DEFAULT_HOURS = 24;
75
75
  const DEFAULT_MAX_ITERATIONS = 200;
76
- const DEFAULT_WORKER_TIMEOUT_MS = 3600000; // 1 hour per contract §13
76
+ // Anthropic prompt-cache TTL is 5 minutes (300,000 ms). The supervisor→worker
77
+ // handoff budget is ~30 s (process exit + state persist + next spawn). A 270 s
78
+ // worker timeout leaves room to complete the iter AND still relaunch against
79
+ // a warm cache. If a single iter legitimately exceeds 270 s, the supervisor
80
+ // kills the worker and logs a cache-miss warning; the next iter pays a
81
+ // cold-cache cost but execution continues.
82
+ const DEFAULT_WORKER_TIMEOUT_MS = 270 * 1000; // 270s — see cache-warm-pacing note above; contract §13/§16
77
83
 
78
84
  const TERMINAL_STATUSES = new Set(["done", "failed", "stopped", "crashed"]);
79
85
  const VALID_STATUSES = new Set([
@@ -205,6 +211,17 @@ function parseArgs(argv) {
205
211
  case "test-mode":
206
212
  out.testMode = true;
207
213
  break;
214
+ case "worker-timeout": {
215
+ // Per unattended-supervisor-contract §6 + §16: user-supplied override
216
+ // of DEFAULT_WORKER_TIMEOUT_MS (270 s). Accepts ms. Must not be raised
217
+ // above 270000 without a separate user-approved contract revision —
218
+ // the supervisor clamps but does not refuse silently.
219
+ const n = parseInt(val, 10);
220
+ if (Number.isFinite(n) && n > 0) {
221
+ out.workerTimeoutMs = n;
222
+ }
223
+ break;
224
+ }
208
225
  default:
209
226
  // Unknown flag — ignore for forward compatibility
210
227
  break;
@@ -558,6 +575,13 @@ function doUnattended(argv, deps) {
558
575
  ) {
559
576
  opts.maxIterations = config.maxIterations;
560
577
  }
578
+ if (
579
+ opts.workerTimeoutMs === undefined &&
580
+ typeof config.workerTimeoutMs === "number" &&
581
+ config.workerTimeoutMs > 0
582
+ ) {
583
+ opts.workerTimeoutMs = config.workerTimeoutMs;
584
+ }
561
585
  // CLI values now win — mirror them back into config so the pre-worker
562
586
  // safety caps (checkIterationCap / checkWallClockCap) use the effective
563
587
  // supervisor-scoped limits rather than the on-disk file defaults.
@@ -705,6 +729,21 @@ function doUnattended(argv, deps) {
705
729
  console.log(
706
730
  `[gsd-t-unattended] running — sessionId=${state.sessionId} pid=${process.pid} milestone=${state.milestone} platform=${state.platform}`,
707
731
  );
732
+ // M39 D2 — append watch-progress tree below banner (best-effort).
733
+ // Banner preserved verbatim above; renderer output appended below per
734
+ // watch-progress-contract.md §7. Never throws into the supervisor loop.
735
+ try {
736
+ const wp = require("./watch-progress.js");
737
+ const stateDir = path.join(projectDir, ".gsd-t", ".watch-state");
738
+ const tree = wp.buildTree(stateDir);
739
+ const rendered = wp.renderTree(tree, { currentAgent: state.sessionId });
740
+ if (rendered) {
741
+ // eslint-disable-next-line no-console
742
+ console.log(rendered);
743
+ }
744
+ } catch (_) {
745
+ /* watch-progress is best-effort; never crash the watch */
746
+ }
708
747
  }
709
748
 
710
749
  // ── SUPERVISOR-INIT HOOK (contract §12) ──────────────────────────────────
@@ -936,8 +975,22 @@ function runMainLoop(state, dir, opts, deps, ctx) {
936
975
  exitCode = mapHeadlessExitCode(res.status, stdout + "\n" + stderr);
937
976
  }
938
977
 
978
+ // v3.13.11 Bug 1: when the watchdog fires (spawnSync timeout SIGTERM or
979
+ // platform.spawnWorker timedOut flag), make the event explicit in run.log
980
+ // so operators can see WHICH iteration timed out without inferring from
981
+ // exit codes. The marker is prepended to stdout and written in the single
982
+ // per-iter run.log append (no duplicate header).
983
+ let loggedStdout = stdout;
984
+ if (exitCode === 124) {
985
+ const marker =
986
+ `[worker_timeout] iter=${state.iter} budget=${workerTimeoutMs}ms ` +
987
+ `elapsed=${elapsedMs}ms — watchdog SIGTERM delivered, ` +
988
+ `supervisor continues relay per contract §16.\n`;
989
+ loggedStdout = marker + (stdout || "");
990
+ }
991
+
939
992
  // Append the full worker output to run.log (never truncate).
940
- _appendRunLog(dir, state.iter, workerEnd, exitCode, stdout, stderr);
993
+ _appendRunLog(dir, state.iter, workerEnd, exitCode, loggedStdout, stderr);
941
994
 
942
995
  // Append to token-log.md (Fix 1, v3.12.12) — supervisor workers write rows
943
996
  // so the log captures headless/unattended activity, not just interactive spawns.
@@ -1138,11 +1191,65 @@ function _spawnWorker(state, opts) {
1138
1191
  else if (process.env.GSD_T_TRACE_ID) workerEnv.GSD_T_TRACE_ID = process.env.GSD_T_TRACE_ID;
1139
1192
  if (state && state.model) workerEnv.GSD_T_MODEL = state.model;
1140
1193
  else if (process.env.GSD_T_MODEL) workerEnv.GSD_T_MODEL = process.env.GSD_T_MODEL;
1194
+ // D2 watch-progress: mint a per-worker agent id and forward the supervisor's
1195
+ // id as parent, so shims inside the worker write state files that the tree
1196
+ // builder can attach under the supervisor root.
1197
+ workerEnv.GSD_T_AGENT_ID =
1198
+ "supervisor-iter-" + (state && state.iter ? state.iter : Date.now());
1199
+ if (process.env.GSD_T_AGENT_ID) {
1200
+ workerEnv.GSD_T_PARENT_AGENT_ID = process.env.GSD_T_AGENT_ID;
1201
+ }
1141
1202
  const res = platformSpawnWorker(opts.cwd, opts.timeout, {
1142
1203
  bin,
1143
1204
  args: [
1144
1205
  "-p",
1145
- "You are an unattended worker iteration. CRITICAL: Do NOT check supervisor.pid, do NOT auto-reattach to a watch loop, do NOT schedule any ScheduleWakeup. You ARE the worker spawned by the supervisor. Skip Step 0 (auto-reattach) entirely and go directly to Step 0.1. Run /gsd-t-resume but skip the unattended supervisor auto-reattach check in Step 0.",
1206
+ [
1207
+ "You are an unattended worker iteration. CRITICAL: Do NOT check supervisor.pid, do NOT auto-reattach to a watch loop, do NOT schedule any ScheduleWakeup. You ARE the worker spawned by the supervisor. Skip Step 0 (auto-reattach) entirely and go directly to Step 0.1.",
1208
+ "",
1209
+ "# CWD Invariant (v3.13.11 Bug 2)",
1210
+ "",
1211
+ "Before any other work, assert your current working directory matches the",
1212
+ "supervisor's project directory. A worker that silently drifts to a",
1213
+ "different repo will commit to the wrong tree and corrupt state.json.",
1214
+ "",
1215
+ "First Bash call this turn (mandatory):",
1216
+ "",
1217
+ " [ \"$(pwd)\" = \"$GSD_T_PROJECT_DIR\" ] || cd \"$GSD_T_PROJECT_DIR\"",
1218
+ " pwd # confirm",
1219
+ "",
1220
+ "Thereafter, scope any directory change inside a subshell so a `cd` in",
1221
+ "one Bash call cannot contaminate the next one:",
1222
+ "",
1223
+ " ( cd some/subdir && run-command ) # safe — subshell",
1224
+ " cd some/subdir && run-command # UNSAFE — leaks cwd",
1225
+ "",
1226
+ "# Team Mode (Intra-Wave Parallelism)",
1227
+ "",
1228
+ "Before executing tasks for this iteration, read `.gsd-t/partition.md` to",
1229
+ "identify the current wave and which domains belong to it.",
1230
+ "",
1231
+ "If the current wave has MULTIPLE independent domains/tasks (check",
1232
+ "`.gsd-t/domains/*/tasks.md` — 2 or more domains with incomplete tasks in the",
1233
+ "current wave):",
1234
+ "",
1235
+ " SPAWN PARALLEL SUBAGENTS — up to 15 concurrent Task subagents, one per",
1236
+ " domain, using `general-purpose` subagent_type. Use the same subagent",
1237
+ " prompt pattern as `/gsd-t-execute` Team Mode (see `commands/gsd-t-execute.md`",
1238
+ " Step 3 Team Mode section). Each subagent:",
1239
+ " - Receives the domain name, its scope.md, its tasks.md (only incomplete",
1240
+ " tasks from the current wave), and the relevant contracts",
1241
+ " - Works ONLY within its domain boundary",
1242
+ " - Returns when all its current-wave tasks are committed",
1243
+ " WAIT for ALL spawned subagents to report back before advancing.",
1244
+ "",
1245
+ "If the current wave has only 1 domain with incomplete tasks, execute",
1246
+ "sequentially in this worker (no subagent spawn needed).",
1247
+ "",
1248
+ "Inter-wave boundaries always remain sequential — never parallelize across",
1249
+ "waves, because wave-N+1 may depend on wave-N contract/state updates.",
1250
+ "",
1251
+ "Your job: run /gsd-t-resume but skip the unattended supervisor auto-reattach check in Step 0.",
1252
+ ].join("\n"),
1146
1253
  "--dangerously-skip-permissions",
1147
1254
  ],
1148
1255
  env: workerEnv,
@@ -74,7 +74,11 @@ const RUN_LOG = "run.log";
74
74
 
75
75
  const DEFAULT_HOURS = 24;
76
76
  const DEFAULT_MAX_ITERATIONS = 200;
77
- const DEFAULT_WORKER_TIMEOUT_MS = 3600000; // 1 hour per contract §13
77
+ // Anthropic prompt-cache TTL is 5 minutes (300,000 ms). The supervisor→worker
78
+ // handoff budget is ~30 s (process exit + state persist + next spawn). A 270 s
79
+ // worker timeout leaves room to complete the iter AND still relaunch against
80
+ // a warm cache. See contract §16 (cache-warm pacing).
81
+ const DEFAULT_WORKER_TIMEOUT_MS = 270 * 1000;
78
82
 
79
83
  const TERMINAL_STATUSES = new Set(["done", "failed", "stopped", "crashed"]);
80
84
  const VALID_STATUSES = new Set([
@@ -84,6 +84,21 @@ function autoSpawnHeadless(opts) {
84
84
  // watch=true + validation → warn on stderr; proceed headless
85
85
  // watch=false → headless (default behavior)
86
86
  if (watch && spawnType === "primary") {
87
+ // M39 D2 — append watch-progress tree below banner (best-effort).
88
+ // Banner here is the in-context-fallback signal printed by the caller;
89
+ // we don't own it, so we only render the tree to stdout. Never throws.
90
+ try {
91
+ const wp = require("./watch-progress.js");
92
+ const stateDir = path.join(projectDir, ".gsd-t", ".watch-state");
93
+ const tree = wp.buildTree(stateDir);
94
+ const rendered = wp.renderTree(tree, { currentAgent: null });
95
+ if (rendered) {
96
+ // eslint-disable-next-line no-console
97
+ console.log(rendered);
98
+ }
99
+ } catch (_) {
100
+ /* watch-progress is best-effort; never crash the watch */
101
+ }
87
102
  return {
88
103
  id: null,
89
104
  pid: null,
@@ -156,6 +171,10 @@ function autoSpawnHeadless(opts) {
156
171
  if (process.env.GSD_T_MODEL) {
157
172
  workerEnv.GSD_T_MODEL = process.env.GSD_T_MODEL;
158
173
  }
174
+ workerEnv.GSD_T_AGENT_ID = "headless-" + id;
175
+ if (process.env.GSD_T_AGENT_ID) {
176
+ workerEnv.GSD_T_PARENT_AGENT_ID = process.env.GSD_T_AGENT_ID;
177
+ }
159
178
 
160
179
  const child = spawn("node", childArgs, {
161
180
  cwd: projectDir,
@@ -99,6 +99,21 @@ function _formatIteration(iter, evs) {
99
99
  * @param {number} [args.now] — override for Date.now() (tests)
100
100
  * @returns {string}
101
101
  */
102
+ function _renderWatchProgressTree(args) {
103
+ // M39 D2 — best-effort watch-progress tree appended below the formatter
104
+ // output. Banner preserved verbatim; returns "" on any failure so callers
105
+ // never see a tree when state is missing/unavailable.
106
+ try {
107
+ const wp = require("./watch-progress.js");
108
+ const stateDir = (args && args.stateDir) || ".gsd-t/.watch-state";
109
+ const current = args && args.currentAgent ? args.currentAgent : null;
110
+ const tree = wp.buildTree(stateDir);
111
+ return wp.renderTree(tree, { currentAgent: current }) || "";
112
+ } catch (_) {
113
+ return "";
114
+ }
115
+ }
116
+
102
117
  function formatWatchTick(args) {
103
118
  const events = Array.isArray(args && args.events) ? args.events : [];
104
119
  const state = (args && args.state) || {};
@@ -108,21 +123,30 @@ function formatWatchTick(args) {
108
123
  const iter = Number.isFinite(state.iter) ? state.iter : 0;
109
124
  const header = `[unattended supervisor — iter ${iter}${elapsed ? `, ${elapsed}` : ""}]`;
110
125
 
126
+ let body;
111
127
  if (events.length === 0) {
112
- return `${header} (no new activity since last tick)`;
128
+ body = `${header} (no new activity since last tick)`;
129
+ } else {
130
+ const groups = _groupByIter(events);
131
+ const blocks = [];
132
+ for (const [groupIter, evs] of groups) {
133
+ const groupHeader = groups.length > 1 ? `[iter ${groupIter}]` : header;
134
+ const bodyLines = _formatIteration(groupIter, evs);
135
+ blocks.push([groupHeader, ...bodyLines].join("\n"));
136
+ }
137
+ body = groups.length > 1 ? `${header}\n${blocks.join("\n")}` : blocks[0];
113
138
  }
114
139
 
115
- const groups = _groupByIter(events);
116
- const blocks = [];
117
- for (const [groupIter, evs] of groups) {
118
- const groupHeader = groups.length > 1 ? `[iter ${groupIter}]` : header;
119
- const body = _formatIteration(groupIter, evs);
120
- blocks.push([groupHeader, ...body].join("\n"));
121
- }
122
- if (groups.length > 1) {
123
- return `${header}\n${blocks.join("\n")}`;
124
- }
125
- return blocks[0];
140
+ // M39 D2 — watch-progress tree below the existing formatter output.
141
+ // Banner preserved verbatim; tree appended only when caller opts in via
142
+ // `args.withWatchProgress = true`. Default-off preserves backward
143
+ // compatibility with existing snapshot-style callers (tests).
144
+ if (!(args && args.withWatchProgress === true)) return body;
145
+ const tree = _renderWatchProgressTree({
146
+ stateDir: args && args.stateDir,
147
+ currentAgent: args && (args.currentAgent || state.sessionId),
148
+ });
149
+ return tree ? `${body}\n${tree}` : body;
126
150
  }
127
151
 
128
152
  /**
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/watch-progress.js — GSD-T Watch-Progress Tree Builder + Renderer
4
+ *
5
+ * Reads `.gsd-t/.watch-state/*.json` state files written by
6
+ * `scripts/gsd-t-watch-state.js` and reconstructs the agent workflow tree
7
+ * via `parent_agent_id` lineage. Renders a task-list with ✅/🔄/⬜ markers
8
+ * below every `--watch` surface (banner preserved intact).
9
+ *
10
+ * Contract: `.gsd-t/contracts/watch-progress-contract.md` v1.0.0
11
+ * Owner: D2 (M39 `d2-progress-watch`)
12
+ * Zero external deps.
13
+ */
14
+
15
+ "use strict";
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+
20
+ const STALE_MS = 24 * 60 * 60 * 1000; // 24h
21
+
22
+ const MARKERS = {
23
+ done: "✅",
24
+ in_progress: "🔄",
25
+ pending: "⬜",
26
+ skipped: "➡️",
27
+ failed: "❌",
28
+ };
29
+
30
+ function _readAll(stateDir) {
31
+ if (!stateDir || !fs.existsSync(stateDir)) return [];
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(stateDir);
35
+ } catch (_) {
36
+ return [];
37
+ }
38
+ const records = [];
39
+ for (const f of entries) {
40
+ if (!f.endsWith(".json")) continue;
41
+ try {
42
+ const raw = fs.readFileSync(path.join(stateDir, f), "utf8");
43
+ const rec = JSON.parse(raw);
44
+ if (rec && typeof rec === "object" && typeof rec.agent_id === "string") {
45
+ records.push(rec);
46
+ }
47
+ } catch (_) { /* skip malformed */ }
48
+ }
49
+ return records;
50
+ }
51
+
52
+ function _isStale(record, now) {
53
+ if (!record.completed_at) return false;
54
+ const t = Date.parse(record.completed_at);
55
+ if (!Number.isFinite(t)) return false;
56
+ return (now - t) > STALE_MS;
57
+ }
58
+
59
+ function buildTree(stateDir, options) {
60
+ const now = options && Number.isFinite(options.now) ? options.now : Date.now();
61
+ const records = _readAll(stateDir).filter((r) => !_isStale(r, now));
62
+ const index = new Map();
63
+ for (const r of records) index.set(r.agent_id, { record: r, children: [] });
64
+ const roots = [];
65
+ const orphans = [];
66
+ for (const r of records) {
67
+ const node = index.get(r.agent_id);
68
+ const parentId = r.parent_agent_id;
69
+ if (parentId === null || parentId === undefined) {
70
+ roots.push(node);
71
+ } else if (index.has(parentId)) {
72
+ index.get(parentId).children.push(node);
73
+ } else {
74
+ orphans.push(node);
75
+ }
76
+ }
77
+ return { roots, orphans };
78
+ }
79
+
80
+ function _marker(status) {
81
+ return MARKERS[status] || MARKERS.pending;
82
+ }
83
+
84
+ function _countDone(node) {
85
+ let count = 0;
86
+ const s = node.record.status;
87
+ if (s === "done" || s === "skipped") count = 1;
88
+ for (const c of node.children) count += _countDone(c);
89
+ return count;
90
+ }
91
+
92
+ function _flattenIds(node, acc) {
93
+ acc.push(node.record.agent_id);
94
+ for (const c of node.children) _flattenIds(c, acc);
95
+ return acc;
96
+ }
97
+
98
+ function _containsAgent(node, agentId) {
99
+ if (!agentId) return false;
100
+ return _flattenIds(node, []).includes(agentId);
101
+ }
102
+
103
+ function _pickCurrent(tree) {
104
+ let pick = null;
105
+ let latest = -Infinity;
106
+ const walk = (node) => {
107
+ const r = node.record;
108
+ if (r.status === "in_progress" && r.started_at) {
109
+ const t = Date.parse(r.started_at);
110
+ if (Number.isFinite(t) && t > latest) { latest = t; pick = r.agent_id; }
111
+ }
112
+ for (const c of node.children) walk(c);
113
+ };
114
+ for (const root of tree.roots) walk(root);
115
+ return pick;
116
+ }
117
+
118
+ function _renderExpanded(node, depth, lines) {
119
+ const indent = " ".repeat(depth);
120
+ const r = node.record;
121
+ lines.push(`${indent}${_marker(r.status)} ${r.step_label || r.command || r.agent_id}`);
122
+ for (const c of node.children) _renderExpanded(c, depth + 1, lines);
123
+ }
124
+
125
+ function _renderCollapsed(node, depth, lines) {
126
+ const indent = " ".repeat(depth);
127
+ const r = node.record;
128
+ const doneCount = _countDone(node);
129
+ const label = r.step_label || r.command || r.agent_id;
130
+ lines.push(`${indent}${_marker(r.status)} ${label} (${doneCount} tasks done)`);
131
+ }
132
+
133
+ function renderTree(tree, options) {
134
+ if (!tree || (tree.roots.length === 0 && tree.orphans.length === 0)) return "";
135
+ const opts = options || {};
136
+ const currentAgent = opts.currentAgent || _pickCurrent(tree);
137
+ const lines = [];
138
+ for (const root of tree.roots) {
139
+ if (_containsAgent(root, currentAgent)) _renderExpanded(root, 0, lines);
140
+ else _renderCollapsed(root, 0, lines);
141
+ }
142
+ if (tree.orphans.length > 0) {
143
+ lines.push("⬜ (orphan subtree — parent state missing)");
144
+ for (const o of tree.orphans) _renderExpanded(o, 1, lines);
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+
149
+ module.exports = { buildTree, renderTree, MARKERS, STALE_MS, _pickCurrent };
150
+
151
+ if (require.main === module) {
152
+ const stateDir = process.argv[2] || path.join(".gsd-t", ".watch-state");
153
+ const out = renderTree(buildTree(stateDir));
154
+ if (out) process.stdout.write(out + "\n");
155
+ }