@tekyzinc/gsd-t 2.74.13 → 3.10.10
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 +165 -0
- package/README.md +117 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -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 +723 -19
- package/bin/handoff-lock.js +249 -0
- package/bin/headless-auto-spawn.js +328 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +22 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +86 -1
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-unattended-stop.md +83 -0
- package/commands/gsd-t-unattended-watch.md +290 -0
- package/commands/gsd-t-unattended.md +414 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +69 -0
- package/docs/architecture.md +176 -4
- package/docs/infrastructure.md +221 -0
- package/docs/methodology.md +44 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +95 -0
- package/docs/unattended-windows-caveats.md +245 -0
- package/package.json +2 -2
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +17 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- package/bin/task-counter.cjs +0 -161
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GSD-T Headless Auto-Spawn — Detached headless continuation
|
|
5
|
+
*
|
|
6
|
+
* When the runway estimator refuses a run (projected context ≥ stop band),
|
|
7
|
+
* the caller invokes `autoSpawnHeadless()` to hand off to a detached child
|
|
8
|
+
* process running `gsd-t headless {command} --log`. The interactive session
|
|
9
|
+
* never blocks on the child (`child.unref()`), so the user retains their
|
|
10
|
+
* terminal and can work on unrelated tasks. On child completion, a macOS
|
|
11
|
+
* notification fires (T2). The interactive session surfaces the result via
|
|
12
|
+
* a read-back banner on the next `gsd-t-resume` or `gsd-t-status` call (T4).
|
|
13
|
+
*
|
|
14
|
+
* Zero external dependencies (Node.js built-ins only).
|
|
15
|
+
*
|
|
16
|
+
* Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
|
|
17
|
+
* Consumers: commands/gsd-t-execute|wave|integrate|quick|debug.md (via runway
|
|
18
|
+
* estimator handoff), bin/runway-estimator.js (conceptual target).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
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");
|
|
32
|
+
|
|
33
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const SESSIONS_DIR_REL = path.join(".gsd-t", "headless-sessions");
|
|
36
|
+
const LOG_DIR_REL = ".gsd-t"; // headless-{id}.log lives directly in .gsd-t
|
|
37
|
+
|
|
38
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
autoSpawnHeadless,
|
|
42
|
+
makeSessionId,
|
|
43
|
+
writeSessionFile,
|
|
44
|
+
writeContinueHereFile,
|
|
45
|
+
markSessionCompleted,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── autoSpawnHeadless ────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* command: string,
|
|
53
|
+
* args?: string[],
|
|
54
|
+
* continue_from?: string,
|
|
55
|
+
* projectDir?: string,
|
|
56
|
+
* context?: object
|
|
57
|
+
* }} opts
|
|
58
|
+
* @returns {{ id: string, pid: number, logPath: string, timestamp: string }}
|
|
59
|
+
*/
|
|
60
|
+
function autoSpawnHeadless(opts) {
|
|
61
|
+
const command = opts.command;
|
|
62
|
+
const args = opts.args || [];
|
|
63
|
+
const continue_from = opts.continue_from || ".";
|
|
64
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
65
|
+
const context = opts.context || null;
|
|
66
|
+
|
|
67
|
+
if (!command || typeof command !== "string") {
|
|
68
|
+
throw new Error("autoSpawnHeadless: `command` is required");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const timestamp = new Date().toISOString();
|
|
72
|
+
const id = makeSessionId(command, new Date());
|
|
73
|
+
const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
74
|
+
|
|
75
|
+
ensureDir(path.join(projectDir, LOG_DIR_REL));
|
|
76
|
+
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
77
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
// T2 — install completion watcher. Non-blocking (setImmediate) so the
|
|
143
|
+
// caller's return is not delayed. The watcher uses `child.on('exit')` on
|
|
144
|
+
// a separately-spawned bridge process; here we defer to fs.watchFile for
|
|
145
|
+
// a detached approach that survives even after the parent's `unref()`.
|
|
146
|
+
installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
|
|
147
|
+
|
|
148
|
+
return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── makeSessionId ────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} command
|
|
155
|
+
* @param {Date} [now]
|
|
156
|
+
* @returns {string} e.g., "gsd-t-execute-2026-04-15-01-23-45"
|
|
157
|
+
*/
|
|
158
|
+
function makeSessionId(command, now) {
|
|
159
|
+
const d = now || new Date();
|
|
160
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
161
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
162
|
+
const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
163
|
+
const base = stripGsdtPrefix(command) || command;
|
|
164
|
+
return `gsd-t-${base}-${date}-${time}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── writeSessionFile ─────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function writeSessionFile(projectDir, session) {
|
|
170
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${session.id}.json`);
|
|
171
|
+
ensureDir(path.dirname(fp));
|
|
172
|
+
fs.writeFileSync(fp, JSON.stringify(session, null, 2) + "\n");
|
|
173
|
+
return fp;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── writeContinueHereFile ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function writeContinueHereFile(projectDir, id, context) {
|
|
179
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}-context.json`);
|
|
180
|
+
const payload = context || buildContextSnapshot(projectDir);
|
|
181
|
+
fs.writeFileSync(fp, JSON.stringify(payload, null, 2) + "\n");
|
|
182
|
+
return fp;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildContextSnapshot(projectDir) {
|
|
186
|
+
// Best-effort snapshot of current GSD-T state at handoff time.
|
|
187
|
+
const snap = {
|
|
188
|
+
capturedAt: new Date().toISOString(),
|
|
189
|
+
progress: null,
|
|
190
|
+
currentDomain: null,
|
|
191
|
+
pendingTasks: [],
|
|
192
|
+
lastDecisionLogEntry: null,
|
|
193
|
+
currentWave: null,
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
const progressFp = path.join(projectDir, ".gsd-t", "progress.md");
|
|
197
|
+
if (fs.existsSync(progressFp)) {
|
|
198
|
+
const raw = fs.readFileSync(progressFp, "utf8");
|
|
199
|
+
snap.progress = firstNLines(raw, 20);
|
|
200
|
+
// Pull the last Decision Log entry (last non-empty bullet line).
|
|
201
|
+
const lines = raw.split("\n");
|
|
202
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
203
|
+
const ln = lines[i].trim();
|
|
204
|
+
if (ln.startsWith("- ")) {
|
|
205
|
+
snap.lastDecisionLogEntry = ln;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (_) {
|
|
211
|
+
/* best-effort */
|
|
212
|
+
}
|
|
213
|
+
return snap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function firstNLines(s, n) {
|
|
217
|
+
return s.split("\n").slice(0, n).join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── markSessionCompleted ─────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* T2 completion hook — updates the session file in place.
|
|
224
|
+
* @param {string} projectDir
|
|
225
|
+
* @param {string} id
|
|
226
|
+
* @param {{ exitCode: number, endTimestamp?: string }} result
|
|
227
|
+
*/
|
|
228
|
+
function markSessionCompleted(projectDir, id, result) {
|
|
229
|
+
const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}.json`);
|
|
230
|
+
if (!fs.existsSync(fp)) return;
|
|
231
|
+
try {
|
|
232
|
+
const s = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
233
|
+
s.status = "completed";
|
|
234
|
+
s.exitCode = result.exitCode;
|
|
235
|
+
s.endTimestamp = result.endTimestamp || new Date().toISOString();
|
|
236
|
+
fs.writeFileSync(fp, JSON.stringify(s, null, 2) + "\n");
|
|
237
|
+
} catch (_) {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Completion watcher (T2) — macOS notification on exit ─────────────────────
|
|
243
|
+
|
|
244
|
+
function installCompletionWatcher(opts) {
|
|
245
|
+
const { projectDir, id, pid, startTimestamp } = opts;
|
|
246
|
+
if (!pid || pid <= 0) return;
|
|
247
|
+
|
|
248
|
+
// Poll-based watcher. We can't hold a reference to the child (it's unref'd
|
|
249
|
+
// and detached), so we poll `process.kill(pid, 0)` which throws if the
|
|
250
|
+
// process is gone. This is cheap and survives across detachment.
|
|
251
|
+
const POLL_MS = 2000;
|
|
252
|
+
const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
|
|
253
|
+
const startMs = Date.now();
|
|
254
|
+
|
|
255
|
+
const timer = setInterval(() => {
|
|
256
|
+
let alive = false;
|
|
257
|
+
try {
|
|
258
|
+
process.kill(pid, 0);
|
|
259
|
+
alive = true;
|
|
260
|
+
} catch (_) {
|
|
261
|
+
alive = false;
|
|
262
|
+
}
|
|
263
|
+
if (!alive) {
|
|
264
|
+
clearInterval(timer);
|
|
265
|
+
// Exit code is unknown from a signal-based probe. Best-effort: read
|
|
266
|
+
// the log's last lines to guess, otherwise default to 0.
|
|
267
|
+
const exitCode = guessExitCodeFromLog(projectDir, id);
|
|
268
|
+
markSessionCompleted(projectDir, id, {
|
|
269
|
+
exitCode,
|
|
270
|
+
endTimestamp: new Date().toISOString(),
|
|
271
|
+
});
|
|
272
|
+
fireMacNotification({ id, command: extractCommand(id), startTimestamp });
|
|
273
|
+
} else if (Date.now() - startMs > MAX_WAIT_MS) {
|
|
274
|
+
clearInterval(timer);
|
|
275
|
+
}
|
|
276
|
+
}, POLL_MS);
|
|
277
|
+
|
|
278
|
+
// Let the timer not block the parent's exit.
|
|
279
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function guessExitCodeFromLog(projectDir, id) {
|
|
283
|
+
try {
|
|
284
|
+
const fp = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
285
|
+
if (!fs.existsSync(fp)) return 0;
|
|
286
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
287
|
+
if (/exit code[: ]+(\d+)/i.test(raw)) {
|
|
288
|
+
const m = raw.match(/exit code[: ]+(\d+)/i);
|
|
289
|
+
return parseInt(m[1], 10);
|
|
290
|
+
}
|
|
291
|
+
return 0;
|
|
292
|
+
} catch (_) {
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractCommand(id) {
|
|
298
|
+
// id format: gsd-t-{command}-{date}-{time}
|
|
299
|
+
const m = id.match(/^gsd-t-(.+?)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/);
|
|
300
|
+
return m ? m[1] : id;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fireMacNotification({ id, command }) {
|
|
304
|
+
if (process.platform !== "darwin") return;
|
|
305
|
+
try {
|
|
306
|
+
const { spawn } = require("child_process");
|
|
307
|
+
const msg = `GSD-T headless run complete: ${id}`;
|
|
308
|
+
const script = `display notification "${msg}" with title "GSD-T" subtitle "${command}"`;
|
|
309
|
+
const child = spawn("osascript", ["-e", script], {
|
|
310
|
+
detached: true,
|
|
311
|
+
stdio: "ignore",
|
|
312
|
+
});
|
|
313
|
+
child.unref();
|
|
314
|
+
} catch (_) {
|
|
315
|
+
/* graceful degradation */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
function ensureDir(d) {
|
|
322
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stripGsdtPrefix(command) {
|
|
326
|
+
if (typeof command !== "string") return "";
|
|
327
|
+
return command.replace(/^gsd-t-/, "");
|
|
328
|
+
}
|