@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.
package/bin/gsd-t.js CHANGED
@@ -2592,19 +2592,30 @@ function parseHeadlessFlags(args) {
2592
2592
 
2593
2593
  /**
2594
2594
  * Build the claude -p invocation string for a GSD-T command.
2595
+ *
2596
+ * Non-interactive `claude -p` mode requires the bare `/gsd-t-X` form — the
2597
+ * `/user:gsd-t-X` namespace prefix is rejected as "Unknown command" even
2598
+ * though interactive mode accepts both. Verified by M36 Phase 0 Spike A
2599
+ * (2026-04-15). See .gsd-t/M36-spike-findings.md.
2595
2600
  */
2596
2601
  function buildHeadlessCmd(command, cmdArgs) {
2597
2602
  const argStr = cmdArgs.length > 0 ? " " + cmdArgs.join(" ") : "";
2598
- return `/user:gsd-t-${command}${argStr}`;
2603
+ return `/gsd-t-${command}${argStr}`;
2599
2604
  }
2600
2605
 
2601
2606
  /**
2602
2607
  * Map claude output + process exit code to a GSD-T headless exit code.
2603
- * Exit codes: 0=success, 1=verify-fail, 2=context-budget-exceeded, 3=error, 4=blocked-needs-human
2608
+ * Exit codes: 0=success, 1=verify-fail, 2=context-budget-exceeded, 3=error,
2609
+ * 4=blocked-needs-human, 5=command-dispatch-failed
2604
2610
  */
2605
2611
  function mapHeadlessExitCode(processExitCode, output) {
2606
2612
  if (processExitCode !== 0 && processExitCode !== null) return 3;
2607
- const lower = (output || "").toLowerCase();
2613
+ const raw = output || "";
2614
+ const lower = raw.toLowerCase();
2615
+ // Command dispatch failure — `claude -p` prints "Unknown command: /X"
2616
+ // to stdout and still exits 0. Without this sentinel, a mistyped or
2617
+ // namespace-prefixed slash command silently reports success. (M36 Phase 0.)
2618
+ if (/^unknown command:/im.test(raw)) return 5;
2608
2619
  if (lower.includes("context budget exceeded") || lower.includes("context window exceeded") ||
2609
2620
  lower.includes("budget exceeded") || lower.includes("token limit")) return 2;
2610
2621
  if (lower.includes("blocked") && (lower.includes("needs human") || lower.includes("need human") ||
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Handoff Lock — Fail-safe sentinel preventing parent/child race
5
+ * conditions in `autoSpawnHeadless()`.
6
+ *
7
+ * Problem: when the interactive parent spawns a detached headless child to
8
+ * resume a session, both processes may briefly contend for the continue-here
9
+ * file and session JSON. If the child wakes and reads before the parent has
10
+ * finished writing, it sees stale or partial state.
11
+ *
12
+ * Solution: the parent acquires a short-lived lock on
13
+ * `.gsd-t/.handoff/lock-{sessionId}` BEFORE writing handoff artifacts and
14
+ * releases it AFTER `child.unref()` returns. The child waits on the same
15
+ * lock path before reading the continue-here file.
16
+ *
17
+ * Locks are TTL-bounded (default 30s) so a crashed parent can never wedge
18
+ * a future spawn — `cleanStaleLocks()` swept by housekeeping or by the next
19
+ * acquire attempt against an expired record reclaims the slot.
20
+ *
21
+ * Zero external dependencies (Node.js built-ins only).
22
+ *
23
+ * Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
24
+ * (implementation-detail primitive; no contract bump)
25
+ * Consumers: bin/headless-auto-spawn.js (Task 2 — wires this in),
26
+ * commands/gsd-t-resume.md (child-side wait).
27
+ */
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+
32
+ // ── Constants ────────────────────────────────────────────────────────────────
33
+
34
+ const HANDOFF_DIR_REL = path.join(".gsd-t", ".handoff");
35
+ const LOCK_FILE_PREFIX = "lock-";
36
+ const DEFAULT_TTL_MS = 30000;
37
+ const DEFAULT_WAIT_TIMEOUT_MS = 30000;
38
+ const DEFAULT_STALE_AGE_MS = 60000;
39
+ const POLL_INTERVAL_MS = 100;
40
+ const GRACE_MS = 500;
41
+
42
+ // ── Exports ──────────────────────────────────────────────────────────────────
43
+
44
+ module.exports = {
45
+ acquireHandoffLock,
46
+ releaseHandoffLock,
47
+ waitForLockRelease,
48
+ cleanStaleLocks,
49
+ // Exported for tests / consumer wiring:
50
+ lockPathFor,
51
+ HANDOFF_DIR_REL,
52
+ };
53
+
54
+ // ── acquireHandoffLock ───────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Acquire an exclusive handoff lock for the given sessionId.
58
+ *
59
+ * @param {string} projectDir
60
+ * @param {string} sessionId
61
+ * @param {{ ttlMs?: number }} [opts]
62
+ * @returns {{ lockPath: string, sessionId: string }} release handle
63
+ * @throws {Error} if an unexpired lock already exists
64
+ */
65
+ function acquireHandoffLock(projectDir, sessionId, opts) {
66
+ if (!projectDir || typeof projectDir !== "string") {
67
+ throw new Error("acquireHandoffLock: `projectDir` is required");
68
+ }
69
+ if (!sessionId || typeof sessionId !== "string") {
70
+ throw new Error("acquireHandoffLock: `sessionId` is required");
71
+ }
72
+ const ttlMs = (opts && opts.ttlMs) || DEFAULT_TTL_MS;
73
+
74
+ const dir = path.join(projectDir, HANDOFF_DIR_REL);
75
+ ensureDir(dir);
76
+
77
+ const lockPath = lockPathFor(projectDir, sessionId);
78
+
79
+ // If a lock file already exists, decide whether it is still binding.
80
+ if (fs.existsSync(lockPath)) {
81
+ const existing = readLockSafe(lockPath);
82
+ const now = Date.now();
83
+ if (existing && now < existing.releaseBy + GRACE_MS) {
84
+ throw new Error(
85
+ `handoff lock held by PID ${existing.parentPid} until ${new Date(
86
+ existing.releaseBy,
87
+ ).toISOString()}`,
88
+ );
89
+ }
90
+ // Expired or unparseable — reclaim it.
91
+ try {
92
+ fs.unlinkSync(lockPath);
93
+ } catch (_) {
94
+ /* race: someone else cleaned it; fall through */
95
+ }
96
+ }
97
+
98
+ const acquiredAt = Date.now();
99
+ const record = {
100
+ sessionId,
101
+ parentPid: process.pid,
102
+ acquiredAt,
103
+ releaseBy: acquiredAt + ttlMs,
104
+ };
105
+
106
+ // Write atomically: O_EXCL ensures we lose if a concurrent acquirer beats
107
+ // us between the existsSync check above and this write.
108
+ let fd;
109
+ try {
110
+ fd = fs.openSync(lockPath, "wx");
111
+ } catch (e) {
112
+ if (e && e.code === "EEXIST") {
113
+ // Another acquirer raced ahead; surface a uniform error.
114
+ const existing = readLockSafe(lockPath);
115
+ const pid = existing ? existing.parentPid : "unknown";
116
+ const until = existing
117
+ ? new Date(existing.releaseBy).toISOString()
118
+ : "unknown";
119
+ throw new Error(`handoff lock held by PID ${pid} until ${until}`);
120
+ }
121
+ throw e;
122
+ }
123
+ try {
124
+ fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
125
+ } finally {
126
+ fs.closeSync(fd);
127
+ }
128
+
129
+ return { lockPath, sessionId };
130
+ }
131
+
132
+ // ── releaseHandoffLock ───────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Release a previously acquired handoff lock. Idempotent — tolerates a
136
+ * missing file (already released or never existed).
137
+ *
138
+ * @param {{ lockPath: string, sessionId: string }} handle
139
+ */
140
+ function releaseHandoffLock(handle) {
141
+ if (!handle || !handle.lockPath) return;
142
+ try {
143
+ fs.unlinkSync(handle.lockPath);
144
+ } catch (e) {
145
+ if (e && e.code === "ENOENT") return; // already released
146
+ throw e;
147
+ }
148
+ }
149
+
150
+ // ── waitForLockRelease ───────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Poll until the lock file for {sessionId} no longer exists, OR until
154
+ * timeoutMs elapses. Throws on timeout.
155
+ *
156
+ * @param {string} projectDir
157
+ * @param {string} sessionId
158
+ * @param {number} [timeoutMs]
159
+ * @returns {Promise<true>}
160
+ */
161
+ function waitForLockRelease(projectDir, sessionId, timeoutMs) {
162
+ const limit = typeof timeoutMs === "number" ? timeoutMs : DEFAULT_WAIT_TIMEOUT_MS;
163
+ const lockPath = lockPathFor(projectDir, sessionId);
164
+ const start = Date.now();
165
+
166
+ return new Promise((resolve, reject) => {
167
+ const check = () => {
168
+ if (!fs.existsSync(lockPath)) {
169
+ resolve(true);
170
+ return;
171
+ }
172
+ if (Date.now() - start >= limit) {
173
+ reject(new Error(`waitForLockRelease timeout after ${limit}ms`));
174
+ return;
175
+ }
176
+ setTimeout(check, POLL_INTERVAL_MS);
177
+ };
178
+ check();
179
+ });
180
+ }
181
+
182
+ // ── cleanStaleLocks ──────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Sweep `.gsd-t/.handoff/` and remove any `lock-*` file whose `acquiredAt`
186
+ * is older than `maxAgeMs`. Returns the count of files removed.
187
+ *
188
+ * @param {string} projectDir
189
+ * @param {number} [maxAgeMs]
190
+ * @returns {number}
191
+ */
192
+ function cleanStaleLocks(projectDir, maxAgeMs) {
193
+ const limit = typeof maxAgeMs === "number" ? maxAgeMs : DEFAULT_STALE_AGE_MS;
194
+ const dir = path.join(projectDir, HANDOFF_DIR_REL);
195
+ if (!fs.existsSync(dir)) return 0;
196
+
197
+ let removed = 0;
198
+ let entries;
199
+ try {
200
+ entries = fs.readdirSync(dir);
201
+ } catch (_) {
202
+ return 0;
203
+ }
204
+ const now = Date.now();
205
+ for (const name of entries) {
206
+ if (!name.startsWith(LOCK_FILE_PREFIX)) continue;
207
+ const fp = path.join(dir, name);
208
+ const rec = readLockSafe(fp);
209
+ // Unparseable lock files are treated as stale — they're not protecting
210
+ // anything, and leaving them around defeats the cleaner.
211
+ if (!rec || now - rec.acquiredAt > limit) {
212
+ try {
213
+ fs.unlinkSync(fp);
214
+ removed++;
215
+ } catch (_) {
216
+ /* ignore */
217
+ }
218
+ }
219
+ }
220
+ return removed;
221
+ }
222
+
223
+ // ── Helpers ──────────────────────────────────────────────────────────────────
224
+
225
+ function lockPathFor(projectDir, sessionId) {
226
+ return path.join(projectDir, HANDOFF_DIR_REL, `${LOCK_FILE_PREFIX}${sessionId}`);
227
+ }
228
+
229
+ function readLockSafe(fp) {
230
+ try {
231
+ const raw = fs.readFileSync(fp, "utf8");
232
+ const j = JSON.parse(raw);
233
+ if (
234
+ typeof j === "object" &&
235
+ j &&
236
+ typeof j.acquiredAt === "number" &&
237
+ typeof j.releaseBy === "number"
238
+ ) {
239
+ return j;
240
+ }
241
+ return null;
242
+ } catch (_) {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function ensureDir(d) {
248
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
249
+ }
@@ -21,6 +21,14 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const { spawn } = require("child_process");
24
+ const {
25
+ acquireHandoffLock,
26
+ releaseHandoffLock,
27
+ // waitForLockRelease — NOT consumed here. The child-side wait is
28
+ // performed by `commands/gsd-t-resume.md` Step 0 (wired in m36
29
+ // watch-loop Task 4); it calls waitForLockRelease(projectDir,
30
+ // sessionId) before reading the continue-here file.
31
+ } = require("./handoff-lock.js");
24
32
 
25
33
  // ── Constants ────────────────────────────────────────────────────────────────
26
34
 
@@ -67,39 +75,69 @@ function autoSpawnHeadless(opts) {
67
75
  ensureDir(path.join(projectDir, LOG_DIR_REL));
68
76
  ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
69
77
 
70
- // Open log file descriptor before spawning child writes directly.
71
- const logFd = fs.openSync(logPath, "a");
72
-
73
- // Headless invocation: `node bin/gsd-t.js headless <command> [args] --log`
74
- // The `gsd-t` CLI entry point is bin/gsd-t.js relative to projectDir.
75
- const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
76
- const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
77
-
78
- const child = spawn("node", childArgs, {
79
- cwd: projectDir,
80
- detached: true,
81
- stdio: ["ignore", logFd, logFd],
82
- env: process.env,
83
- });
84
-
85
- child.unref();
86
- fs.closeSync(logFd);
87
-
88
- const pid = child.pid || 0;
89
-
90
- writeSessionFile(projectDir, {
91
- id,
92
- pid,
93
- logPath: path.relative(projectDir, logPath),
94
- startTimestamp: timestamp,
95
- command,
96
- args,
97
- status: "running",
98
- continueFromPath: continue_from,
99
- surfaced: false,
100
- });
101
-
102
- writeContinueHereFile(projectDir, id, context);
78
+ // Handoff-lock gate (m36 gap-fix T2). Only engaged when the caller
79
+ // supplies a `sessionId` — existing callers that do not pass one keep
80
+ // the pre-m36 behavior unchanged. When engaged, the lock is held
81
+ // across writeContinueHereFile + spawn so a child that is already
82
+ // waiting via `waitForLockRelease()` (see commands/gsd-t-resume.md
83
+ // Step 0, wired by m36 watch-loop T4) cannot read a half-written
84
+ // continue-here file. See .gsd-t/contracts/headless-auto-spawn-contract.md
85
+ // v1.0.0 (implementation-detail primitive; no contract bump).
86
+ const lockSessionId = typeof opts.sessionId === "string" && opts.sessionId
87
+ ? opts.sessionId
88
+ : null;
89
+ let lockHandle = null;
90
+ if (lockSessionId) {
91
+ lockHandle = acquireHandoffLock(projectDir, lockSessionId);
92
+ }
93
+
94
+ let pid = 0;
95
+ try {
96
+ // Open log file descriptor before spawning — child writes directly.
97
+ const logFd = fs.openSync(logPath, "a");
98
+
99
+ // Headless invocation: `node bin/gsd-t.js headless <command> [args] --log`
100
+ // The `gsd-t` CLI entry point is bin/gsd-t.js relative to projectDir.
101
+ const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
102
+ const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
103
+
104
+ const child = spawn("node", childArgs, {
105
+ cwd: projectDir,
106
+ detached: true,
107
+ stdio: ["ignore", logFd, logFd],
108
+ env: process.env,
109
+ });
110
+
111
+ child.unref();
112
+ fs.closeSync(logFd);
113
+
114
+ pid = child.pid || 0;
115
+
116
+ writeSessionFile(projectDir, {
117
+ id,
118
+ pid,
119
+ logPath: path.relative(projectDir, logPath),
120
+ startTimestamp: timestamp,
121
+ command,
122
+ args,
123
+ status: "running",
124
+ continueFromPath: continue_from,
125
+ surfaced: false,
126
+ });
127
+
128
+ writeContinueHereFile(projectDir, id, context);
129
+ } finally {
130
+ // Release the lock AFTER the child is confirmed started and
131
+ // handoff artifacts are on disk. The finally ensures a spawn
132
+ // failure still frees the slot for a retry.
133
+ if (lockHandle) {
134
+ try {
135
+ releaseHandoffLock(lockHandle);
136
+ } catch (_) {
137
+ /* idempotent best-effort */
138
+ }
139
+ }
140
+ }
103
141
 
104
142
  // T2 — install completion watcher. Non-blocking (setImmediate) so the
105
143
  // caller's return is not delayed. The watcher uses `child.on('exit')` on
@@ -46,6 +46,9 @@ MILESTONE WORKFLOW [auto] = in wave
46
46
  AUTOMATION Auto
47
47
  ───────────────────────────────────────────────────────────────────────────────
48
48
  wave Full cycle: partition → ... → complete (auto-advances)
49
+ unattended Run active milestone unattended — detached OS supervisor, multi-worker relay (24h+)
50
+ unattended-watch Show live supervisor status; reschedules every 270s via ScheduleWakeup
51
+ unattended-stop Request graceful supervisor stop (sentinel file — safe mid-worker)
49
52
 
50
53
  UTILITIES Manual
51
54
  ───────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,34 @@
2
2
 
3
3
  You are resuming work after an interruption. This handles both same-session pauses (user pressed Esc to interject) and cross-session recovery (new Claude Code session).
4
4
 
5
- ## Step 0: Detect Resume Mode
5
+ ## Step 0: Unattended Supervisor Auto-Reattach
6
+
7
+ **This step runs FIRST, before reading any docs, contracts, or continue-here files.**
8
+
9
+ Check whether an unattended supervisor is actively running for this project:
10
+
11
+ 1. Check if `.gsd-t/.unattended/supervisor.pid` exists.
12
+ - **Does not exist** → no supervisor running. Fall through to Step 0.1.
13
+
14
+ 2. **File exists**: Read the PID (single integer on one line). Run:
15
+ ```bash
16
+ kill -0 <pid> 2>/dev/null && echo "alive" || echo "dead"
17
+ ```
18
+ - **"dead"** → supervisor exited (cleanly or crashed). The PID file is stale. Log: `[resume] supervisor PID <pid> no longer alive — stale PID file, falling through to normal resume`. Fall through to Step 0.1.
19
+ - **"alive"** → supervisor process is live. Proceed to step 3.
20
+
21
+ 3. **Supervisor is alive**: Read `.gsd-t/.unattended/state.json`. Check `state.status`:
22
+ - **Terminal status** (`done`, `failed`, `stopped`, `crashed`) → the supervisor has finished and is waiting for cleanup. Fall through to Step 0.1 so normal resume flow runs (it will see progress.md state and continue from where the supervisor left off).
23
+ - **Non-terminal status** (`initializing`, `running`, or any unrecognized value) → **AUTO-REATTACH**:
24
+ - Print the current watch status using the data in `state.json` (elapsed time, current iteration, milestone/wave/task, last worker exit code).
25
+ - Call `ScheduleWakeup(270, '/user:gsd-t-unattended-watch', reason='resumed watch')`.
26
+ - **STOP reading resume.md entirely. Do NOT proceed to Step 0.1 or any later step. Do NOT read docs, contracts, or continue-here files. Do NOT display a headless read-back banner.** The watcher will display the live status block and re-schedule itself. Return now.
27
+
28
+ Contract reference: `unattended-supervisor-contract.md` §9 (Resume Auto-Reattach Handshake)
29
+
30
+ ---
31
+
32
+ ## Step 0.1: Detect Resume Mode
6
33
 
7
34
  **Same-session** (conversation context still available — you can see prior messages about the active phase/task):
8
35
  - Skip to Step 2 — you already have the context loaded
@@ -11,6 +38,28 @@ You are resuming work after an interruption. This handles both same-session paus
11
38
  **Cross-session** (first command in a new session, no prior conversation context):
12
39
  - Run Step 1 to load full state
13
40
 
41
+ ## Step 0.2: Handoff Lock Wait (headless resume only)
42
+
43
+ Before reading any continue-here file or state file, check if a parent process wrote a handoff lock for this session:
44
+
45
+ ```bash
46
+ node -e "
47
+ const sessionId = process.env.CLAUDE_HEADLESS_SESSION_ID;
48
+ if (!sessionId) { process.exit(0); }
49
+ const hl = require('./bin/handoff-lock.js');
50
+ hl.waitForLockRelease('.', sessionId, 5000)
51
+ .then(() => process.exit(0))
52
+ .catch(e => { console.error('[resume] handoff lock wait timed out:', e.message); process.exit(0); });
53
+ "
54
+ ```
55
+
56
+ - If `CLAUDE_HEADLESS_SESSION_ID` is not set (interactive resume) → the script exits immediately; no wait needed.
57
+ - If set → wait up to **5 seconds** for the parent's handoff lock to be released before reading `.gsd-t/continue-here-*.md` or any other state file. On timeout, log and proceed anyway (parent may have crashed after spawning).
58
+
59
+ This prevents the child side of a headless runway-handoff from reading a partial continue-here file written by the parent. Contract: `headless-auto-spawn-contract.md` v1.0.0, m35-gap-fixes T2 deferred hook.
60
+
61
+ ---
62
+
14
63
  ## Step 0.5: Headless Read-Back Banner (MANDATORY)
15
64
 
16
65
  Before loading full state, surface any completed headless sessions the user hasn't seen yet. Run this once at the start of every resume invocation:
@@ -0,0 +1,83 @@
1
+ # GSD-T: Unattended Stop — Signal Supervisor to Halt
2
+
3
+ **Model**: haiku (trivial sentinel toucher — no reasoning needed)
4
+
5
+ You are signaling the unattended supervisor to halt cleanly between worker iterations by writing the stop sentinel file at `.gsd-t/.unattended/stop`. This is a fire-and-forget command: no PID kill, no wait, no confirmation prompt.
6
+
7
+ See `unattended-supervisor-contract.md` §10 (Stop Mechanism) for the full handshake.
8
+
9
+ ## Step 1: Check Supervisor State Directory Exists
10
+
11
+ Run via Bash:
12
+
13
+ ```bash
14
+ if [ ! -d ".gsd-t/.unattended" ]; then
15
+ echo "No supervisor state directory — nothing to stop."
16
+ exit 0
17
+ fi
18
+ ```
19
+
20
+ If the directory does not exist, exit 0 (not an error — there is simply nothing to signal).
21
+
22
+ ## Step 2: Check Supervisor PID File Exists
23
+
24
+ Run via Bash:
25
+
26
+ ```bash
27
+ if [ ! -f ".gsd-t/.unattended/supervisor.pid" ]; then
28
+ echo "No supervisor running in this project (no PID file at .gsd-t/.unattended/supervisor.pid)."
29
+ exit 0
30
+ fi
31
+ ```
32
+
33
+ If the PID file is missing, the supervisor has already finalized cleanly. Exit 0 without writing the sentinel.
34
+
35
+ ## Step 3: Read Current Session Snapshot
36
+
37
+ Run via Bash to grab the session ID and current iteration from `state.json` (best-effort — tolerate missing or partial state):
38
+
39
+ ```bash
40
+ node -e "
41
+ try {
42
+ const s = JSON.parse(require('fs').readFileSync('.gsd-t/.unattended/state.json', 'utf8'));
43
+ console.log('SESSION=' + (s.sessionId || 'unknown'));
44
+ console.log('ITER=' + (s.iter ?? 'unknown'));
45
+ console.log('STATUS=' + (s.status || 'unknown'));
46
+ } catch (e) {
47
+ console.log('SESSION=unknown');
48
+ console.log('ITER=unknown');
49
+ console.log('STATUS=unknown');
50
+ }
51
+ "
52
+ ```
53
+
54
+ ## Step 4: Write the Stop Sentinel
55
+
56
+ Write `.gsd-t/.unattended/stop` containing the current ISO timestamp as the body (for diagnostics — the supervisor only checks for the file's existence, not its contents):
57
+
58
+ ```bash
59
+ node -e "require('fs').writeFileSync('.gsd-t/.unattended/stop', new Date().toISOString())"
60
+ ```
61
+
62
+ This is race-free, terminal-close-safe, and language-agnostic (per contract §10).
63
+
64
+ ## Step 5: Print Confirmation
65
+
66
+ Output the confirmation block:
67
+
68
+ ```
69
+ 🛑 Stop sentinel written. Supervisor will halt between next worker iterations (within ~5 minutes). State file will finalize with status=stopped.
70
+
71
+ Session: {SESSION from Step 3}
72
+ Iter: {ITER from Step 3}
73
+ Status: {STATUS from Step 3}
74
+
75
+ The current worker will run to completion (up to ~1h). Stop is honored at the next pre-worker checkpoint.
76
+ No kill signal is sent — this is a clean cooperative halt.
77
+ ```
78
+
79
+ ## Step 6: Return Immediately
80
+
81
+ Do NOT wait for the supervisor to acknowledge. Do NOT poll state.json. Do NOT call ScheduleWakeup. The supervisor will finalize state.json with `status=stopped` on its next iteration check — that's the watch loop's job to observe, not this command's.
82
+
83
+ $ARGUMENTS