@deltafleet/codex-goalkeeper 0.1.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.
- package/CHANGELOG.md +15 -0
- package/CODE_OF_CONDUCT.md +13 -0
- package/CONTRIBUTING.md +57 -0
- package/LICENSE +22 -0
- package/README.ja.md +197 -0
- package/README.ko.md +197 -0
- package/README.md +223 -0
- package/README.zh-CN.md +197 -0
- package/SECURITY.md +32 -0
- package/SKILL.md +164 -0
- package/agents/openai.yaml +5 -0
- package/docs/RELEASE.md +77 -0
- package/docs/ROADMAP.md +77 -0
- package/examples/goalkeeper-session/checkpoint.md +58 -0
- package/examples/goalkeeper-session/context-pack.md +39 -0
- package/examples/goalkeeper-session/events.jsonl +5 -0
- package/package.json +60 -0
- package/src/references/event-schema.md +63 -0
- package/src/references/guardrail.md +62 -0
- package/src/references/workflow.md +187 -0
- package/src/scripts/goalkeeper-append-event.mjs +263 -0
- package/src/scripts/goalkeeper-doctor.mjs +476 -0
- package/src/scripts/goalkeeper-init.mjs +271 -0
- package/src/scripts/goalkeeper-turn-start.mjs +166 -0
- package/src/scripts/goalkeeper-update-checkpoint.mjs +339 -0
- package/src/scripts/test-goalkeeper-update-checkpoint.mjs +236 -0
- package/src/templates/AGENTS.goalkeeper.md +48 -0
- package/src/templates/active-session +1 -0
- package/src/templates/checkpoint.md +54 -0
- package/src/templates/context-pack.md +45 -0
- package/src/templates/event.jsonl +3 -0
|
@@ -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 src/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,236 @@
|
|
|
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 SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
|
10
|
+
const TMP_ROOT = path.join(REPO_ROOT, ".goalkeeper", "tmp", "checkpoint-update");
|
|
11
|
+
const WORKSPACE = path.join(TMP_ROOT, "workspace");
|
|
12
|
+
const SESSION_ID = "checkpoint-update-poc";
|
|
13
|
+
|
|
14
|
+
function run(args, options = {}) {
|
|
15
|
+
const result = spawnSync(process.execPath, args, {
|
|
16
|
+
cwd: REPO_ROOT,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
...options,
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
...result,
|
|
22
|
+
stdout: result.stdout.trim(),
|
|
23
|
+
stderr: result.stderr.trim(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function script(name) {
|
|
28
|
+
return path.join(SCRIPT_DIR, name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assert(condition, message) {
|
|
32
|
+
if (!condition) {
|
|
33
|
+
throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setupWorkspace() {
|
|
38
|
+
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
39
|
+
fs.mkdirSync(WORKSPACE, { recursive: true });
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(WORKSPACE, "AGENTS.md"),
|
|
42
|
+
[
|
|
43
|
+
"# Goalkeeper Guardrail",
|
|
44
|
+
"",
|
|
45
|
+
"At the start of each new assistant turn, before source work, read .goalkeeper/sessions/<goal-session-id>/checkpoint.md.",
|
|
46
|
+
"This checkpoint-first rule applies after compaction, compact, start, resume, and before normal project work.",
|
|
47
|
+
"",
|
|
48
|
+
].join("\n"),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function main() {
|
|
53
|
+
setupWorkspace();
|
|
54
|
+
|
|
55
|
+
const init = run([
|
|
56
|
+
script("goalkeeper-init.mjs"),
|
|
57
|
+
"--workspace",
|
|
58
|
+
WORKSPACE,
|
|
59
|
+
"--session",
|
|
60
|
+
SESSION_ID,
|
|
61
|
+
"--goal",
|
|
62
|
+
"Validate canonical checkpoint updates.",
|
|
63
|
+
"--constraint",
|
|
64
|
+
"Keep checkpoints short.",
|
|
65
|
+
"--json",
|
|
66
|
+
]);
|
|
67
|
+
assert(init.status === 0, `init failed:\n${init.stderr}\n${init.stdout}`);
|
|
68
|
+
|
|
69
|
+
const append = run([
|
|
70
|
+
script("goalkeeper-append-event.mjs"),
|
|
71
|
+
"--workspace",
|
|
72
|
+
WORKSPACE,
|
|
73
|
+
"--type",
|
|
74
|
+
"decision",
|
|
75
|
+
"--text",
|
|
76
|
+
"Use the update helper to rewrite checkpoint.md canonically.",
|
|
77
|
+
"--json",
|
|
78
|
+
]);
|
|
79
|
+
assert(append.status === 0, `append failed:\n${append.stderr}\n${append.stdout}`);
|
|
80
|
+
const parsedAppend = JSON.parse(append.stdout);
|
|
81
|
+
assert(parsedAppend.sessionId === SESSION_ID, "append helper should recover the session id from active-session");
|
|
82
|
+
assert(parsedAppend.lineNumber === 4, "append helper should report the appended JSONL line number");
|
|
83
|
+
|
|
84
|
+
const contextPackPath = path.join(WORKSPACE, ".goalkeeper", "sessions", SESSION_ID, "context-pack.md");
|
|
85
|
+
assert(fs.existsSync(contextPackPath), "init should create context-pack.md");
|
|
86
|
+
|
|
87
|
+
const turnStartWithContext = run([
|
|
88
|
+
script("goalkeeper-turn-start.mjs"),
|
|
89
|
+
"--workspace",
|
|
90
|
+
WORKSPACE,
|
|
91
|
+
"--context",
|
|
92
|
+
"--json",
|
|
93
|
+
]);
|
|
94
|
+
assert(turnStartWithContext.status === 0, `turn-start --context failed:\n${turnStartWithContext.stderr}\n${turnStartWithContext.stdout}`);
|
|
95
|
+
const parsedTurnStart = JSON.parse(turnStartWithContext.stdout);
|
|
96
|
+
assert(parsedTurnStart.contextPackPath === contextPackPath, "turn-start should report context-pack.md path");
|
|
97
|
+
assert(parsedTurnStart.contextPack.includes("Context Pack"), "turn-start --context should include context pack content");
|
|
98
|
+
|
|
99
|
+
const noActiveWorkspace = path.join(TMP_ROOT, "no-active-workspace");
|
|
100
|
+
fs.mkdirSync(path.join(noActiveWorkspace, ".goalkeeper", "sessions", SESSION_ID), { recursive: true });
|
|
101
|
+
fs.writeFileSync(path.join(noActiveWorkspace, ".goalkeeper", "sessions", SESSION_ID, "events.jsonl"), "");
|
|
102
|
+
const appendWithoutActive = run([
|
|
103
|
+
script("goalkeeper-append-event.mjs"),
|
|
104
|
+
"--workspace",
|
|
105
|
+
noActiveWorkspace,
|
|
106
|
+
"--type",
|
|
107
|
+
"decision",
|
|
108
|
+
"--text",
|
|
109
|
+
"This should fail because no active-session pointer exists.",
|
|
110
|
+
"--json",
|
|
111
|
+
]);
|
|
112
|
+
assert(appendWithoutActive.status === 1, "append without --session should fail when active-session is missing");
|
|
113
|
+
|
|
114
|
+
const schemaInvalidWorkspace = path.join(TMP_ROOT, "schema-invalid-workspace");
|
|
115
|
+
const schemaInvalidSessionDir = path.join(schemaInvalidWorkspace, ".goalkeeper", "sessions", SESSION_ID);
|
|
116
|
+
fs.mkdirSync(schemaInvalidSessionDir, { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(schemaInvalidWorkspace, ".goalkeeper", "active-session"), `${SESSION_ID}\n`);
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(schemaInvalidSessionDir, "events.jsonl"),
|
|
120
|
+
`${JSON.stringify({ ts: "2026-05-18T00:00:00Z", type: "unknown", text: "bad existing event" })}\n`,
|
|
121
|
+
);
|
|
122
|
+
const appendToSchemaInvalid = run([
|
|
123
|
+
script("goalkeeper-append-event.mjs"),
|
|
124
|
+
"--workspace",
|
|
125
|
+
schemaInvalidWorkspace,
|
|
126
|
+
"--type",
|
|
127
|
+
"decision",
|
|
128
|
+
"--text",
|
|
129
|
+
"This should fail because the existing event log is schema-invalid.",
|
|
130
|
+
"--json",
|
|
131
|
+
]);
|
|
132
|
+
assert(appendToSchemaInvalid.status === 1, "append should refuse an existing schema-invalid event log");
|
|
133
|
+
|
|
134
|
+
const update = run([
|
|
135
|
+
script("goalkeeper-update-checkpoint.mjs"),
|
|
136
|
+
"--workspace",
|
|
137
|
+
WORKSPACE,
|
|
138
|
+
"--goal",
|
|
139
|
+
"Validate canonical checkpoint updates.",
|
|
140
|
+
"--done",
|
|
141
|
+
"Doctor passes after a helper-rendered checkpoint.",
|
|
142
|
+
"--status",
|
|
143
|
+
"Helper under test.",
|
|
144
|
+
"--throughline",
|
|
145
|
+
"Use deterministic CLI rendering instead of manual checkpoint Markdown.",
|
|
146
|
+
"--why",
|
|
147
|
+
"Long sessions need bounded state that can be safely refreshed after compacted turns.",
|
|
148
|
+
"--constraint",
|
|
149
|
+
"Keep checkpoint under the routine-read budget.",
|
|
150
|
+
"--forbidden",
|
|
151
|
+
"Do not paste long command output into checkpoint.md.",
|
|
152
|
+
"--decision",
|
|
153
|
+
"Render a canonical checkpoint from CLI fields.",
|
|
154
|
+
"--attempt",
|
|
155
|
+
"Manual checkpoint edits remain possible but are not the default path.",
|
|
156
|
+
"--file",
|
|
157
|
+
"src/scripts/goalkeeper-update-checkpoint.mjs",
|
|
158
|
+
"--file",
|
|
159
|
+
"docs/path with spaces.md",
|
|
160
|
+
"--evidence",
|
|
161
|
+
"This test rewrites checkpoint.md in a guarded temporary workspace.",
|
|
162
|
+
"--verified",
|
|
163
|
+
"Update helper exits 0.",
|
|
164
|
+
"--unverified",
|
|
165
|
+
"No real compact boundary is generated by this unit test.",
|
|
166
|
+
"--risk",
|
|
167
|
+
"Overlong input should fail before writing.",
|
|
168
|
+
"--next",
|
|
169
|
+
"Run strict doctor against the updated temporary workspace.",
|
|
170
|
+
"--json",
|
|
171
|
+
]);
|
|
172
|
+
assert(update.status === 0, `update failed:\n${update.stderr}\n${update.stdout}`);
|
|
173
|
+
|
|
174
|
+
const parsedUpdate = JSON.parse(update.stdout);
|
|
175
|
+
assert(parsedUpdate.bytes > 0 && parsedUpdate.bytes <= 8_000, "updated checkpoint should fit the default budget");
|
|
176
|
+
|
|
177
|
+
const checkpointPath = path.join(WORKSPACE, ".goalkeeper", "sessions", SESSION_ID, "checkpoint.md");
|
|
178
|
+
const checkpoint = fs.readFileSync(checkpointPath, "utf8");
|
|
179
|
+
assert(checkpoint.includes("## Active Goal"), "checkpoint should include Active Goal section");
|
|
180
|
+
assert(checkpoint.includes("## Context Pack"), "checkpoint should include Context Pack section");
|
|
181
|
+
assert(checkpoint.includes("## Next Action"), "checkpoint should include Next Action section");
|
|
182
|
+
assert(checkpoint.includes("src/scripts/goalkeeper-update-checkpoint.mjs"), "checkpoint should include important files");
|
|
183
|
+
assert(checkpoint.includes("docs/path with spaces.md"), "checkpoint should preserve spaces in file paths");
|
|
184
|
+
|
|
185
|
+
const beforeOversize = checkpoint;
|
|
186
|
+
const oversize = run([
|
|
187
|
+
script("goalkeeper-update-checkpoint.mjs"),
|
|
188
|
+
"--workspace",
|
|
189
|
+
WORKSPACE,
|
|
190
|
+
"--goal",
|
|
191
|
+
"Validate oversize refusal.",
|
|
192
|
+
"--decision",
|
|
193
|
+
"x".repeat(2_000),
|
|
194
|
+
"--next",
|
|
195
|
+
"This should not be written.",
|
|
196
|
+
"--max-bytes",
|
|
197
|
+
"1000",
|
|
198
|
+
"--json",
|
|
199
|
+
]);
|
|
200
|
+
assert(oversize.status === 1, "oversize checkpoint update should exit 1");
|
|
201
|
+
assert(fs.readFileSync(checkpointPath, "utf8") === beforeOversize, "oversize failure should not rewrite checkpoint.md");
|
|
202
|
+
|
|
203
|
+
const doctor = run([
|
|
204
|
+
script("goalkeeper-doctor.mjs"),
|
|
205
|
+
"--workspace",
|
|
206
|
+
WORKSPACE,
|
|
207
|
+
"--session",
|
|
208
|
+
SESSION_ID,
|
|
209
|
+
"--strict",
|
|
210
|
+
"--json",
|
|
211
|
+
]);
|
|
212
|
+
assert(doctor.status === 0, `doctor failed:\n${doctor.stderr}\n${doctor.stdout}`);
|
|
213
|
+
const parsedDoctor = JSON.parse(doctor.stdout);
|
|
214
|
+
assert(parsedDoctor.ok === true, "doctor JSON should be ok");
|
|
215
|
+
|
|
216
|
+
console.log(
|
|
217
|
+
JSON.stringify(
|
|
218
|
+
{
|
|
219
|
+
ok: true,
|
|
220
|
+
workspace: WORKSPACE,
|
|
221
|
+
sessionId: SESSION_ID,
|
|
222
|
+
checkpointBytes: parsedUpdate.bytes,
|
|
223
|
+
doctor: parsedDoctor.summary,
|
|
224
|
+
},
|
|
225
|
+
null,
|
|
226
|
+
2,
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
main();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(error.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
@@ -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 Codex 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 `src/scripts/goalkeeper-turn-start.mjs`, you may use:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
node src/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 src/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>/src/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 src/scripts/goalkeeper-turn-start.mjs --session <goal-session-id>`
|
|
40
|
+
- running `node src/scripts/goalkeeper-turn-start.mjs`
|
|
41
|
+
- running `node <skill-path>/src/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
|
+
-
|