@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/CHANGELOG.md +63 -0
- package/README.md +46 -0
- package/bin/gsd-t-unattended-platform.js +381 -0
- package/bin/gsd-t-unattended-safety.js +766 -0
- package/bin/gsd-t-unattended.js +1259 -0
- package/bin/gsd-t.js +14 -3
- package/bin/handoff-lock.js +249 -0
- package/bin/headless-auto-spawn.js +71 -33
- package/commands/gsd-t-help.md +3 -0
- package/commands/gsd-t-resume.md +50 -1
- package/commands/gsd-t-unattended-stop.md +83 -0
- package/commands/gsd-t-unattended-watch.md +290 -0
- package/commands/gsd-t-unattended.md +420 -0
- package/commands/gsd-t-wave.md +1 -1
- package/docs/GSD-T-README.md +17 -0
- package/docs/architecture.md +81 -4
- package/docs/infrastructure.md +104 -0
- package/docs/methodology.md +8 -0
- package/docs/requirements.md +29 -0
- package/docs/unattended-config.md +138 -0
- package/docs/unattended-windows-caveats.md +245 -0
- package/package.json +2 -2
- package/scripts/gsd-t-context-meter.e2e.test.js +1 -1
- package/templates/CLAUDE-global.md +12 -0
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 `/
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
package/commands/gsd-t-help.md
CHANGED
|
@@ -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
|
───────────────────────────────────────────────────────────────────────────────
|
package/commands/gsd-t-resume.md
CHANGED
|
@@ -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:
|
|
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
|