@deltafleet/goalkeeper 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const EVENT_TYPES = new Set([
9
+ "goal",
10
+ "user_constraint",
11
+ "decision",
12
+ "attempt",
13
+ "failure",
14
+ "edit",
15
+ "command",
16
+ "verification",
17
+ "risk",
18
+ "handoff",
19
+ "next_action",
20
+ "compact_observed",
21
+ "recovery_violation",
22
+ ]);
23
+
24
+ const STATUSES = new Set(["open", "done", "failed", "blocked", "superseded"]);
25
+ const CHECKPOINT_TARGET_BYTES = 8_000;
26
+ const CHECKPOINT_MAX_BYTES = 16_000;
27
+ const CONTEXT_PACK_TARGET_BYTES = 30_000;
28
+ const CONTEXT_PACK_MAX_BYTES = 60_000;
29
+
30
+ const USAGE = `Usage:
31
+ node scripts/goalkeeper-doctor.mjs --session <goal-session-id> [--workspace <path>] [--strict] [--json]
32
+
33
+ Checks whether a target workspace has enough Goalkeeper state and guardrails for long-running agent goal work.
34
+ This script is read-only.
35
+ `;
36
+
37
+ function parseArgs(argv) {
38
+ const options = {
39
+ sessionId: null,
40
+ workspace: ".",
41
+ strict: false,
42
+ json: false,
43
+ };
44
+
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const arg = argv[i];
47
+ if (arg === "--session") {
48
+ options.sessionId = argv[i + 1];
49
+ i += 1;
50
+ } else if (arg === "--workspace") {
51
+ options.workspace = argv[i + 1];
52
+ i += 1;
53
+ } else if (arg === "--strict") {
54
+ options.strict = true;
55
+ } else if (arg === "--json") {
56
+ options.json = true;
57
+ } else {
58
+ throw new Error(`Unknown argument: ${arg}`);
59
+ }
60
+ }
61
+
62
+ if (!options.sessionId || !options.workspace) {
63
+ throw new Error("Missing required argument.");
64
+ }
65
+
66
+ if (options.sessionId.includes("/") || options.sessionId.includes("..")) {
67
+ throw new Error("Session id must be a single path segment.");
68
+ }
69
+
70
+ return options;
71
+ }
72
+
73
+ function check(status, name, message, details = {}) {
74
+ return { status, name, message, ...(Object.keys(details).length > 0 ? { details } : {}) };
75
+ }
76
+
77
+ function isDirectory(filePath) {
78
+ try {
79
+ return fs.statSync(filePath).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function fileExists(filePath) {
86
+ try {
87
+ return fs.statSync(filePath).isFile();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function validateJsonl(filePath) {
94
+ const raw = fs.readFileSync(filePath, "utf8");
95
+ const lines = raw.split(/\r?\n/);
96
+ const invalid = [];
97
+ const schemaErrors = [];
98
+ let records = 0;
99
+
100
+ for (let index = 0; index < lines.length; index += 1) {
101
+ const line = lines[index];
102
+ if (!line.trim()) continue;
103
+ records += 1;
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(line);
107
+ } catch (error) {
108
+ invalid.push({ line: index + 1, message: error.message });
109
+ if (invalid.length >= 5) break;
110
+ continue;
111
+ }
112
+
113
+ const lineNumber = index + 1;
114
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
115
+ schemaErrors.push({ line: lineNumber, message: "Event must be a JSON object." });
116
+ continue;
117
+ }
118
+
119
+ if (typeof parsed.ts !== "string" || Number.isNaN(Date.parse(parsed.ts))) {
120
+ schemaErrors.push({ line: lineNumber, message: "Event ts must be a valid ISO timestamp string." });
121
+ }
122
+
123
+ if (typeof parsed.type !== "string" || !EVENT_TYPES.has(parsed.type)) {
124
+ schemaErrors.push({ line: lineNumber, message: `Event type is missing or unknown: ${parsed.type}` });
125
+ }
126
+
127
+ if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
128
+ schemaErrors.push({ line: lineNumber, message: "Event text must be a non-empty string." });
129
+ }
130
+
131
+ if (parsed.status !== undefined && (typeof parsed.status !== "string" || !STATUSES.has(parsed.status))) {
132
+ schemaErrors.push({ line: lineNumber, message: `Event status is unknown: ${parsed.status}` });
133
+ }
134
+
135
+ if (parsed.files !== undefined && (!Array.isArray(parsed.files) || parsed.files.some((item) => typeof item !== "string" || !item))) {
136
+ schemaErrors.push({ line: lineNumber, message: "Event files must be an array of non-empty strings." });
137
+ }
138
+
139
+ if (
140
+ parsed.commands !== undefined &&
141
+ (!Array.isArray(parsed.commands) || parsed.commands.some((item) => typeof item !== "string" || !item))
142
+ ) {
143
+ schemaErrors.push({ line: lineNumber, message: "Event commands must be an array of non-empty strings." });
144
+ }
145
+ }
146
+
147
+ return { records, invalid, schemaErrors };
148
+ }
149
+
150
+ function validateActiveSessionPointer(filePath, expectedSessionId) {
151
+ const sessionId = fs.readFileSync(filePath, "utf8").trim();
152
+ if (!sessionId) {
153
+ return { status: "fail", message: "active-session is empty.", sessionId };
154
+ }
155
+ if (sessionId.includes("/") || sessionId.includes("..")) {
156
+ return { status: "fail", message: "active-session must contain a single session id path segment.", sessionId };
157
+ }
158
+ if (sessionId !== expectedSessionId) {
159
+ return { status: "fail", message: `active-session points to ${sessionId}, not ${expectedSessionId}.`, sessionId };
160
+ }
161
+ return { status: "pass", message: "active-session points to the target session.", sessionId };
162
+ }
163
+
164
+ function listSessionDirs(sessionsRoot) {
165
+ try {
166
+ return fs
167
+ .readdirSync(sessionsRoot, { withFileTypes: true })
168
+ .filter((entry) => entry.isDirectory())
169
+ .map((entry) => entry.name)
170
+ .sort();
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ function guardrailStatus(workspace, strict) {
177
+ const candidates = [
178
+ { name: "AGENTS.md", path: path.join(workspace, "AGENTS.md") },
179
+ { name: "CLAUDE.md", path: path.join(workspace, "CLAUDE.md") },
180
+ ];
181
+ const existing = candidates.filter((candidate) => fileExists(candidate.path));
182
+
183
+ if (existing.length === 0) {
184
+ return check(strict ? "fail" : "warn", "project_guardrail", "AGENTS.md or CLAUDE.md is missing.", {
185
+ paths: candidates.map((candidate) => candidate.path),
186
+ });
187
+ }
188
+
189
+ const inspected = existing.map((candidate) => {
190
+ const text = fs.readFileSync(candidate.path, "utf8");
191
+ return {
192
+ ...candidate,
193
+ hasGoalkeeperPath: text.includes(".goalkeeper/sessions"),
194
+ hasCheckpoint: text.includes("checkpoint"),
195
+ hasFirstActionLanguage: /start|before|first|resume|compaction|compact/i.test(text),
196
+ };
197
+ });
198
+ const passing = inspected.find((candidate) => candidate.hasGoalkeeperPath && candidate.hasCheckpoint && candidate.hasFirstActionLanguage);
199
+
200
+ if (passing) {
201
+ return check("pass", "project_guardrail", `${passing.name} contains a Goalkeeper checkpoint-first guardrail.`, {
202
+ path: passing.path,
203
+ });
204
+ }
205
+
206
+ return check(
207
+ strict ? "fail" : "warn",
208
+ "project_guardrail",
209
+ "AGENTS.md or CLAUDE.md exists but does not clearly contain the Goalkeeper checkpoint-first guardrail.",
210
+ { paths: inspected.map((candidate) => candidate.path) },
211
+ );
212
+ }
213
+
214
+ function runTurnStart(workspace, sessionId) {
215
+ const scriptPath = fileURLToPath(new URL("./goalkeeper-turn-start.mjs", import.meta.url));
216
+ if (!fileExists(scriptPath)) {
217
+ return check("fail", "turn_start_helper", "goalkeeper-turn-start.mjs is missing next to doctor.", {
218
+ path: scriptPath,
219
+ });
220
+ }
221
+
222
+ const result = spawnSync(
223
+ process.execPath,
224
+ [scriptPath, "--workspace", workspace, "--session", sessionId, "--json"],
225
+ { encoding: "utf8" },
226
+ );
227
+
228
+ if (result.status !== 0) {
229
+ return check("fail", "turn_start_helper", "goalkeeper-turn-start.mjs could not read the checkpoint.", {
230
+ status: result.status,
231
+ stderr: result.stderr.trim(),
232
+ });
233
+ }
234
+
235
+ try {
236
+ const parsed = JSON.parse(result.stdout);
237
+ return check("pass", "turn_start_helper", "goalkeeper-turn-start.mjs can read the active checkpoint.", {
238
+ checkpointPath: parsed.checkpointPath,
239
+ checkpointBytes: parsed.checkpoint.length,
240
+ });
241
+ } catch (error) {
242
+ return check("fail", "turn_start_helper", "goalkeeper-turn-start.mjs returned invalid JSON.", {
243
+ message: error.message,
244
+ });
245
+ }
246
+ }
247
+
248
+ function inspectWorkspace(options) {
249
+ const workspace = path.resolve(options.workspace);
250
+ const activeSessionPath = path.join(workspace, ".goalkeeper", "active-session");
251
+ const sessionsRoot = path.join(workspace, ".goalkeeper", "sessions");
252
+ const sessionDir = path.join(workspace, ".goalkeeper", "sessions", options.sessionId);
253
+ const checkpointPath = path.join(sessionDir, "checkpoint.md");
254
+ const contextPackPath = path.join(sessionDir, "context-pack.md");
255
+ const eventsPath = path.join(sessionDir, "events.jsonl");
256
+ const checks = [];
257
+
258
+ checks.push(
259
+ isDirectory(workspace)
260
+ ? check("pass", "workspace", "Workspace exists.", { path: workspace })
261
+ : check("fail", "workspace", "Workspace does not exist or is not a directory.", { path: workspace }),
262
+ );
263
+
264
+ checks.push(
265
+ isDirectory(sessionDir)
266
+ ? check("pass", "session_dir", "Goalkeeper session directory exists.", { path: sessionDir })
267
+ : check("fail", "session_dir", "Goalkeeper session directory is missing.", { path: sessionDir }),
268
+ );
269
+
270
+ if (fileExists(checkpointPath)) {
271
+ const checkpoint = fs.readFileSync(checkpointPath, "utf8");
272
+ const checkpointBytes = Buffer.byteLength(checkpoint);
273
+ checks.push(
274
+ checkpoint.trim().length > 0
275
+ ? check("pass", "checkpoint", "Checkpoint exists and is non-empty.", {
276
+ path: checkpointPath,
277
+ bytes: checkpointBytes,
278
+ })
279
+ : check("fail", "checkpoint", "Checkpoint exists but is empty.", { path: checkpointPath }),
280
+ );
281
+
282
+ if (checkpointBytes <= CHECKPOINT_TARGET_BYTES) {
283
+ checks.push(
284
+ check("pass", "checkpoint_size", "Checkpoint is within the routine-read size target.", {
285
+ bytes: checkpointBytes,
286
+ targetBytes: CHECKPOINT_TARGET_BYTES,
287
+ maxBytes: CHECKPOINT_MAX_BYTES,
288
+ }),
289
+ );
290
+ } else if (checkpointBytes <= CHECKPOINT_MAX_BYTES) {
291
+ checks.push(
292
+ check("warn", "checkpoint_size", "Checkpoint is above the target size; compact stale details soon.", {
293
+ bytes: checkpointBytes,
294
+ targetBytes: CHECKPOINT_TARGET_BYTES,
295
+ maxBytes: CHECKPOINT_MAX_BYTES,
296
+ }),
297
+ );
298
+ } else {
299
+ checks.push(
300
+ check("fail", "checkpoint_size", "Checkpoint is too large for reliable routine recovery.", {
301
+ bytes: checkpointBytes,
302
+ targetBytes: CHECKPOINT_TARGET_BYTES,
303
+ maxBytes: CHECKPOINT_MAX_BYTES,
304
+ }),
305
+ );
306
+ }
307
+
308
+ const expectedSections = ["goal", "constraint", "evidence", "next"];
309
+ const lower = checkpoint.toLowerCase();
310
+ const missingSections = expectedSections.filter((section) => !lower.includes(section));
311
+ if (missingSections.length === 0) {
312
+ checks.push(check("pass", "checkpoint_shape", "Checkpoint appears to contain goal, constraints, evidence, and next action."));
313
+ } else {
314
+ checks.push(
315
+ check("warn", "checkpoint_shape", "Checkpoint is readable but may be too thin for reliable recovery.", {
316
+ missingHints: missingSections,
317
+ }),
318
+ );
319
+ }
320
+ } else {
321
+ checks.push(check("fail", "checkpoint", "Checkpoint is missing.", { path: checkpointPath }));
322
+ }
323
+
324
+ if (fileExists(eventsPath)) {
325
+ const jsonl = validateJsonl(eventsPath);
326
+ if (jsonl.invalid.length > 0) {
327
+ checks.push(
328
+ check("fail", "events_jsonl", "events.jsonl contains invalid JSON.", {
329
+ path: eventsPath,
330
+ invalid: jsonl.invalid,
331
+ }),
332
+ );
333
+ } else if (jsonl.schemaErrors.length > 0) {
334
+ checks.push(
335
+ check("fail", "events_jsonl", "events.jsonl contains schema-invalid events.", {
336
+ path: eventsPath,
337
+ records: jsonl.records,
338
+ schemaErrors: jsonl.schemaErrors.slice(0, 5),
339
+ }),
340
+ );
341
+ } else {
342
+ checks.push(
343
+ check("pass", "events_jsonl", "events.jsonl exists and passes schema validation.", {
344
+ path: eventsPath,
345
+ records: jsonl.records,
346
+ }),
347
+ );
348
+ }
349
+ } else {
350
+ checks.push(check("fail", "events_jsonl", "events.jsonl is missing.", { path: eventsPath }));
351
+ }
352
+
353
+ if (fileExists(contextPackPath)) {
354
+ const contextPack = fs.readFileSync(contextPackPath, "utf8");
355
+ const contextPackBytes = Buffer.byteLength(contextPack);
356
+ if (contextPackBytes <= CONTEXT_PACK_TARGET_BYTES) {
357
+ checks.push(
358
+ check("pass", "context_pack", "context-pack.md exists and is within the medium-context target.", {
359
+ path: contextPackPath,
360
+ bytes: contextPackBytes,
361
+ targetBytes: CONTEXT_PACK_TARGET_BYTES,
362
+ maxBytes: CONTEXT_PACK_MAX_BYTES,
363
+ }),
364
+ );
365
+ } else if (contextPackBytes <= CONTEXT_PACK_MAX_BYTES) {
366
+ checks.push(
367
+ check("warn", "context_pack", "context-pack.md is large; compact stale detail when possible.", {
368
+ path: contextPackPath,
369
+ bytes: contextPackBytes,
370
+ targetBytes: CONTEXT_PACK_TARGET_BYTES,
371
+ maxBytes: CONTEXT_PACK_MAX_BYTES,
372
+ }),
373
+ );
374
+ } else {
375
+ checks.push(
376
+ check("fail", "context_pack", "context-pack.md is too large to use as a practical recovery aid.", {
377
+ path: contextPackPath,
378
+ bytes: contextPackBytes,
379
+ targetBytes: CONTEXT_PACK_TARGET_BYTES,
380
+ maxBytes: CONTEXT_PACK_MAX_BYTES,
381
+ }),
382
+ );
383
+ }
384
+ }
385
+
386
+ if (fileExists(activeSessionPath)) {
387
+ const activeSession = validateActiveSessionPointer(activeSessionPath, options.sessionId);
388
+ checks.push(
389
+ check(activeSession.status, "active_session", activeSession.message, {
390
+ path: activeSessionPath,
391
+ sessionId: activeSession.sessionId,
392
+ }),
393
+ );
394
+ } else {
395
+ const sessionDirs = listSessionDirs(sessionsRoot);
396
+ if (sessionDirs.length > 1) {
397
+ checks.push(
398
+ check(
399
+ options.strict ? "fail" : "warn",
400
+ "active_session",
401
+ "active-session is missing while multiple Goalkeeper sessions exist.",
402
+ {
403
+ path: activeSessionPath,
404
+ sessions: sessionDirs,
405
+ },
406
+ ),
407
+ );
408
+ }
409
+ }
410
+
411
+ checks.push(guardrailStatus(workspace, options.strict));
412
+
413
+ if (fileExists(checkpointPath)) {
414
+ checks.push(runTurnStart(workspace, options.sessionId));
415
+ }
416
+
417
+ const failed = checks.filter((item) => item.status === "fail").length;
418
+ const warned = checks.filter((item) => item.status === "warn").length;
419
+
420
+ return {
421
+ ok: failed === 0,
422
+ strict: options.strict,
423
+ workspace,
424
+ sessionId: options.sessionId,
425
+ sessionDir,
426
+ activeSessionPath: fileExists(activeSessionPath) ? activeSessionPath : null,
427
+ checkpointPath,
428
+ eventsPath,
429
+ checks,
430
+ summary: {
431
+ passed: checks.filter((item) => item.status === "pass").length,
432
+ warned,
433
+ failed,
434
+ },
435
+ };
436
+ }
437
+
438
+ function printText(result) {
439
+ console.log(`Goalkeeper doctor: ${result.ok ? "PASS" : "FAIL"}`);
440
+ console.log(`Workspace: ${result.workspace}`);
441
+ console.log(`Session: ${result.sessionId}`);
442
+ console.log(`Strict: ${result.strict ? "yes" : "no"}`);
443
+ console.log("");
444
+
445
+ for (const item of result.checks) {
446
+ console.log(`- ${item.status.toUpperCase()} ${item.name}: ${item.message}`);
447
+ if (item.details?.path) console.log(` path: ${item.details.path}`);
448
+ if (item.details?.paths) console.log(` paths: ${item.details.paths.join(", ")}`);
449
+ if (item.details?.sessionId) console.log(` session id: ${item.details.sessionId}`);
450
+ if (item.details?.bytes !== undefined) console.log(` bytes: ${item.details.bytes}`);
451
+ if (item.details?.records !== undefined) console.log(` records: ${item.details.records}`);
452
+ if (item.details?.sessions) console.log(` sessions: ${item.details.sessions.join(", ")}`);
453
+ if (item.details?.checkpointBytes !== undefined) console.log(` checkpoint bytes: ${item.details.checkpointBytes}`);
454
+ if (item.details?.missingHints) console.log(` missing hints: ${item.details.missingHints.join(", ")}`);
455
+ if (item.details?.errors) {
456
+ for (const error of item.details.errors) {
457
+ console.log(` error: ${error}`);
458
+ }
459
+ }
460
+ if (item.details?.schemaErrors) {
461
+ for (const error of item.details.schemaErrors) {
462
+ console.log(` schema error line ${error.line}: ${error.message}`);
463
+ }
464
+ }
465
+ if (item.details?.stderr) console.log(` stderr: ${item.details.stderr.slice(0, 300)}`);
466
+ }
467
+ }
468
+
469
+ function main() {
470
+ let options;
471
+ try {
472
+ options = parseArgs(process.argv.slice(2));
473
+ } catch (error) {
474
+ console.error(error.message);
475
+ console.error(USAGE);
476
+ process.exit(2);
477
+ }
478
+
479
+ const result = inspectWorkspace(options);
480
+
481
+ if (options.json) {
482
+ console.log(JSON.stringify(result, null, 2));
483
+ } else {
484
+ printText(result);
485
+ }
486
+
487
+ process.exit(result.ok ? 0 : 1);
488
+ }
489
+
490
+ main();