@tekyzinc/gsd-t 2.76.10 → 3.10.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.
@@ -0,0 +1,290 @@
1
+ # GSD-T: Unattended Watch — Stateless Watch Tick
2
+
3
+ **Model**: haiku (polling a state file is mechanical — no reasoning needed)
4
+
5
+ You are running one tick of the unattended supervisor watch loop. This command is **stateless**: every firing re-reads `.gsd-t/.unattended/supervisor.pid` + `state.json` + `run.log` from disk and makes a fresh decision. There is no in-memory state between ticks, and a `/clear` followed by `/user:gsd-t-resume` during a live unattended run is a no-op.
6
+
7
+ The watch loop is the user's visibility surface into the running supervisor. It fires every 270 seconds (inside the 5-minute prompt-cache TTL) and either:
8
+ 1. Renders a compact status block + reschedules itself, OR
9
+ 2. Prints a terminal report and STOPS rescheduling.
10
+
11
+ See `unattended-supervisor-contract.md` §8 (Watch Tick Decision Tree), §4 (Status Enum), §3 (state.json schema).
12
+
13
+ ## Decision Tree (Contract §8)
14
+
15
+ ```
16
+ 1. supervisor.pid absent → finalized cleanly → final report, STOP
17
+ 2. kill -0 {pid} fails → crashed → crash diagnostics + log tail, STOP
18
+ 3. state.json.status:
19
+ - done → success report, STOP
20
+ - failed → failure summary, STOP
21
+ - stopped → user-stop confirm, STOP
22
+ - initializing | running → render watch block, go to 4
23
+ 4. ScheduleWakeup(270, '/user:gsd-t-unattended-watch', reason='unattended tick {iter}')
24
+ ```
25
+
26
+ **Critical**: Every branch except `initializing`/`running` is TERMINAL — do NOT reschedule. When you reach a terminal state, print the report and end the turn. Do not add a "Next Up" block — this is a self-rescheduling loop, not a phase workflow.
27
+
28
+ ## Step 1: Check State Directory
29
+
30
+ Run via Bash:
31
+
32
+ ```bash
33
+ if [ ! -d ".gsd-t/.unattended" ]; then
34
+ echo "No supervisor state directory at .gsd-t/.unattended/ — nothing to watch."
35
+ exit 0
36
+ fi
37
+ ```
38
+
39
+ If the directory does not exist, there is no supervisor to watch. Exit 0, do NOT reschedule. End the turn.
40
+
41
+ ## Step 2: Read State, PID, and Run.log Tail
42
+
43
+ Run a single `node -e` block that gathers everything needed for the decision tree. This reads the last ~2KB of `run.log` via `fs.open` + `fs.read` with a negative offset from end (not `readFileSync`), so the command stays cheap even when `run.log` is hundreds of MB.
44
+
45
+ ```bash
46
+ node -e "
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+
50
+ const UDIR = '.gsd-t/.unattended';
51
+ const PID_FILE = path.join(UDIR, 'supervisor.pid');
52
+ const STATE_FILE = path.join(UDIR, 'state.json');
53
+ const LOG_FILE = path.join(UDIR, 'run.log');
54
+
55
+ function out(k, v) { console.log(k + '=' + JSON.stringify(v ?? null)); }
56
+
57
+ // --- PID file ---
58
+ let pid = null;
59
+ let pidFileExists = fs.existsSync(PID_FILE);
60
+ if (pidFileExists) {
61
+ try {
62
+ pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
63
+ if (!Number.isFinite(pid)) pid = null;
64
+ } catch (_) { pid = null; }
65
+ }
66
+ out('PID_FILE_EXISTS', pidFileExists);
67
+ out('PID', pid);
68
+
69
+ // --- kill -0 liveness ---
70
+ let alive = null;
71
+ if (pid !== null) {
72
+ try { process.kill(pid, 0); alive = true; }
73
+ catch (_) { alive = false; }
74
+ }
75
+ out('ALIVE', alive);
76
+
77
+ // --- state.json ---
78
+ let state = null;
79
+ try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); }
80
+ catch (_) {
81
+ // tolerate transient rename race — retry once
82
+ try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); }
83
+ catch (_) { state = null; }
84
+ }
85
+ if (state) {
86
+ out('STATUS', state.status);
87
+ out('SESSION', state.sessionId);
88
+ out('MILESTONE', state.milestone);
89
+ out('WAVE', state.wave);
90
+ out('TASK', state.task);
91
+ out('ITER', state.iter);
92
+ out('MAX_ITER', state.maxIterations);
93
+ out('STARTED_AT', state.startedAt);
94
+ out('LAST_TICK', state.lastTick);
95
+ out('LAST_EXIT', state.lastExit);
96
+ out('LAST_ELAPSED_MS', state.lastElapsedMs);
97
+ out('LOG_PATH', state.logPath || LOG_FILE);
98
+ // elapsed
99
+ const started = state.startedAt ? Date.parse(state.startedAt) : null;
100
+ const elapsedMs = started ? (Date.now() - started) : null;
101
+ out('ELAPSED_MS', elapsedMs);
102
+ // last tick age
103
+ const lt = state.lastTick ? Date.parse(state.lastTick) : null;
104
+ const tickAgeMs = lt ? (Date.now() - lt) : null;
105
+ out('TICK_AGE_MS', tickAgeMs);
106
+ } else {
107
+ out('STATUS', null);
108
+ }
109
+
110
+ // --- run.log tail (last ~2KB via fd seek, not full read) ---
111
+ let tailLines = [];
112
+ try {
113
+ const st = fs.statSync(LOG_FILE);
114
+ const size = st.size;
115
+ const readLen = Math.min(size, 2048);
116
+ const offset = size - readLen;
117
+ const fd = fs.openSync(LOG_FILE, 'r');
118
+ const buf = Buffer.alloc(readLen);
119
+ fs.readSync(fd, buf, 0, readLen, offset);
120
+ fs.closeSync(fd);
121
+ const text = buf.toString('utf8');
122
+ tailLines = text.split('\n').filter(l => l.trim().length > 0);
123
+ } catch (_) { tailLines = []; }
124
+ // last 50 lines for crash path; last 1 non-empty for normal path
125
+ const last50 = tailLines.slice(-50);
126
+ const last1 = tailLines.length > 0 ? tailLines[tailLines.length - 1] : '';
127
+ out('LOG_TAIL_LAST1', last1);
128
+ out('LOG_TAIL_LAST50', last50.join('\n'));
129
+ "
130
+ ```
131
+
132
+ ## Step 3: Branch on PID File Absence (Finalized Cleanly)
133
+
134
+ If `PID_FILE_EXISTS=false`:
135
+
136
+ The supervisor has finalized cleanly and removed its own PID file. Read the final `state.json` status and iter, then print the terminal report:
137
+
138
+ ```
139
+ ✅ Unattended supervisor finalized cleanly.
140
+
141
+ Session: {SESSION}
142
+ Milestone: {MILESTONE}
143
+ Final status: {STATUS}
144
+ Iterations: {ITER} / {MAX_ITER}
145
+ Total elapsed: {Hh Mm — formatted from ELAPSED_MS}
146
+ Log: {LOG_PATH}
147
+ State: .gsd-t/.unattended/state.json
148
+ ```
149
+
150
+ If `state.json` was also unreadable (null STATUS), emit:
151
+
152
+ ```
153
+ ⚠️ Unattended supervisor PID file absent but state.json unreadable.
154
+ Manual inspection required: ls -la .gsd-t/.unattended/
155
+ ```
156
+
157
+ **STOP** — do NOT reschedule. End the turn.
158
+
159
+ ## Step 4: Branch on Dead PID (Crashed)
160
+
161
+ If `PID_FILE_EXISTS=true` AND `ALIVE=false`:
162
+
163
+ The supervisor process is gone but it did not remove its PID file — this is a crash. Print crash diagnostics including the last 50 lines of `run.log`:
164
+
165
+ ```
166
+ 💥 Unattended supervisor CRASHED (PID {PID} no longer alive).
167
+
168
+ Last known state:
169
+ Session: {SESSION}
170
+ Status: {STATUS}
171
+ Iter: {ITER} / {MAX_ITER}
172
+ Last tick: {LAST_TICK} (age {tickAgeMs → seconds})
173
+ Last exit: {LAST_EXIT}
174
+
175
+ Last 50 lines of run.log:
176
+ ──────────────────────────────────────────────
177
+ {LOG_TAIL_LAST50}
178
+ ──────────────────────────────────────────────
179
+
180
+ Recovery: inspect .gsd-t/.unattended/run.log and .gsd-t/.unattended/state.json.
181
+ Remove the stale PID file with: rm .gsd-t/.unattended/supervisor.pid
182
+ Then relaunch with: /user:gsd-t-unattended
183
+ ```
184
+
185
+ **STOP** — do NOT reschedule. End the turn.
186
+
187
+ ## Step 5: Branch on Terminal Status
188
+
189
+ If `PID_FILE_EXISTS=true` AND `ALIVE=true` AND `STATUS` is terminal (`done` | `failed` | `stopped`):
190
+
191
+ The supervisor is still alive but has transitioned to a terminal status on its last tick. The next supervisor pass will remove the PID file — we just caught it mid-cleanup. Print the matching report and STOP.
192
+
193
+ ### 5a: STATUS=done
194
+
195
+ ```
196
+ 🎉 Unattended supervisor COMPLETED the milestone.
197
+
198
+ Session: {SESSION}
199
+ Milestone: {MILESTONE} — DONE
200
+ Iterations: {ITER} / {MAX_ITER}
201
+ Total elapsed: {Hh Mm}
202
+ Log: {LOG_PATH}
203
+
204
+ The supervisor will clean up its PID file momentarily.
205
+ ```
206
+
207
+ ### 5b: STATUS=failed
208
+
209
+ ```
210
+ ❌ Unattended supervisor HALTED — status=failed.
211
+
212
+ Session: {SESSION}
213
+ Milestone: {MILESTONE}
214
+ Iterations: {ITER} / {MAX_ITER}
215
+ Last exit: {LAST_EXIT}
216
+ Total elapsed: {Hh Mm}
217
+ Log: {LOG_PATH}
218
+
219
+ See run.log for the halt reason. The supervisor finalized state.json before exiting.
220
+ ```
221
+
222
+ ### 5c: STATUS=stopped
223
+
224
+ ```
225
+ 🛑 Unattended supervisor STOPPED by user.
226
+
227
+ Session: {SESSION}
228
+ Milestone: {MILESTONE}
229
+ Iterations: {ITER} / {MAX_ITER}
230
+ Total elapsed: {Hh Mm}
231
+ Log: {LOG_PATH}
232
+
233
+ Stop sentinel was honored between worker iterations. Relaunch with /user:gsd-t-unattended.
234
+ ```
235
+
236
+ **STOP** — do NOT reschedule. End the turn.
237
+
238
+ ## Step 6: Render Live Watch Block (Non-Terminal)
239
+
240
+ If `PID_FILE_EXISTS=true` AND `ALIVE=true` AND `STATUS` is `initializing` or `running`:
241
+
242
+ Render the compact watch block below. Format rules:
243
+ - One extra space after each emoji (per CLAUDE.md markdown table rules — preserves alignment in terminal views).
244
+ - Elapsed formatted as `{H}h{M}m` from `ELAPSED_MS`.
245
+ - Last-tick age formatted as `{S}s` or `{M}m{S}s`. If age > 540s (2× tick cadence), append ` ⚠️ stale` as a soft warning (not a crash — per contract §3 write semantics).
246
+ - `LAST_EXIT` duration from `LAST_ELAPSED_MS` rendered as seconds.
247
+ - Wave/task lines are omitted when absent from state.
248
+ - Last non-empty `run.log` line is truncated to 80 chars.
249
+
250
+ ```
251
+ ⚙ Unattended — {MILESTONE}{ Wave {WAVE}}{ · Task {TASK}}
252
+ ⏱ Iter {ITER} / {MAX_ITER} · elapsed {Hh Mm} · last tick {tickAge}
253
+ 📊 Last exit: {LAST_EXIT} ({durationSec}s) · PID {PID} · session {SESSION}
254
+ 📝 {truncated last log line or "(no output yet)"}
255
+ ⏰ Next tick in 270s · Stop: /user:gsd-t-unattended-stop
256
+ ```
257
+
258
+ (5 lines — the contract §8 format allows up to 10. Keep it tight.)
259
+
260
+ ## Step 7: Reschedule via ScheduleWakeup (Non-Terminal Only)
261
+
262
+ **This step runs ONLY when Step 6 rendered a live watch block.** All terminal branches (Steps 3, 4, 5) must NOT reach this step — they STOP.
263
+
264
+ Call the harness `ScheduleWakeup` tool with these exact parameters:
265
+
266
+ - `delaySeconds`: `270` (fixed — inside the 5-minute prompt-cache TTL)
267
+ - `prompt`: `/user:gsd-t-unattended-watch`
268
+ - `reason`: `unattended tick {ITER}` — substituting the integer `ITER` from Step 2
269
+
270
+ Tool invocation pattern (make this real tool call, not a bash command):
271
+
272
+ ```
273
+ ScheduleWakeup(
274
+ delaySeconds: 270,
275
+ prompt: "/user:gsd-t-unattended-watch",
276
+ reason: "unattended tick {ITER}"
277
+ )
278
+ ```
279
+
280
+ After the tool call, end the turn. Do NOT output a "Next Up" hint, do NOT continue with further steps, do NOT summarize. The watch block from Step 6 plus the ScheduleWakeup tool call IS the entire turn output.
281
+
282
+ ## Notes
283
+
284
+ - **Stateless**: every tick re-reads state from disk. No memory between firings.
285
+ - **Terminal = STOP**: any terminal branch (3, 4, 5) ends the loop. The user relaunches via `/user:gsd-t-unattended` if needed.
286
+ - **Never spawn subagents**: this is a pure polling command — no Task, no TeamCreate, no observability logging block.
287
+ - **No branch guard, no pre-commit gate, no doc ripple**: this command does not modify any files.
288
+ - **Stale-tick tolerance**: if `lastTick` is >540s old but PID is still alive, warn soft (`⚠️ stale`) but still reschedule — the supervisor may be mid-worker.
289
+
290
+ $ARGUMENTS