@tekyzinc/gsd-t 2.76.10 → 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 +49 -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 +414 -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-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
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gsd-t-unattended-safety.js
|
|
3
|
+
*
|
|
4
|
+
* Pure-function safety rails for the unattended supervisor.
|
|
5
|
+
*
|
|
6
|
+
* Contract: .gsd-t/contracts/unattended-supervisor-contract.md v1.0.0
|
|
7
|
+
* §5 — Exit code table (2 = preflight-failure, 7 = protected-branch-refusal,
|
|
8
|
+
* 8 = dirty-tree-refusal)
|
|
9
|
+
* §12 — Safety Rails Hook Points
|
|
10
|
+
* §13 — Configuration File schema (DEFAULTS authoritative source)
|
|
11
|
+
*
|
|
12
|
+
* This module exports synchronous, side-effect-light check functions called
|
|
13
|
+
* by the supervisor between worker spawns. Each check returns
|
|
14
|
+
* { ok: true } on allow
|
|
15
|
+
* { ok: false, reason: string, code: number } on refuse
|
|
16
|
+
*
|
|
17
|
+
* The only permitted side effects are:
|
|
18
|
+
* - reading git state via `git branch --show-current` and `git status --porcelain`
|
|
19
|
+
* - reading the optional config file at `.gsd-t/.unattended/config.json`
|
|
20
|
+
*
|
|
21
|
+
* Zero external dependencies — Node built-ins only.
|
|
22
|
+
*
|
|
23
|
+
* Owner: m36-safety-rails
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require("node:fs");
|
|
27
|
+
const path = require("node:path");
|
|
28
|
+
const { spawnSync } = require("node:child_process");
|
|
29
|
+
|
|
30
|
+
// ── DEFAULTS ────────────────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Source of truth: unattended-supervisor-contract.md §13.
|
|
33
|
+
// Any drift here from the contract is a contract violation.
|
|
34
|
+
|
|
35
|
+
const DEFAULTS = Object.freeze({
|
|
36
|
+
protectedBranches: [
|
|
37
|
+
"main",
|
|
38
|
+
"master",
|
|
39
|
+
"develop",
|
|
40
|
+
"trunk",
|
|
41
|
+
"release/*",
|
|
42
|
+
"hotfix/*",
|
|
43
|
+
],
|
|
44
|
+
dirtyTreeWhitelist: [
|
|
45
|
+
".gsd-t/heartbeat-*.jsonl",
|
|
46
|
+
".gsd-t/.context-meter-state.json",
|
|
47
|
+
".gsd-t/events/*.jsonl",
|
|
48
|
+
".gsd-t/token-metrics.jsonl",
|
|
49
|
+
".gsd-t/token-log.md",
|
|
50
|
+
".gsd-t/.unattended/*",
|
|
51
|
+
".gsd-t/.handoff/*",
|
|
52
|
+
".claude/settings.local.json",
|
|
53
|
+
".claude/settings.local.json.bak*",
|
|
54
|
+
],
|
|
55
|
+
maxIterations: 200,
|
|
56
|
+
hours: 24,
|
|
57
|
+
gutterNoProgressIters: 5,
|
|
58
|
+
workerTimeoutMs: 3600000,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── Glob → regex helper ─────────────────────────────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// Minimal glob matcher: `*` matches any run of characters except `/`,
|
|
64
|
+
// `**` matches across path separators, everything else is a literal.
|
|
65
|
+
// No external dependency. Sufficient for the whitelist patterns in §13.
|
|
66
|
+
|
|
67
|
+
function globToRegex(glob) {
|
|
68
|
+
let re = "^";
|
|
69
|
+
for (let i = 0; i < glob.length; i++) {
|
|
70
|
+
const c = glob[i];
|
|
71
|
+
if (c === "*") {
|
|
72
|
+
if (glob[i + 1] === "*") {
|
|
73
|
+
re += ".*";
|
|
74
|
+
i++;
|
|
75
|
+
} else {
|
|
76
|
+
re += "[^/]*";
|
|
77
|
+
}
|
|
78
|
+
} else if (c === "?") {
|
|
79
|
+
re += "[^/]";
|
|
80
|
+
} else if ("\\^$+.()|{}[]".includes(c)) {
|
|
81
|
+
re += "\\" + c;
|
|
82
|
+
} else {
|
|
83
|
+
re += c;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
re += "$";
|
|
87
|
+
return new RegExp(re);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function matchesAnyGlob(value, patterns) {
|
|
91
|
+
for (const pattern of patterns) {
|
|
92
|
+
if (globToRegex(pattern).test(value)) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── loadConfig ──────────────────────────────────────────────────────────────
|
|
98
|
+
//
|
|
99
|
+
// Reads `.gsd-t/.unattended/config.json` if present, merges field-by-field
|
|
100
|
+
// over DEFAULTS, returns a plain object. Missing file → return a deep copy
|
|
101
|
+
// of DEFAULTS unchanged. Malformed JSON → throws a clear Error.
|
|
102
|
+
|
|
103
|
+
function cloneDefaults() {
|
|
104
|
+
// Defensive deep copy so callers can mutate the result without poisoning
|
|
105
|
+
// the frozen DEFAULTS singleton.
|
|
106
|
+
return {
|
|
107
|
+
protectedBranches: DEFAULTS.protectedBranches.slice(),
|
|
108
|
+
dirtyTreeWhitelist: DEFAULTS.dirtyTreeWhitelist.slice(),
|
|
109
|
+
maxIterations: DEFAULTS.maxIterations,
|
|
110
|
+
hours: DEFAULTS.hours,
|
|
111
|
+
gutterNoProgressIters: DEFAULTS.gutterNoProgressIters,
|
|
112
|
+
workerTimeoutMs: DEFAULTS.workerTimeoutMs,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function loadConfig(projectDir) {
|
|
117
|
+
const merged = cloneDefaults();
|
|
118
|
+
const configPath = path.join(
|
|
119
|
+
projectDir,
|
|
120
|
+
".gsd-t",
|
|
121
|
+
".unattended",
|
|
122
|
+
"config.json",
|
|
123
|
+
);
|
|
124
|
+
if (!fs.existsSync(configPath)) return merged;
|
|
125
|
+
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`safety-rails: failed to read config at ${configPath}: ${err.message}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(raw);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`safety-rails: malformed JSON in ${configPath}: ${err.message}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (parsed && typeof parsed === "object") {
|
|
145
|
+
for (const key of Object.keys(merged)) {
|
|
146
|
+
if (parsed[key] !== undefined) {
|
|
147
|
+
merged[key] = parsed[key];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── checkGitBranch ──────────────────────────────────────────────────────────
|
|
155
|
+
//
|
|
156
|
+
// Runs `git branch --show-current` in projectDir. An empty result indicates
|
|
157
|
+
// detached HEAD, which is treated as a protected-branch refusal (you cannot
|
|
158
|
+
// safely run unattended on a detached HEAD — there's no branch to push to).
|
|
159
|
+
// Otherwise, the current branch is matched against the protectedBranches
|
|
160
|
+
// list using glob semantics. Match → refuse with code 7.
|
|
161
|
+
|
|
162
|
+
function checkGitBranch(projectDir, config) {
|
|
163
|
+
const cfg = config || loadConfig(projectDir);
|
|
164
|
+
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
165
|
+
cwd: projectDir,
|
|
166
|
+
encoding: "utf8",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (result.error) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
reason: `git branch --show-current failed: ${result.error.message}`,
|
|
173
|
+
code: 2,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (result.status !== 0) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason:
|
|
180
|
+
`git branch --show-current exited ${result.status}: ` +
|
|
181
|
+
(result.stderr || "").trim(),
|
|
182
|
+
code: 2,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const branch = (result.stdout || "").trim();
|
|
187
|
+
if (!branch) {
|
|
188
|
+
// Detached HEAD: refuse as protected.
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: "detached HEAD: refusing to run unattended without a branch",
|
|
192
|
+
code: 7,
|
|
193
|
+
branch: "",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (matchesAnyGlob(branch, cfg.protectedBranches)) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: `branch '${branch}' is protected (matches one of: ${cfg.protectedBranches.join(", ")})`,
|
|
201
|
+
code: 7,
|
|
202
|
+
branch,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { ok: true, branch };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── checkWorktreeCleanliness ────────────────────────────────────────────────
|
|
210
|
+
//
|
|
211
|
+
// Runs `git status --porcelain`, parses each line, filters whitelisted files
|
|
212
|
+
// per dirtyTreeWhitelist (glob-aware). If any non-whitelisted dirty file
|
|
213
|
+
// remains, refuse with code 8 and report the offenders.
|
|
214
|
+
//
|
|
215
|
+
// Fail-closed: any git failure → code 2 preflight-failure.
|
|
216
|
+
|
|
217
|
+
function parsePorcelainLine(line) {
|
|
218
|
+
// Porcelain v1 line shape: "XY path" (XY are 2 status chars, then a space).
|
|
219
|
+
// Renames look like "R old -> new" — return the destination path.
|
|
220
|
+
if (line.length < 4) return null;
|
|
221
|
+
let payload = line.slice(3);
|
|
222
|
+
const arrow = payload.indexOf(" -> ");
|
|
223
|
+
if (arrow !== -1) payload = payload.slice(arrow + 4);
|
|
224
|
+
// Strip optional surrounding quotes that git uses for special chars.
|
|
225
|
+
if (payload.startsWith('"') && payload.endsWith('"')) {
|
|
226
|
+
payload = payload.slice(1, -1);
|
|
227
|
+
}
|
|
228
|
+
return payload;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function checkWorktreeCleanliness(projectDir, config) {
|
|
232
|
+
const cfg = config || loadConfig(projectDir);
|
|
233
|
+
// `--untracked-files=all` expands untracked directories to individual file
|
|
234
|
+
// paths. Without this, git would summarize new dirs as ".gsd-t/" and we'd
|
|
235
|
+
// refuse a tree even when every file inside is whitelisted.
|
|
236
|
+
const result = spawnSync(
|
|
237
|
+
"git",
|
|
238
|
+
["status", "--porcelain", "--untracked-files=all"],
|
|
239
|
+
{ cwd: projectDir, encoding: "utf8" },
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (result.error) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: `git status --porcelain failed: ${result.error.message}`,
|
|
246
|
+
code: 2,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
reason:
|
|
253
|
+
`git status --porcelain exited ${result.status}: ` +
|
|
254
|
+
(result.stderr || "").trim(),
|
|
255
|
+
code: 2,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const stdout = (result.stdout || "").replace(/\r\n/g, "\n");
|
|
260
|
+
const lines = stdout.split("\n").filter((l) => l.length > 0);
|
|
261
|
+
|
|
262
|
+
const dirtyFiles = [];
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
const file = parsePorcelainLine(line);
|
|
265
|
+
if (!file) continue;
|
|
266
|
+
if (!matchesAnyGlob(file, cfg.dirtyTreeWhitelist)) {
|
|
267
|
+
dirtyFiles.push(file);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (dirtyFiles.length > 0) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
reason:
|
|
275
|
+
`worktree has ${dirtyFiles.length} non-whitelisted dirty file(s): ` +
|
|
276
|
+
dirtyFiles.slice(0, 5).join(", ") +
|
|
277
|
+
(dirtyFiles.length > 5 ? ", …" : ""),
|
|
278
|
+
code: 8,
|
|
279
|
+
dirtyFiles,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { ok: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── checkIterationCap ───────────────────────────────────────────────────────
|
|
287
|
+
//
|
|
288
|
+
// Pre-worker hook: refuses to spawn another worker if the iteration count has
|
|
289
|
+
// reached the configured cap. Contract §12 lists this under the pre-worker
|
|
290
|
+
// hook. The cap is resolved with the precedence:
|
|
291
|
+
// 1. config.maxIterations (explicit override)
|
|
292
|
+
// 2. DEFAULTS.maxIterations (hardcoded contract default)
|
|
293
|
+
// 3. state.maxIterations (fallback — what the supervisor was launched with)
|
|
294
|
+
//
|
|
295
|
+
// Iteration cap is a soft gutter (not a crash), so it surfaces as code 6
|
|
296
|
+
// per contract §5.
|
|
297
|
+
|
|
298
|
+
function checkIterationCap(state, config) {
|
|
299
|
+
const cap =
|
|
300
|
+
(config && typeof config.maxIterations === "number"
|
|
301
|
+
? config.maxIterations
|
|
302
|
+
: undefined) ??
|
|
303
|
+
DEFAULTS.maxIterations ??
|
|
304
|
+
(state && state.maxIterations);
|
|
305
|
+
|
|
306
|
+
if (typeof cap !== "number" || !Number.isFinite(cap)) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: "iteration cap is not a finite number",
|
|
310
|
+
code: 2,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const iter = state && typeof state.iter === "number" ? state.iter : 0;
|
|
315
|
+
if (iter < cap) {
|
|
316
|
+
return { ok: true };
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
reason: `iteration cap exceeded: iter=${iter} >= maxIterations=${cap}`,
|
|
321
|
+
code: 6,
|
|
322
|
+
iter,
|
|
323
|
+
maxIterations: cap,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── checkWallClockCap ───────────────────────────────────────────────────────
|
|
328
|
+
//
|
|
329
|
+
// Pre-worker hook: refuses to spawn another worker if the total elapsed wall
|
|
330
|
+
// clock time has reached the configured hours cap. The cap is resolved with
|
|
331
|
+
// the precedence:
|
|
332
|
+
// 1. config.hours (explicit override)
|
|
333
|
+
// 2. DEFAULTS.hours (hardcoded contract default — 24)
|
|
334
|
+
//
|
|
335
|
+
// wallClockElapsedMs is an integer on state.json (§3). The cap is converted
|
|
336
|
+
// to milliseconds for comparison. Wall-clock cap is a soft gutter → code 6.
|
|
337
|
+
|
|
338
|
+
function checkWallClockCap(state, config) {
|
|
339
|
+
const hours =
|
|
340
|
+
config && typeof config.hours === "number"
|
|
341
|
+
? config.hours
|
|
342
|
+
: DEFAULTS.hours;
|
|
343
|
+
|
|
344
|
+
if (typeof hours !== "number" || !Number.isFinite(hours) || hours <= 0) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
reason: "wall-clock cap (hours) is not a positive finite number",
|
|
348
|
+
code: 2,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const capMs = hours * 3600 * 1000;
|
|
353
|
+
const elapsedMs =
|
|
354
|
+
state && typeof state.wallClockElapsedMs === "number"
|
|
355
|
+
? state.wallClockElapsedMs
|
|
356
|
+
: 0;
|
|
357
|
+
|
|
358
|
+
if (elapsedMs < capMs) {
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
reason:
|
|
364
|
+
`wall-clock cap exceeded: elapsedMs=${elapsedMs} >= capMs=${capMs} ` +
|
|
365
|
+
`(hours=${hours})`,
|
|
366
|
+
code: 6,
|
|
367
|
+
elapsedMs,
|
|
368
|
+
capMs,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── validateState ───────────────────────────────────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// Pure schema validator for state.json. Checks every REQUIRED field from
|
|
375
|
+
// contract §3, verifies types, and validates the status enum from §4.
|
|
376
|
+
//
|
|
377
|
+
// Aggregates errors (does NOT fail-fast) so the caller can surface every
|
|
378
|
+
// problem in a single preflight refusal. Returns code 2 (preflight-failure)
|
|
379
|
+
// per contract §5 on any failure.
|
|
380
|
+
|
|
381
|
+
const STATUS_ENUM = Object.freeze([
|
|
382
|
+
"initializing",
|
|
383
|
+
"running",
|
|
384
|
+
"done",
|
|
385
|
+
"failed",
|
|
386
|
+
"stopped",
|
|
387
|
+
"crashed",
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
const PLATFORM_ENUM = Object.freeze(["darwin", "linux", "win32"]);
|
|
391
|
+
|
|
392
|
+
// Required fields per contract §3, with an expected-type tag. We check the
|
|
393
|
+
// supervisor-critical fields listed in the Task 2 acceptance criteria.
|
|
394
|
+
const REQUIRED_STATE_FIELDS = Object.freeze([
|
|
395
|
+
{ name: "version", type: "string" },
|
|
396
|
+
{ name: "sessionId", type: "string" },
|
|
397
|
+
{ name: "projectDir", type: "string" },
|
|
398
|
+
{ name: "status", type: "string" }, // enum-validated below
|
|
399
|
+
{ name: "milestone", type: "string" },
|
|
400
|
+
{ name: "iter", type: "integer" },
|
|
401
|
+
{ name: "maxIterations", type: "integer" },
|
|
402
|
+
{ name: "startedAt", type: "string" },
|
|
403
|
+
{ name: "lastTick", type: "string" },
|
|
404
|
+
{ name: "hours", type: "number" },
|
|
405
|
+
{ name: "wallClockElapsedMs", type: "integer" },
|
|
406
|
+
{ name: "supervisorPid", type: "integer" },
|
|
407
|
+
{ name: "logPath", type: "string" },
|
|
408
|
+
{ name: "platform", type: "string" }, // enum-validated below
|
|
409
|
+
{ name: "claudeBin", type: "string" },
|
|
410
|
+
]);
|
|
411
|
+
|
|
412
|
+
function typeMatches(value, expected) {
|
|
413
|
+
if (value === undefined || value === null) return false;
|
|
414
|
+
switch (expected) {
|
|
415
|
+
case "string":
|
|
416
|
+
return typeof value === "string";
|
|
417
|
+
case "number":
|
|
418
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
419
|
+
case "integer":
|
|
420
|
+
return (
|
|
421
|
+
typeof value === "number" &&
|
|
422
|
+
Number.isFinite(value) &&
|
|
423
|
+
Number.isInteger(value)
|
|
424
|
+
);
|
|
425
|
+
default:
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function validateState(state) {
|
|
431
|
+
const errors = [];
|
|
432
|
+
|
|
433
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
code: 2,
|
|
437
|
+
reason: "state-validation-failed",
|
|
438
|
+
errors: ["state must be a non-null object"],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const field of REQUIRED_STATE_FIELDS) {
|
|
443
|
+
if (!(field.name in state)) {
|
|
444
|
+
errors.push(`${field.name}: missing required field`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const value = state[field.name];
|
|
448
|
+
if (!typeMatches(value, field.type)) {
|
|
449
|
+
errors.push(
|
|
450
|
+
`${field.name}: expected ${field.type}, got ${
|
|
451
|
+
value === null ? "null" : typeof value
|
|
452
|
+
}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Status enum check — only if the field was present and a string. Skip if
|
|
458
|
+
// we already flagged it as a type error to avoid double reporting.
|
|
459
|
+
if (typeof state.status === "string" && !STATUS_ENUM.includes(state.status)) {
|
|
460
|
+
errors.push(
|
|
461
|
+
`status: invalid enum value '${state.status}' (expected one of: ${STATUS_ENUM.join(", ")})`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Platform enum check.
|
|
466
|
+
if (
|
|
467
|
+
typeof state.platform === "string" &&
|
|
468
|
+
!PLATFORM_ENUM.includes(state.platform)
|
|
469
|
+
) {
|
|
470
|
+
errors.push(
|
|
471
|
+
`platform: invalid enum value '${state.platform}' (expected one of: ${PLATFORM_ENUM.join(", ")})`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Value sanity — only if the field was present and numeric.
|
|
476
|
+
if (typeof state.iter === "number" && Number.isInteger(state.iter) && state.iter < 0) {
|
|
477
|
+
errors.push(`iter: must be >= 0, got ${state.iter}`);
|
|
478
|
+
}
|
|
479
|
+
if (
|
|
480
|
+
typeof state.maxIterations === "number" &&
|
|
481
|
+
Number.isInteger(state.maxIterations) &&
|
|
482
|
+
state.maxIterations <= 0
|
|
483
|
+
) {
|
|
484
|
+
errors.push(
|
|
485
|
+
`maxIterations: must be > 0, got ${state.maxIterations}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
typeof state.wallClockElapsedMs === "number" &&
|
|
490
|
+
Number.isInteger(state.wallClockElapsedMs) &&
|
|
491
|
+
state.wallClockElapsedMs < 0
|
|
492
|
+
) {
|
|
493
|
+
errors.push(
|
|
494
|
+
`wallClockElapsedMs: must be >= 0, got ${state.wallClockElapsedMs}`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (errors.length > 0) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
code: 2,
|
|
502
|
+
reason: "state-validation-failed",
|
|
503
|
+
errors,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { ok: true };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── detectGutter ────────────────────────────────────────────────────────────
|
|
511
|
+
//
|
|
512
|
+
// Post-worker hook: scans the tail of run.log plus the supervisor state for
|
|
513
|
+
// three stall patterns. A positive detection returns code 6 (gutter-detected)
|
|
514
|
+
// per contract §5. Pure function — NO filesystem reads. The caller is
|
|
515
|
+
// responsible for passing the run-log tail as a string (typically last ~200
|
|
516
|
+
// lines) and the current state object.
|
|
517
|
+
//
|
|
518
|
+
// The three patterns:
|
|
519
|
+
//
|
|
520
|
+
// 1. repeated-error
|
|
521
|
+
// Extract error lines (regex /error[:\s].*$/im) grouped by iteration
|
|
522
|
+
// block (headers of the form "--- ITER N ---"). If the same error
|
|
523
|
+
// signature appears in the last `gutterThreshold` (default 3) consecutive
|
|
524
|
+
// iteration blocks, flag it.
|
|
525
|
+
//
|
|
526
|
+
// 2. file-thrash
|
|
527
|
+
// Count `Edit(`/`Write(` tool operations per file across iteration
|
|
528
|
+
// blocks. Heuristic: if the top file appears in >= `gutterThreshold`
|
|
529
|
+
// iterations AND accounts for a dominant share of edits, flag it. This
|
|
530
|
+
// is intentionally a cheap approximation — see §12 of the contract which
|
|
531
|
+
// lists gutter detection as implementation-owned.
|
|
532
|
+
//
|
|
533
|
+
// 3. no-progress
|
|
534
|
+
// If the caller passes `state.progressHash` and `state.progressHashHistory`
|
|
535
|
+
// (an array of the last N hashes, one per iter), and the last
|
|
536
|
+
// `gutterWindow` (default 5) hashes are all identical AND state.iter has
|
|
537
|
+
// advanced by at least `gutterWindow`, flag it. Callers without history
|
|
538
|
+
// can omit this signal and the function will skip the no-progress check
|
|
539
|
+
// (low false-positive design).
|
|
540
|
+
//
|
|
541
|
+
// Config fields consumed (all optional):
|
|
542
|
+
// - gutterThreshold (default 3) — min consecutive iters for pattern
|
|
543
|
+
// - gutterWindow (default 5) — lookback window for no-progress
|
|
544
|
+
// - gutterNoProgressIters (default 5) — alias for gutterWindow from §13
|
|
545
|
+
|
|
546
|
+
const ITER_HEADER_RE = /^---\s*ITER\s+(\d+)\s*---/im;
|
|
547
|
+
|
|
548
|
+
function splitIterBlocks(runLogTail) {
|
|
549
|
+
// Split the tail into blocks keyed by the "--- ITER N ---" header. Content
|
|
550
|
+
// before the first header is discarded (it belongs to an iteration we don't
|
|
551
|
+
// have full visibility into).
|
|
552
|
+
if (typeof runLogTail !== "string" || runLogTail.length === 0) return [];
|
|
553
|
+
const lines = runLogTail.split(/\r?\n/);
|
|
554
|
+
const blocks = [];
|
|
555
|
+
let current = null;
|
|
556
|
+
for (const line of lines) {
|
|
557
|
+
const m = line.match(ITER_HEADER_RE);
|
|
558
|
+
if (m) {
|
|
559
|
+
if (current) blocks.push(current);
|
|
560
|
+
current = { iter: Number(m[1]), lines: [] };
|
|
561
|
+
} else if (current) {
|
|
562
|
+
current.lines.push(line);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (current) blocks.push(current);
|
|
566
|
+
return blocks;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Extract the first error-looking line from a block and normalize it into a
|
|
570
|
+
// signature for equality comparison. Returns null if no error line found.
|
|
571
|
+
function extractErrorSignature(block) {
|
|
572
|
+
for (const line of block.lines) {
|
|
573
|
+
const m = line.match(/error[:\s]+(.+)$/i);
|
|
574
|
+
if (m) {
|
|
575
|
+
// Normalize whitespace and strip volatile numeric/path suffixes so that
|
|
576
|
+
// two errors that differ only by line number still match.
|
|
577
|
+
return m[1]
|
|
578
|
+
.replace(/\s+/g, " ")
|
|
579
|
+
.replace(/\b\d+\b/g, "N")
|
|
580
|
+
.replace(/[/\\][\w./\\-]+/g, "PATH")
|
|
581
|
+
.trim()
|
|
582
|
+
.slice(0, 200);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Count `Edit(path=...)` / `Write(file_path=...)` mentions per file in a
|
|
589
|
+
// block. The exact tool-call serialization isn't standardized, so we fall back
|
|
590
|
+
// to a loose match: `(Edit|Write)\s*\(\s*(?:file_path|path)?\s*=?\s*['"]([^'"]+)['"]`
|
|
591
|
+
const TOOL_CALL_RE =
|
|
592
|
+
/(?:Edit|Write)\s*\(\s*(?:file_path|path)?\s*=?\s*['"]([^'"]+)['"]/gi;
|
|
593
|
+
|
|
594
|
+
function extractEditedFiles(block) {
|
|
595
|
+
const files = new Set();
|
|
596
|
+
const text = block.lines.join("\n");
|
|
597
|
+
let m;
|
|
598
|
+
while ((m = TOOL_CALL_RE.exec(text)) !== null) {
|
|
599
|
+
files.add(m[1]);
|
|
600
|
+
}
|
|
601
|
+
return files;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function detectGutter(state, runLogTail, config) {
|
|
605
|
+
const cfg = config || {};
|
|
606
|
+
const threshold =
|
|
607
|
+
typeof cfg.gutterThreshold === "number" && cfg.gutterThreshold > 0
|
|
608
|
+
? cfg.gutterThreshold
|
|
609
|
+
: 3;
|
|
610
|
+
const window =
|
|
611
|
+
typeof cfg.gutterWindow === "number" && cfg.gutterWindow > 0
|
|
612
|
+
? cfg.gutterWindow
|
|
613
|
+
: typeof cfg.gutterNoProgressIters === "number" &&
|
|
614
|
+
cfg.gutterNoProgressIters > 0
|
|
615
|
+
? cfg.gutterNoProgressIters
|
|
616
|
+
: DEFAULTS.gutterNoProgressIters;
|
|
617
|
+
|
|
618
|
+
const blocks = splitIterBlocks(runLogTail || "");
|
|
619
|
+
|
|
620
|
+
// ── Pattern 1: repeated-error ─────────────────────────────────────────────
|
|
621
|
+
if (blocks.length >= threshold) {
|
|
622
|
+
const recent = blocks.slice(-threshold);
|
|
623
|
+
const sigs = recent.map(extractErrorSignature);
|
|
624
|
+
if (sigs.every((s) => s !== null) && sigs.every((s) => s === sigs[0])) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false,
|
|
627
|
+
code: 6,
|
|
628
|
+
reason: "gutter-detected",
|
|
629
|
+
pattern: "repeated-error",
|
|
630
|
+
details: {
|
|
631
|
+
signature: sigs[0],
|
|
632
|
+
consecutiveIters: threshold,
|
|
633
|
+
iters: recent.map((b) => b.iter),
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Pattern 2: file-thrash ────────────────────────────────────────────────
|
|
640
|
+
// Heuristic: for each file, count how many of the last `threshold` blocks
|
|
641
|
+
// it was edited in. If any file appears in ALL `threshold` recent blocks AND
|
|
642
|
+
// the top file accounts for a dominant share of edits (>= 50% of total
|
|
643
|
+
// file-block pairs), flag it. This catches "keeps editing the same 2-3
|
|
644
|
+
// files over and over" stalls without firing on normal healthy multi-file
|
|
645
|
+
// churn.
|
|
646
|
+
if (blocks.length >= threshold) {
|
|
647
|
+
const recent = blocks.slice(-threshold);
|
|
648
|
+
const fileCounts = new Map(); // file -> number of blocks it appears in
|
|
649
|
+
let totalPairs = 0;
|
|
650
|
+
for (const block of recent) {
|
|
651
|
+
const files = extractEditedFiles(block);
|
|
652
|
+
for (const f of files) {
|
|
653
|
+
fileCounts.set(f, (fileCounts.get(f) || 0) + 1);
|
|
654
|
+
totalPairs += 1;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Find files that appear in every block.
|
|
658
|
+
const persistent = Array.from(fileCounts.entries())
|
|
659
|
+
.filter(([, count]) => count >= threshold)
|
|
660
|
+
.sort((a, b) => b[1] - a[1]);
|
|
661
|
+
if (persistent.length > 0 && totalPairs > 0) {
|
|
662
|
+
const topShare =
|
|
663
|
+
persistent.reduce((sum, [, c]) => sum + c, 0) / totalPairs;
|
|
664
|
+
// Dominant = the persistent files account for >= 50% of all edit
|
|
665
|
+
// activity in the window. Tuned for low false-positive rate.
|
|
666
|
+
if (topShare >= 0.5) {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
code: 6,
|
|
670
|
+
reason: "gutter-detected",
|
|
671
|
+
pattern: "file-thrash",
|
|
672
|
+
details: {
|
|
673
|
+
files: persistent.map(([f]) => f),
|
|
674
|
+
window: threshold,
|
|
675
|
+
dominantShare: Number(topShare.toFixed(2)),
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Pattern 3: no-progress ────────────────────────────────────────────────
|
|
683
|
+
// Only runs when caller supplies progressHashHistory. Absence = skip
|
|
684
|
+
// (low-false-positive design — we'd rather miss a stall than flag a
|
|
685
|
+
// healthy run as stalled).
|
|
686
|
+
if (
|
|
687
|
+
state &&
|
|
688
|
+
Array.isArray(state.progressHashHistory) &&
|
|
689
|
+
state.progressHashHistory.length >= window &&
|
|
690
|
+
typeof state.iter === "number"
|
|
691
|
+
) {
|
|
692
|
+
const history = state.progressHashHistory.slice(-window);
|
|
693
|
+
const first = history[0];
|
|
694
|
+
if (first && history.every((h) => h === first)) {
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
code: 6,
|
|
698
|
+
reason: "gutter-detected",
|
|
699
|
+
pattern: "no-progress",
|
|
700
|
+
details: {
|
|
701
|
+
unchangedHash: first,
|
|
702
|
+
window,
|
|
703
|
+
iter: state.iter,
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return { ok: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── detectBlockerSentinel ───────────────────────────────────────────────────
|
|
713
|
+
//
|
|
714
|
+
// Post-worker hook: scans the run.log tail for sentinel strings the worker
|
|
715
|
+
// emits when it hits a human-gated blocker (destructive action guard, waiting
|
|
716
|
+
// for user input, etc.). A match halts the supervisor with code 6 and the
|
|
717
|
+
// matched pattern string so the watch loop can surface it to the user.
|
|
718
|
+
|
|
719
|
+
const BLOCKER_SENTINEL_PATTERNS = Object.freeze([
|
|
720
|
+
/\bblocked\s+needs\s+human\b/i,
|
|
721
|
+
/\bblocker:\s+.+$/im,
|
|
722
|
+
/\bdestructive\s+action\s+guard\b/i,
|
|
723
|
+
/\bwaiting\s+for\s+user\b/i,
|
|
724
|
+
]);
|
|
725
|
+
|
|
726
|
+
function detectBlockerSentinel(runLogTail) {
|
|
727
|
+
if (typeof runLogTail !== "string" || runLogTail.length === 0) {
|
|
728
|
+
return { ok: true };
|
|
729
|
+
}
|
|
730
|
+
for (const re of BLOCKER_SENTINEL_PATTERNS) {
|
|
731
|
+
const m = runLogTail.match(re);
|
|
732
|
+
if (m) {
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
code: 6,
|
|
736
|
+
reason: "blocker-sentinel-detected",
|
|
737
|
+
pattern: re.source,
|
|
738
|
+
matchedText: m[0].slice(0, 200),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return { ok: true };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
module.exports = {
|
|
746
|
+
DEFAULTS,
|
|
747
|
+
loadConfig,
|
|
748
|
+
checkGitBranch,
|
|
749
|
+
checkWorktreeCleanliness,
|
|
750
|
+
checkIterationCap,
|
|
751
|
+
checkWallClockCap,
|
|
752
|
+
validateState,
|
|
753
|
+
detectGutter,
|
|
754
|
+
detectBlockerSentinel,
|
|
755
|
+
// Internal helpers exported for unit tests.
|
|
756
|
+
_globToRegex: globToRegex,
|
|
757
|
+
_matchesAnyGlob: matchesAnyGlob,
|
|
758
|
+
_parsePorcelainLine: parsePorcelainLine,
|
|
759
|
+
_splitIterBlocks: splitIterBlocks,
|
|
760
|
+
_extractErrorSignature: extractErrorSignature,
|
|
761
|
+
_extractEditedFiles: extractEditedFiles,
|
|
762
|
+
_BLOCKER_SENTINEL_PATTERNS: BLOCKER_SENTINEL_PATTERNS,
|
|
763
|
+
_STATUS_ENUM: STATUS_ENUM,
|
|
764
|
+
_PLATFORM_ENUM: PLATFORM_ENUM,
|
|
765
|
+
_REQUIRED_STATE_FIELDS: REQUIRED_STATE_FIELDS,
|
|
766
|
+
};
|