@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,339 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_MAX_BYTES = 8_000;
7
+ const HARD_MAX_BYTES = 16_000;
8
+
9
+ const USAGE = `Usage:
10
+ node scripts/goalkeeper-update-checkpoint.mjs --goal <text> --next <text> [--session <goal-session-id>] [--workspace <path>] [--done <text>] [--status <text>] [--throughline <text>] [--why <text>] [--constraint <text> ...] [--forbidden <text> ...] [--decision <text> ...] [--attempt <text> ...] [--file <path> ...] [--verified <text> ...] [--unverified <text> ...] [--risk <text> ...] [--evidence <text> ...] [--max-bytes <n>] [--dry-run] [--json]
11
+
12
+ Replaces checkpoint.md with a bounded, canonical recovery checkpoint.
13
+ Append the corresponding event first with goalkeeper-append-event.mjs; this script writes only checkpoint.md.
14
+ If --session is omitted, <workspace>/.goalkeeper/active-session is used.
15
+ `;
16
+
17
+ function parseArgs(argv) {
18
+ const options = {
19
+ sessionId: null,
20
+ workspace: ".",
21
+ title: null,
22
+ goal: null,
23
+ doneCriteria: null,
24
+ status: null,
25
+ throughline: null,
26
+ why: null,
27
+ constraints: [],
28
+ forbidden: [],
29
+ decisions: [],
30
+ attempts: [],
31
+ files: [],
32
+ verified: [],
33
+ unverified: [],
34
+ risks: [],
35
+ evidence: [],
36
+ next: null,
37
+ maxBytes: DEFAULT_MAX_BYTES,
38
+ dryRun: false,
39
+ json: false,
40
+ };
41
+
42
+ const repeated = new Map([
43
+ ["--constraint", "constraints"],
44
+ ["--forbidden", "forbidden"],
45
+ ["--decision", "decisions"],
46
+ ["--attempt", "attempts"],
47
+ ["--file", "files"],
48
+ ["--verified", "verified"],
49
+ ["--unverified", "unverified"],
50
+ ["--risk", "risks"],
51
+ ["--evidence", "evidence"],
52
+ ]);
53
+
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const arg = argv[i];
56
+ if (arg === "--session") {
57
+ options.sessionId = argv[i + 1];
58
+ i += 1;
59
+ } else if (arg === "--workspace") {
60
+ options.workspace = argv[i + 1];
61
+ i += 1;
62
+ } else if (arg === "--title") {
63
+ options.title = argv[i + 1];
64
+ i += 1;
65
+ } else if (arg === "--goal") {
66
+ options.goal = argv[i + 1];
67
+ i += 1;
68
+ } else if (arg === "--done") {
69
+ options.doneCriteria = argv[i + 1];
70
+ i += 1;
71
+ } else if (arg === "--status") {
72
+ options.status = argv[i + 1];
73
+ i += 1;
74
+ } else if (arg === "--throughline") {
75
+ options.throughline = argv[i + 1];
76
+ i += 1;
77
+ } else if (arg === "--why") {
78
+ options.why = argv[i + 1];
79
+ i += 1;
80
+ } else if (arg === "--next") {
81
+ options.next = argv[i + 1];
82
+ i += 1;
83
+ } else if (arg === "--max-bytes") {
84
+ options.maxBytes = Number(argv[i + 1]);
85
+ i += 1;
86
+ } else if (arg === "--dry-run") {
87
+ options.dryRun = true;
88
+ } else if (arg === "--json") {
89
+ options.json = true;
90
+ } else if (repeated.has(arg)) {
91
+ options[repeated.get(arg)].push(argv[i + 1]);
92
+ i += 1;
93
+ } else {
94
+ throw new Error(`Unknown argument: ${arg}`);
95
+ }
96
+ }
97
+
98
+ if (!options.workspace || !options.goal || !options.next) {
99
+ throw new Error("Missing required argument.");
100
+ }
101
+
102
+ if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > HARD_MAX_BYTES) {
103
+ throw new Error(`--max-bytes must be an integer between 1 and ${HARD_MAX_BYTES}.`);
104
+ }
105
+
106
+ const stringFields = [
107
+ "sessionId",
108
+ "title",
109
+ "goal",
110
+ "doneCriteria",
111
+ "status",
112
+ "throughline",
113
+ "why",
114
+ "next",
115
+ ];
116
+
117
+ for (const field of stringFields) {
118
+ if (options[field] !== null && options[field] !== undefined) {
119
+ options[field] = normalizeText(options[field], field);
120
+ }
121
+ }
122
+
123
+ for (const key of repeated.values()) {
124
+ const normalize = key === "files" ? normalizePathText : normalizeText;
125
+ options[key] = options[key].map((value) => normalize(value, key));
126
+ if (options[key].some((value) => !value)) {
127
+ throw new Error(`Values for ${key} must not be empty.`);
128
+ }
129
+ }
130
+
131
+ return options;
132
+ }
133
+
134
+ function normalizeText(value, field) {
135
+ if (typeof value !== "string") {
136
+ throw new Error(`${field} must be a string.`);
137
+ }
138
+ const normalized = value.replace(/\s+/g, " ").trim();
139
+ if (!normalized) {
140
+ throw new Error(`${field} must not be empty.`);
141
+ }
142
+ return normalized;
143
+ }
144
+
145
+ function normalizePathText(value, field) {
146
+ if (typeof value !== "string") {
147
+ throw new Error(`${field} must be a string.`);
148
+ }
149
+ const normalized = value.trim();
150
+ if (!normalized) {
151
+ throw new Error(`${field} must not be empty.`);
152
+ }
153
+ if (/[\r\n]/.test(normalized)) {
154
+ throw new Error(`${field} must not contain newlines.`);
155
+ }
156
+ return normalized;
157
+ }
158
+
159
+ function isSingleSegmentSessionId(value) {
160
+ return typeof value === "string" && value.trim() && !value.includes("/") && !value.includes("..");
161
+ }
162
+
163
+ function readActiveSession(workspace) {
164
+ const activeSessionPath = path.join(workspace, ".goalkeeper", "active-session");
165
+ if (!fs.existsSync(activeSessionPath)) {
166
+ throw new Error(`--session was omitted and active-session is missing: ${activeSessionPath}`);
167
+ }
168
+ const sessionId = fs.readFileSync(activeSessionPath, "utf8").trim();
169
+ if (!isSingleSegmentSessionId(sessionId)) {
170
+ throw new Error(`active-session must contain a single session id path segment: ${activeSessionPath}`);
171
+ }
172
+ return { sessionId, activeSessionPath };
173
+ }
174
+
175
+ function bulletList(items, fallback = "None recorded.") {
176
+ if (items.length === 0) return `- ${fallback}`;
177
+ return items.map((item) => `- ${item}`).join("\n");
178
+ }
179
+
180
+ function renderCheckpoint(options, context) {
181
+ const title = options.title || context.sessionId;
182
+ const contextPack = fs.existsSync(context.contextPackPath)
183
+ ? `.goalkeeper/sessions/${context.sessionId}/context-pack.md`
184
+ : "None recorded.";
185
+
186
+ return `# Checkpoint: ${title}
187
+
188
+ ## Active Goal
189
+
190
+ - Objective: ${options.goal}
191
+ - Done criteria: ${options.doneCriteria || "Not explicitly recorded."}
192
+ - Current status: ${options.status || "Open."}
193
+
194
+ ## Throughline
195
+
196
+ - Current direction: ${options.throughline || "Continue from the active goal and latest verified state."}
197
+ - Why this direction: ${options.why || "Preserve direction across compaction with project-local state."}
198
+
199
+ ## Constraints
200
+
201
+ - Non-negotiable:
202
+ ${indentBullets(options.constraints, "None recorded.")}
203
+ - Forbidden approaches:
204
+ ${indentBullets(options.forbidden, "None recorded.")}
205
+
206
+ ## Decisions
207
+
208
+ ${bulletList(options.decisions)}
209
+
210
+ ## Attempts And Failures
211
+
212
+ ${bulletList(options.attempts)}
213
+
214
+ ## Important Files
215
+
216
+ ${bulletList(options.files)}
217
+
218
+ ## Evidence
219
+
220
+ ${bulletList(options.evidence)}
221
+
222
+ ## Context Pack
223
+
224
+ - ${contextPack}
225
+
226
+ ## Verification
227
+
228
+ - Verified:
229
+ ${indentBullets(options.verified, "None recorded.")}
230
+ - Not yet verified:
231
+ ${indentBullets(options.unverified, "None recorded.")}
232
+
233
+ ## Open Risks
234
+
235
+ ${bulletList(options.risks)}
236
+
237
+ ## Next Action
238
+
239
+ - ${options.next}
240
+
241
+ ## Last Updated
242
+
243
+ - ${context.updatedAt}
244
+ `;
245
+ }
246
+
247
+ function indentBullets(items, fallback) {
248
+ return bulletList(items, fallback)
249
+ .split("\n")
250
+ .map((line) => ` ${line}`)
251
+ .join("\n");
252
+ }
253
+
254
+ function main() {
255
+ let options;
256
+ try {
257
+ options = parseArgs(process.argv.slice(2));
258
+ } catch (error) {
259
+ console.error(error.message);
260
+ console.error(USAGE);
261
+ process.exit(2);
262
+ }
263
+
264
+ const workspace = path.resolve(options.workspace);
265
+ if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
266
+ console.error(`Workspace does not exist or is not a directory: ${workspace}`);
267
+ process.exit(1);
268
+ }
269
+
270
+ let activeSessionPath = null;
271
+ if (!options.sessionId) {
272
+ try {
273
+ const active = readActiveSession(workspace);
274
+ options.sessionId = active.sessionId;
275
+ activeSessionPath = active.activeSessionPath;
276
+ } catch (error) {
277
+ console.error(error.message);
278
+ process.exit(1);
279
+ }
280
+ }
281
+
282
+ if (!isSingleSegmentSessionId(options.sessionId)) {
283
+ console.error("Session id must be a single path segment.");
284
+ process.exit(2);
285
+ }
286
+
287
+ const sessionDir = path.join(workspace, ".goalkeeper", "sessions", options.sessionId);
288
+ const checkpointPath = path.join(sessionDir, "checkpoint.md");
289
+ const contextPackPath = path.join(sessionDir, "context-pack.md");
290
+
291
+ if (!fs.existsSync(sessionDir) || !fs.statSync(sessionDir).isDirectory()) {
292
+ console.error(`Goalkeeper session directory is missing: ${sessionDir}`);
293
+ process.exit(1);
294
+ }
295
+
296
+ const updatedAt = new Date().toISOString();
297
+ const checkpoint = renderCheckpoint(options, {
298
+ sessionId: options.sessionId,
299
+ contextPackPath,
300
+ updatedAt,
301
+ });
302
+ const bytes = Buffer.byteLength(checkpoint);
303
+
304
+ if (bytes > options.maxBytes) {
305
+ console.error(`Rendered checkpoint is ${bytes} bytes, over --max-bytes ${options.maxBytes}.`);
306
+ console.error("Shorten fields or raise --max-bytes up to the 16000 hard limit only when recovery cost is acceptable.");
307
+ process.exit(1);
308
+ }
309
+
310
+ if (!options.dryRun) {
311
+ fs.writeFileSync(checkpointPath, checkpoint);
312
+ }
313
+
314
+ const result = {
315
+ ok: true,
316
+ dryRun: options.dryRun,
317
+ workspace,
318
+ sessionId: options.sessionId,
319
+ sessionDir,
320
+ checkpointPath,
321
+ activeSessionPath,
322
+ bytes,
323
+ maxBytes: options.maxBytes,
324
+ };
325
+
326
+ if (options.json) {
327
+ console.log(JSON.stringify(result, null, 2));
328
+ return;
329
+ }
330
+
331
+ console.log("Goalkeeper update-checkpoint: PASS");
332
+ console.log(`Workspace: ${workspace}`);
333
+ console.log(`Session: ${options.sessionId}`);
334
+ console.log(`Checkpoint: ${checkpointPath}`);
335
+ console.log(`Bytes: ${bytes}/${options.maxBytes}`);
336
+ if (options.dryRun) console.log("Dry run: yes");
337
+ }
338
+
339
+ main();
@@ -0,0 +1,48 @@
1
+ # Goalkeeper Guardrail
2
+
3
+ When this repository has an active `.goalkeeper/sessions/<goal-session-id>/` directory, treat it as the continuity source for long-running agent work.
4
+
5
+ At the start of each new assistant turn, before reading normal project files or making edits:
6
+
7
+ 1. Run `pwd` if the workspace is unclear.
8
+ 2. Locate the active checkpoint under `.goalkeeper/sessions/`.
9
+ 3. Read `.goalkeeper/sessions/<goal-session-id>/checkpoint.md`.
10
+ 4. If the checkpoint is unclear or too thin, read `.goalkeeper/sessions/<goal-session-id>/context-pack.md`.
11
+ 5. If exact evidence is needed, inspect recent `.goalkeeper/sessions/<goal-session-id>/events.jsonl` entries.
12
+
13
+ If this repository includes `scripts/goalkeeper-turn-start.mjs`, you may use:
14
+
15
+ ```bash
16
+ node scripts/goalkeeper-turn-start.mjs --session <goal-session-id>
17
+ ```
18
+
19
+ If `.goalkeeper/active-session` contains the current session id, this shorter form is also valid:
20
+
21
+ ```bash
22
+ node scripts/goalkeeper-turn-start.mjs
23
+ ```
24
+
25
+ If the helper comes from an installed skill path instead of this repository, pass the target workspace:
26
+
27
+ ```bash
28
+ node <skill-path>/scripts/goalkeeper-turn-start.mjs --workspace <workspace> --session <goal-session-id>
29
+ ```
30
+
31
+ If checkpoint recovery needs the larger context pack too, add `--context`.
32
+
33
+ Allowed before reading the checkpoint:
34
+
35
+ - `pwd`
36
+ - listing `.goalkeeper/sessions/`
37
+ - reading `.goalkeeper/active-session`
38
+ - minimal filename inspection needed to choose the active session
39
+ - running `node scripts/goalkeeper-turn-start.mjs --session <goal-session-id>`
40
+ - running `node scripts/goalkeeper-turn-start.mjs`
41
+ - running `node <skill-path>/scripts/goalkeeper-turn-start.mjs --workspace <workspace> --session <goal-session-id>`
42
+ - adding `--context` to the turn-start command when the checkpoint is too thin
43
+
44
+ Do not read project docs, source files, examples, tests, or make edits before the checkpoint read.
45
+
46
+ If you notice that you continued after compaction or resume without reading the checkpoint, stop, read it immediately, append a `recovery_violation` event, then continue from the recovered state.
47
+
48
+ Do not claim Goalkeeper reduces compaction frequency. Its purpose is direction recovery after compaction, resume, or handoff.
@@ -0,0 +1,48 @@
1
+ # Goalkeeper Guardrail
2
+
3
+ When this repository has an active `.goalkeeper/sessions/<goal-session-id>/` directory, treat it as the continuity source for long-running Claude Code or agent work.
4
+
5
+ At the start of each new assistant turn, before reading normal project files or making edits:
6
+
7
+ 1. Run `pwd` if the workspace is unclear.
8
+ 2. Locate the active checkpoint under `.goalkeeper/sessions/`.
9
+ 3. Read `.goalkeeper/sessions/<goal-session-id>/checkpoint.md`.
10
+ 4. If the checkpoint is unclear or too thin, read `.goalkeeper/sessions/<goal-session-id>/context-pack.md`.
11
+ 5. If exact evidence is needed, inspect recent `.goalkeeper/sessions/<goal-session-id>/events.jsonl` entries.
12
+
13
+ If this repository includes `scripts/goalkeeper-turn-start.mjs`, you may use:
14
+
15
+ ```bash
16
+ node scripts/goalkeeper-turn-start.mjs --session <goal-session-id>
17
+ ```
18
+
19
+ If `.goalkeeper/active-session` contains the current session id, this shorter form is also valid:
20
+
21
+ ```bash
22
+ node scripts/goalkeeper-turn-start.mjs
23
+ ```
24
+
25
+ If the helper comes from an installed skill path instead of this repository, pass the target workspace:
26
+
27
+ ```bash
28
+ node <skill-path>/scripts/goalkeeper-turn-start.mjs --workspace <workspace> --session <goal-session-id>
29
+ ```
30
+
31
+ If checkpoint recovery needs the larger context pack too, add `--context`.
32
+
33
+ Allowed before reading the checkpoint:
34
+
35
+ - `pwd`
36
+ - listing `.goalkeeper/sessions/`
37
+ - reading `.goalkeeper/active-session`
38
+ - minimal filename inspection needed to choose the active session
39
+ - running `node scripts/goalkeeper-turn-start.mjs --session <goal-session-id>`
40
+ - running `node scripts/goalkeeper-turn-start.mjs`
41
+ - running `node <skill-path>/scripts/goalkeeper-turn-start.mjs --workspace <workspace> --session <goal-session-id>`
42
+ - adding `--context` to the turn-start command when the checkpoint is too thin
43
+
44
+ Do not read project docs, source files, examples, tests, or make edits before the checkpoint read.
45
+
46
+ If you notice that you continued after compaction or resume without reading the checkpoint, stop, read it immediately, append a `recovery_violation` event, then continue from the recovered state.
47
+
48
+ Do not claim Goalkeeper reduces compaction frequency. Its purpose is direction recovery after compaction, resume, or handoff.
@@ -0,0 +1 @@
1
+ <goal-session-id>
@@ -0,0 +1,54 @@
1
+ # Goalkeeper Checkpoint
2
+
3
+ ## Active Goal
4
+
5
+ - Objective:
6
+ - Done criteria:
7
+ - Current status:
8
+
9
+ ## Throughline
10
+
11
+ - Current direction:
12
+ - Why this direction:
13
+
14
+ ## Constraints
15
+
16
+ - Non-negotiable:
17
+ - Forbidden approaches:
18
+
19
+ ## Decisions
20
+
21
+ -
22
+
23
+ ## Attempts And Failures
24
+
25
+ -
26
+
27
+ ## Important Files
28
+
29
+ -
30
+
31
+ ## Evidence
32
+
33
+ -
34
+
35
+ ## Context Pack
36
+
37
+ -
38
+
39
+ ## Verification
40
+
41
+ - Verified:
42
+ - Not yet verified:
43
+
44
+ ## Open Risks
45
+
46
+ -
47
+
48
+ ## Next Action
49
+
50
+ -
51
+
52
+ ## Last Updated
53
+
54
+ -
@@ -0,0 +1,45 @@
1
+ # Goalkeeper Context Pack
2
+
3
+ ## Purpose
4
+
5
+ Preserve medium-density context that is too detailed for `checkpoint.md` but too important to leave only in compacted conversation history.
6
+
7
+ Read this when:
8
+
9
+ - `checkpoint.md` is too thin to explain why the current direction exists
10
+ - a long-running goal resumes after many turns or a long gap
11
+ - the agent is about to change direction and needs the decision chain
12
+
13
+ ## Active Goal
14
+
15
+ -
16
+
17
+ ## Durable Constraints
18
+
19
+ -
20
+
21
+ ## Working Model
22
+
23
+ -
24
+
25
+ ## Decision Chain
26
+
27
+ -
28
+
29
+ ## Rejected Alternatives
30
+
31
+ -
32
+
33
+ ## Open Threads
34
+
35
+ -
36
+
37
+ ## Evidence Index
38
+
39
+ - Atomic events: `.goalkeeper/sessions/<goal-session-id>/events.jsonl`
40
+
41
+ ## Maintenance Notes
42
+
43
+ - Keep `checkpoint.md` short enough to read every turn.
44
+ - Use this file for the larger explanation that helps reconstruct pre-compaction reasoning.
45
+ - Do not paste raw transcripts or long command output here.
@@ -0,0 +1,3 @@
1
+ {"ts":"2026-05-17T00:00:00Z","type":"goal","text":"Define the active long-running agent goal.","status":"open"}
2
+ {"ts":"2026-05-17T00:00:00Z","type":"user_constraint","text":"Record durable user constraints that must survive compaction."}
3
+ {"ts":"2026-05-17T00:00:00Z","type":"decision","text":"Record the current direction and why it was chosen."}