@crewhaus/continuity-store 0.3.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/dist/index.js ADDED
@@ -0,0 +1,765 @@
1
+ /**
2
+ * v0.3.0 Goal 1 — `continuity-store` (design §2.2–§2.7, PR 7).
3
+ *
4
+ * Human-readable, user-clearable continuity artifacts under
5
+ * `.crewhaus/state/<specName>/`:
6
+ *
7
+ * focus.md — marker-gated (`<!-- crewhaus:focus -->`),
8
+ * focus body capped at `focusMaxChars`, plus the
9
+ * REQ-nnn requirements ledger and the active
10
+ * plan pointer
11
+ * plans/plan-NNNN-<slug>.md — YAML frontmatter + numbered steps carrying the
12
+ * open → in_progress → claimed → proven ladder
13
+ * goals.yaml — {id, title, status, target?, current?, unit?}
14
+ * handoff.md — deterministic teardown render (NO model call
15
+ * anywhere in this package)
16
+ * .lock — advisory single-writer lock (see lock.ts)
17
+ *
18
+ * Why files + markers: matches project-memory's LESSONS.md precedent — a
19
+ * user-authored focus.md without the marker is NEVER overwritten (and never
20
+ * injected), so crewhaus-managed state cannot hijack a human file.
21
+ *
22
+ * Proof ladder (§2.4): `claimed` is always free; `proven` is machine-checked
23
+ * against the append-only session event logs (including sub-agent child
24
+ * sessions via the `sub_agent_start` brackets). On a proven transition the
25
+ * store (a) pins the cited session in `.crewhaus/retention.json` and (b)
26
+ * freezes a `{toolName, inputHash, resultDigest}` proof excerpt into the
27
+ * plan/goal record, so evidence outlives the transcript TTL.
28
+ *
29
+ * Clearing (§2.6): `clear()` moves files to `.crewhaus/trash/<ts>/…` — never
30
+ * hard-deletes — and `restore(ts)` undoes it. `moveToTrash` is exported for
31
+ * other stores to adopt.
32
+ *
33
+ * Scoping (§2.7): one store per spec by default; `scope: {kind: "session"}`
34
+ * nests under `<spec>/sessions/<sessionId>/` for per-conversation state
35
+ * (channel daemons). Tenant contexts use the same fail-closed path fencing
36
+ * session-store enforces (CWE-1230): with a tenant present, any resolved
37
+ * path outside the tenant's root throws.
38
+ */
39
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { CrewhausError } from "@crewhaus/errors";
42
+ import { assertSamePath, currentTenantContext } from "@crewhaus/tenancy";
43
+ import YAML from "yaml";
44
+ import { appendRetentionPins, verifyEvidence, } from "./evidence";
45
+ import { HANDOFF_MARKER, renderHandoff } from "./handoff";
46
+ import { withLock } from "./lock";
47
+ import { listTrash as listTrashDir, moveToTrash, restoreFromTrash, } from "./trash";
48
+ export { ContinuityLockError, DEFAULT_LOCK_POLICY, acquireLock, withLock, } from "./lock";
49
+ export { TRASH_DIR_NAME, TRASH_PURGE_AFTER_MS, TrashError, listTrash, moveToTrash, parseTrashTimestamp, purgeTrash, restoreFromTrash, } from "./trash";
50
+ export { DEFAULT_SESSION_ROOT_DIR, EvidenceError, appendRetentionPins, resolveEvidence, verifyEvidence, } from "./evidence";
51
+ export { HANDOFF_MARKER, renderHandoff, renderStatus } from "./handoff";
52
+ export const DEFAULT_ROOT_DIR = ".crewhaus/state";
53
+ export const DEFAULT_FOCUS_MAX_CHARS = 4096;
54
+ /** §2.3 ledger cap: oldest-first eviction with a `[ledger truncated]` marker. */
55
+ export const REQUIREMENTS_LEDGER_MAX_BYTES = 16_384;
56
+ export const FOCUS_MARKER = "<!-- crewhaus:focus -->";
57
+ const ACTIVE_PLAN_MARKER = "<!-- crewhaus:active-plan -->";
58
+ const REQUIREMENTS_MARKER = "<!-- crewhaus:requirements -->";
59
+ const LEDGER_TRUNCATED_LINE = "[ledger truncated]";
60
+ const NONE_LINE = "_none_";
61
+ const SPEC_NAME_REGEX = /^[a-zA-Z0-9_\-.]+$/;
62
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
63
+ const PLAN_ID_REGEX = /^plan-\d{4}$/;
64
+ const GOAL_ID_REGEX = /^goal-\d{4}$/;
65
+ const REQ_ID_REGEX = /^REQ-\d{3,}$/;
66
+ const REQ_LINE_REGEX = /^- (REQ-\d{3,}) \[(open|confirmed|dropped)\] (".*") \(user, (sess_[0-9a-f]{16}), turn (\d+)\)$/;
67
+ const STEP_LINE_REGEX = /^(\d+)\. \[(open|in_progress|claimed|proven)\] (.+)$/;
68
+ export class ContinuityStoreError extends CrewhausError {
69
+ name = "ContinuityStoreError";
70
+ constructor(message, cause) {
71
+ super("config", message, cause);
72
+ }
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // focus.md rendering + parsing
76
+ // ---------------------------------------------------------------------------
77
+ function renderRequirementLine(req) {
78
+ return `- ${req.id} [${req.status}] ${JSON.stringify(req.text)} (user, ${req.source.sessionId}, turn ${req.source.turn})`;
79
+ }
80
+ function renderFocusFile(state) {
81
+ const reqLines = state.requirements.map(renderRequirementLine);
82
+ const ledgerLines = reqLines.length > 0
83
+ ? state.ledgerTruncated
84
+ ? [LEDGER_TRUNCATED_LINE, ...reqLines]
85
+ : reqLines
86
+ : [NONE_LINE];
87
+ return [
88
+ FOCUS_MARKER,
89
+ "# Focus",
90
+ "",
91
+ state.body,
92
+ "",
93
+ "## Active plan",
94
+ ACTIVE_PLAN_MARKER,
95
+ state.activePlanId ?? NONE_LINE,
96
+ "",
97
+ "## Requirements",
98
+ REQUIREMENTS_MARKER,
99
+ ...ledgerLines,
100
+ "",
101
+ ].join("\n");
102
+ }
103
+ function parseFocusFile(raw) {
104
+ if (!raw.trimStart().startsWith(FOCUS_MARKER))
105
+ return null;
106
+ const activeHeader = `## Active plan\n${ACTIVE_PLAN_MARKER}`;
107
+ const reqHeader = `## Requirements\n${REQUIREMENTS_MARKER}`;
108
+ const activeIdx = raw.indexOf(activeHeader);
109
+ const reqIdx = raw.indexOf(reqHeader);
110
+ const afterMarker = raw.indexOf(FOCUS_MARKER) + FOCUS_MARKER.length;
111
+ const bodyEnd = activeIdx >= 0 ? activeIdx : reqIdx >= 0 ? reqIdx : raw.length;
112
+ let body = raw.slice(afterMarker, bodyEnd);
113
+ body = body.replace(/^\s*# Focus\s*\n/, "").trim();
114
+ let activePlanId = null;
115
+ if (activeIdx >= 0) {
116
+ const sectionStart = activeIdx + activeHeader.length;
117
+ const sectionEnd = reqIdx >= 0 ? reqIdx : raw.length;
118
+ const line = raw.slice(sectionStart, sectionEnd).trim().split("\n")[0]?.trim() ?? "";
119
+ if (PLAN_ID_REGEX.test(line))
120
+ activePlanId = line;
121
+ }
122
+ const requirements = [];
123
+ let ledgerTruncated = false;
124
+ if (reqIdx >= 0) {
125
+ for (const line of raw
126
+ .slice(reqIdx + reqHeader.length)
127
+ .split("\n")
128
+ .map((l) => l.trim())) {
129
+ if (line === LEDGER_TRUNCATED_LINE) {
130
+ ledgerTruncated = true;
131
+ continue;
132
+ }
133
+ const m = REQ_LINE_REGEX.exec(line);
134
+ if (m === null)
135
+ continue;
136
+ let text;
137
+ try {
138
+ text = JSON.parse(m[3]);
139
+ }
140
+ catch {
141
+ continue; // A hand-mangled line — skip rather than mis-attribute.
142
+ }
143
+ requirements.push({
144
+ id: m[1],
145
+ status: m[2],
146
+ text,
147
+ source: { sessionId: m[4], turn: Number(m[5]) },
148
+ });
149
+ }
150
+ }
151
+ return { body, activePlanId, requirements, ledgerTruncated };
152
+ }
153
+ function slugify(title) {
154
+ const slug = title
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9]+/g, "-")
157
+ .replace(/^-+|-+$/g, "")
158
+ .slice(0, 40)
159
+ .replace(/-+$/g, "");
160
+ return slug !== "" ? slug : "plan";
161
+ }
162
+ function renderPlanFile(plan) {
163
+ const proofs = {};
164
+ for (const step of plan.steps) {
165
+ if (step.proofs.length > 0)
166
+ proofs[String(step.index)] = [...step.proofs];
167
+ }
168
+ const frontmatter = {
169
+ id: plan.id,
170
+ slug: plan.slug,
171
+ title: plan.title,
172
+ createdAt: plan.createdAt,
173
+ updatedAt: plan.updatedAt,
174
+ ...(Object.keys(proofs).length > 0 ? { proofs } : {}),
175
+ };
176
+ const stepLines = plan.steps.map((s) => `${s.index}. [${s.status}] ${s.text}`);
177
+ return [
178
+ "---",
179
+ YAML.stringify(frontmatter).trimEnd(),
180
+ "---",
181
+ "",
182
+ `# ${plan.title}`,
183
+ "",
184
+ "## Steps",
185
+ "",
186
+ ...(stepLines.length > 0 ? stepLines : ["_no steps yet_"]),
187
+ "",
188
+ ].join("\n");
189
+ }
190
+ function isFrozenProofShape(value) {
191
+ if (typeof value !== "object" || value === null)
192
+ return false;
193
+ const v = value;
194
+ return (typeof v["toolUseId"] === "string" &&
195
+ typeof v["sessionId"] === "string" &&
196
+ typeof v["toolName"] === "string" &&
197
+ typeof v["inputHash"] === "string" &&
198
+ typeof v["resultDigest"] === "string" &&
199
+ typeof v["verifiedAt"] === "string");
200
+ }
201
+ function parsePlanFile(raw, path) {
202
+ const fmMatch = /^---\n([\s\S]*?)\n---\n?/.exec(raw);
203
+ if (fmMatch === null) {
204
+ throw new ContinuityStoreError(`continuity-store: ${path} has no YAML frontmatter`);
205
+ }
206
+ let fm;
207
+ try {
208
+ fm = YAML.parse(fmMatch[1]);
209
+ }
210
+ catch (err) {
211
+ throw new ContinuityStoreError(`continuity-store: ${path} has malformed frontmatter`, err);
212
+ }
213
+ if (typeof fm !== "object" || fm === null) {
214
+ throw new ContinuityStoreError(`continuity-store: ${path} frontmatter is not a mapping`);
215
+ }
216
+ const f = fm;
217
+ const id = typeof f["id"] === "string" ? f["id"] : "";
218
+ if (!PLAN_ID_REGEX.test(id)) {
219
+ throw new ContinuityStoreError(`continuity-store: ${path} has an invalid plan id "${id}"`);
220
+ }
221
+ const proofsByStep = new Map();
222
+ if (typeof f["proofs"] === "object" && f["proofs"] !== null) {
223
+ for (const [key, value] of Object.entries(f["proofs"])) {
224
+ const index = Number(key);
225
+ if (!Number.isInteger(index) || !Array.isArray(value))
226
+ continue;
227
+ proofsByStep.set(index, value.filter(isFrozenProofShape));
228
+ }
229
+ }
230
+ const body = raw.slice(fmMatch[0].length);
231
+ const steps = [];
232
+ for (const line of body.split("\n")) {
233
+ const m = STEP_LINE_REGEX.exec(line.trim());
234
+ if (m === null)
235
+ continue;
236
+ const index = steps.length + 1;
237
+ steps.push({
238
+ index,
239
+ status: m[2],
240
+ text: m[3].trim(),
241
+ proofs: proofsByStep.get(index) ?? [],
242
+ });
243
+ }
244
+ return {
245
+ id,
246
+ slug: typeof f["slug"] === "string" ? f["slug"] : slugify(String(f["title"] ?? "")),
247
+ title: typeof f["title"] === "string" ? f["title"] : id,
248
+ createdAt: typeof f["createdAt"] === "string" ? f["createdAt"] : "",
249
+ updatedAt: typeof f["updatedAt"] === "string" ? f["updatedAt"] : "",
250
+ steps,
251
+ };
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // goals.yaml
255
+ // ---------------------------------------------------------------------------
256
+ function isGoalShape(value) {
257
+ if (typeof value !== "object" || value === null)
258
+ return false;
259
+ const v = value;
260
+ return (typeof v["id"] === "string" &&
261
+ typeof v["title"] === "string" &&
262
+ typeof v["status"] === "string" &&
263
+ ["open", "in_progress", "claimed", "proven"].includes(v["status"]));
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // the store
267
+ // ---------------------------------------------------------------------------
268
+ /**
269
+ * Construct a continuity store. Lazy: directories and files are created on
270
+ * the first write. Reads never take the lock; every mutation runs under the
271
+ * advisory `.lock` (§7.6) and lands via tmp+rename.
272
+ */
273
+ export function createContinuityStore(opts) {
274
+ if (!opts.specName) {
275
+ throw new ContinuityStoreError("specName is required");
276
+ }
277
+ if (!SPEC_NAME_REGEX.test(opts.specName) || /^\.+$/.test(opts.specName)) {
278
+ throw new ContinuityStoreError(`invalid specName "${opts.specName}" — must match [a-zA-Z0-9_\\-.]+ and not be dots only`);
279
+ }
280
+ const scope = opts.scope ?? { kind: "spec" };
281
+ if (scope.kind === "session" && !SESSION_ID_REGEX.test(scope.sessionId)) {
282
+ throw new ContinuityStoreError(`invalid scope.sessionId "${scope.sessionId}" — expected sess_<16 hex>`);
283
+ }
284
+ const now = opts.now ?? (() => new Date());
285
+ const focusMaxChars = opts.focusMaxChars ?? DEFAULT_FOCUS_MAX_CHARS;
286
+ // Tenant fencing (§2.7): honor BOTH an explicit `tenant` option and the
287
+ // ambient AsyncLocalStorage context, exactly like session-store — with a
288
+ // tenant present, any resolved path outside the tenant's root fails closed
289
+ // (CWE-1230). The fence root is the tenant's directory (the parent of its
290
+ // sessionRoot), because state, trash, retention.json, and sessions all
291
+ // live as siblings under it.
292
+ function tenantRootOf(tenant) {
293
+ return resolve(tenant.sessionRoot, "..");
294
+ }
295
+ function fence(absPath) {
296
+ if (opts.tenant !== undefined)
297
+ assertSamePath(absPath, tenantRootOf(opts.tenant));
298
+ const ctx = currentTenantContext();
299
+ if (ctx !== undefined)
300
+ assertSamePath(absPath, tenantRootOf(ctx.tenant));
301
+ return absPath;
302
+ }
303
+ const constructionTenant = opts.tenant ?? currentTenantContext()?.tenant;
304
+ const rootDir = resolve(opts.rootDir ??
305
+ (constructionTenant !== undefined
306
+ ? join(tenantRootOf(constructionTenant), "state")
307
+ : DEFAULT_ROOT_DIR));
308
+ const crewhausDir = resolve(rootDir, "..");
309
+ const storeDir = scope.kind === "session"
310
+ ? join(rootDir, opts.specName, "sessions", scope.sessionId)
311
+ : join(rootDir, opts.specName);
312
+ const plansDir = join(storeDir, "plans");
313
+ const focusPath = join(storeDir, "focus.md");
314
+ const goalsPath = join(storeDir, "goals.yaml");
315
+ const handoffPath = join(storeDir, "handoff.md");
316
+ const lockPath = join(storeDir, ".lock");
317
+ const retentionPath = join(crewhausDir, "retention.json");
318
+ const sessionRootDir = resolve(opts.sessionRootDir ?? join(crewhausDir, "sessions"));
319
+ // Fail closed at construction, not just on first I/O.
320
+ fence(storeDir);
321
+ async function writeAtomic(path, content) {
322
+ fence(path);
323
+ await mkdir(dirname(path), { recursive: true });
324
+ const tmpPath = `${path}.tmp`;
325
+ await writeFile(tmpPath, content, { mode: 0o600 });
326
+ await rename(tmpPath, path);
327
+ }
328
+ async function readText(path) {
329
+ fence(path);
330
+ try {
331
+ return await readFile(path, "utf8");
332
+ }
333
+ catch (err) {
334
+ if (err.code === "ENOENT")
335
+ return null;
336
+ throw err;
337
+ }
338
+ }
339
+ function locked(fn) {
340
+ return withLock(fence(lockPath), fn, opts.lock ?? {});
341
+ }
342
+ // ---- focus internals ----
343
+ const emptyFocus = {
344
+ body: "",
345
+ activePlanId: null,
346
+ requirements: [],
347
+ ledgerTruncated: false,
348
+ };
349
+ async function readFocusState() {
350
+ const raw = await readText(focusPath);
351
+ if (raw === null)
352
+ return null;
353
+ return parseFocusFile(raw);
354
+ }
355
+ async function requireWritableFocus() {
356
+ const raw = await readText(focusPath);
357
+ if (raw === null)
358
+ return emptyFocus;
359
+ const parsed = parseFocusFile(raw);
360
+ if (parsed === null) {
361
+ throw new ContinuityStoreError(`continuity-store: ${focusPath} exists but is missing the "${FOCUS_MARKER}" marker — refusing to overwrite a user-authored file. Move it aside (or add the marker) to let crewhaus manage it.`);
362
+ }
363
+ return parsed;
364
+ }
365
+ function capLedger(requirements) {
366
+ const kept = [...requirements];
367
+ let truncated = false;
368
+ const bytes = () => Buffer.byteLength(kept.map(renderRequirementLine).join("\n"), "utf8");
369
+ while (kept.length > 1 && bytes() > REQUIREMENTS_LEDGER_MAX_BYTES) {
370
+ kept.shift(); // oldest-first eviction (§2.3)
371
+ truncated = true;
372
+ }
373
+ return { requirements: kept, truncated };
374
+ }
375
+ async function writeFocusState(state) {
376
+ await writeAtomic(focusPath, renderFocusFile(state));
377
+ }
378
+ // ---- plan internals ----
379
+ async function planFiles() {
380
+ fence(plansDir);
381
+ let entries;
382
+ try {
383
+ entries = await readdir(plansDir);
384
+ }
385
+ catch (err) {
386
+ if (err.code === "ENOENT")
387
+ return [];
388
+ throw err;
389
+ }
390
+ return entries.filter((e) => /^plan-\d{4}-.*\.md$/.test(e)).sort();
391
+ }
392
+ async function readPlans() {
393
+ const files = await planFiles();
394
+ const plans = [];
395
+ for (const file of files) {
396
+ const raw = await readText(join(plansDir, file));
397
+ if (raw === null)
398
+ continue;
399
+ plans.push(parsePlanFile(raw, join(plansDir, file)));
400
+ }
401
+ return plans;
402
+ }
403
+ async function planPathFor(planId) {
404
+ const files = await planFiles();
405
+ const match = files.find((f) => f.startsWith(`${planId}-`));
406
+ return match !== undefined ? join(plansDir, match) : null;
407
+ }
408
+ async function readPlan(planId) {
409
+ if (!PLAN_ID_REGEX.test(planId)) {
410
+ throw new ContinuityStoreError(`invalid planId "${planId}" — expected plan-NNNN`);
411
+ }
412
+ const path = await planPathFor(planId);
413
+ if (path === null)
414
+ return null;
415
+ const raw = await readText(path);
416
+ if (raw === null)
417
+ return null;
418
+ return { plan: parsePlanFile(raw, path), path };
419
+ }
420
+ async function requirePlan(planId) {
421
+ const found = await readPlan(planId);
422
+ if (found === null) {
423
+ throw new ContinuityStoreError(`continuity-store: no plan "${planId}" — create one first (PlanUpdate {action: "create"}).`);
424
+ }
425
+ return found;
426
+ }
427
+ function requireStep(plan, step) {
428
+ const found = plan.steps.find((s) => s.index === step);
429
+ if (found === undefined) {
430
+ throw new ContinuityStoreError(`continuity-store: ${plan.id} has no step ${step} (steps 1–${plan.steps.length}).`);
431
+ }
432
+ return found;
433
+ }
434
+ async function writePlan(plan) {
435
+ const path = (await planPathFor(plan.id)) ?? join(plansDir, `${plan.id}-${plan.slug}.md`);
436
+ await writeAtomic(path, renderPlanFile(plan));
437
+ }
438
+ function withStep(plan, step, update) {
439
+ return {
440
+ ...plan,
441
+ updatedAt: now().toISOString(),
442
+ steps: plan.steps.map((s) => (s.index === step ? update(s) : s)),
443
+ };
444
+ }
445
+ // ---- goal internals ----
446
+ async function readGoals() {
447
+ const raw = await readText(goalsPath);
448
+ if (raw === null)
449
+ return [];
450
+ let parsed;
451
+ try {
452
+ parsed = YAML.parse(raw);
453
+ }
454
+ catch (err) {
455
+ throw new ContinuityStoreError(`continuity-store: ${goalsPath} is malformed YAML`, err);
456
+ }
457
+ const list = parsed?.goals;
458
+ if (!Array.isArray(list))
459
+ return [];
460
+ return list.filter(isGoalShape);
461
+ }
462
+ async function writeGoals(goals) {
463
+ await writeAtomic(goalsPath, YAML.stringify({ version: 1, goals }));
464
+ }
465
+ function nextId(prefix, existing, width = 4) {
466
+ let max = 0;
467
+ for (const id of existing) {
468
+ const n = Number(id.slice(prefix.length + 1));
469
+ if (Number.isInteger(n) && n > max)
470
+ max = n;
471
+ }
472
+ return `${prefix}-${String(max + 1).padStart(width, "0")}`;
473
+ }
474
+ async function verify(refs) {
475
+ return verifyEvidence(refs, {
476
+ sessionRootDir,
477
+ ...(opts.sessionId !== undefined ? { defaultSessionId: opts.sessionId } : {}),
478
+ now,
479
+ });
480
+ }
481
+ function mergeProofs(existing, incoming) {
482
+ const seen = new Set(existing.map((p) => p.toolUseId));
483
+ return [...existing, ...incoming.filter((p) => !seen.has(p.toolUseId))];
484
+ }
485
+ async function gatherHandoffInput(lastSessionId) {
486
+ const focus = (await readFocusState()) ?? emptyFocus;
487
+ const plans = await readPlans();
488
+ const activePlan = focus.activePlanId !== null ? (plans.find((p) => p.id === focus.activePlanId) ?? null) : null;
489
+ const goals = await readGoals();
490
+ return {
491
+ focusBody: focus.body,
492
+ activePlan,
493
+ plans,
494
+ goals,
495
+ requirements: focus.requirements,
496
+ ...(lastSessionId !== undefined ? { lastSessionId } : {}),
497
+ };
498
+ }
499
+ return {
500
+ dir() {
501
+ return storeDir;
502
+ },
503
+ async readFocus() {
504
+ return readFocusState();
505
+ },
506
+ async writeFocus(body) {
507
+ if (body.length > focusMaxChars) {
508
+ throw new ContinuityStoreError(`continuity-store: focus body is ${body.length} chars — the cap is ${focusMaxChars} (focusMaxChars). Keep focus.md short; details belong in plan steps.`);
509
+ }
510
+ await locked(async () => {
511
+ const state = await requireWritableFocus();
512
+ await writeFocusState({ ...state, body: body.trim() });
513
+ });
514
+ },
515
+ async setActivePlan(planId) {
516
+ if (planId !== null)
517
+ await requirePlan(planId);
518
+ await locked(async () => {
519
+ const state = await requireWritableFocus();
520
+ await writeFocusState({ ...state, activePlanId: planId });
521
+ });
522
+ },
523
+ async appendRequirement(input) {
524
+ if (input.text.trim() === "") {
525
+ throw new ContinuityStoreError("appendRequirement(): text must be non-empty (verbatim)");
526
+ }
527
+ if (!SESSION_ID_REGEX.test(input.source.sessionId)) {
528
+ throw new ContinuityStoreError(`appendRequirement(): invalid source.sessionId "${input.source.sessionId}" — expected sess_<16 hex>`);
529
+ }
530
+ if (input.id !== undefined && !REQ_ID_REGEX.test(input.id)) {
531
+ throw new ContinuityStoreError(`appendRequirement(): invalid id "${input.id}" — expected REQ-nnn`);
532
+ }
533
+ return locked(async () => {
534
+ const state = await requireWritableFocus();
535
+ const requirements = [...state.requirements];
536
+ let entry;
537
+ const existingIdx = input.id !== undefined ? requirements.findIndex((r) => r.id === input.id) : -1;
538
+ if (existingIdx >= 0) {
539
+ const existing = requirements[existingIdx];
540
+ if (existing.text !== input.text) {
541
+ throw new ContinuityStoreError(`appendRequirement(): ${existing.id} already exists with different text — the ledger is verbatim-only; append a NEW requirement instead of paraphrasing an old one.`);
542
+ }
543
+ entry = { ...existing, status: input.status ?? existing.status };
544
+ requirements[existingIdx] = entry;
545
+ }
546
+ else {
547
+ const id = input.id ??
548
+ nextId("REQ", requirements.map((r) => r.id), 3);
549
+ entry = {
550
+ id,
551
+ text: input.text,
552
+ status: input.status ?? "open",
553
+ source: { sessionId: input.source.sessionId, turn: input.source.turn },
554
+ };
555
+ requirements.push(entry);
556
+ }
557
+ const capped = capLedger(requirements);
558
+ await writeFocusState({
559
+ ...state,
560
+ requirements: capped.requirements,
561
+ ledgerTruncated: state.ledgerTruncated || capped.truncated,
562
+ });
563
+ return entry;
564
+ });
565
+ },
566
+ async listRequirements() {
567
+ return (await readFocusState())?.requirements ?? [];
568
+ },
569
+ async createPlan(input) {
570
+ if (input.title.trim() === "") {
571
+ throw new ContinuityStoreError("createPlan(): title must be non-empty");
572
+ }
573
+ return locked(async () => {
574
+ const existing = await readPlans();
575
+ const id = nextId("plan", existing.map((p) => p.id));
576
+ const iso = now().toISOString();
577
+ const plan = {
578
+ id,
579
+ slug: slugify(input.title),
580
+ title: input.title.trim(),
581
+ createdAt: iso,
582
+ updatedAt: iso,
583
+ steps: (input.steps ?? []).map((text, i) => ({
584
+ index: i + 1,
585
+ text: text.replace(/\s+/g, " ").trim(),
586
+ status: "open",
587
+ proofs: [],
588
+ })),
589
+ };
590
+ await writePlan(plan);
591
+ // First plan becomes the active plan (the §2.2 pointer) so PlanRead
592
+ // and the handoff have a default without an extra tool call.
593
+ const focus = await requireWritableFocus();
594
+ if (focus.activePlanId === null) {
595
+ await writeFocusState({ ...focus, activePlanId: id });
596
+ }
597
+ return plan;
598
+ });
599
+ },
600
+ async getPlan(planId) {
601
+ return (await readPlan(planId))?.plan ?? null;
602
+ },
603
+ async listPlans() {
604
+ return readPlans();
605
+ },
606
+ async getActivePlan() {
607
+ const focus = await readFocusState();
608
+ if (focus === null || focus.activePlanId === null)
609
+ return null;
610
+ return (await readPlan(focus.activePlanId))?.plan ?? null;
611
+ },
612
+ async addStep(planId, text) {
613
+ if (text.trim() === "") {
614
+ throw new ContinuityStoreError("addStep(): text must be non-empty");
615
+ }
616
+ return locked(async () => {
617
+ const { plan } = await requirePlan(planId);
618
+ const next = {
619
+ ...plan,
620
+ updatedAt: now().toISOString(),
621
+ steps: [
622
+ ...plan.steps,
623
+ {
624
+ index: plan.steps.length + 1,
625
+ text: text.replace(/\s+/g, " ").trim(),
626
+ status: "open",
627
+ proofs: [],
628
+ },
629
+ ],
630
+ };
631
+ await writePlan(next);
632
+ return next;
633
+ });
634
+ },
635
+ async setStepStatus(planId, step, status) {
636
+ if (!["open", "in_progress", "claimed"].includes(status)) {
637
+ throw new ContinuityStoreError(`setStepStatus(): "${status}" is not claimable — the proven transition requires evidence; use proveStep() (PlanComplete).`);
638
+ }
639
+ return locked(async () => {
640
+ const { plan } = await requirePlan(planId);
641
+ requireStep(plan, step);
642
+ const next = withStep(plan, step, (s) => ({ ...s, status }));
643
+ await writePlan(next);
644
+ return next;
645
+ });
646
+ },
647
+ async proveStep(planId, step, evidence) {
648
+ // Verify BEFORE taking the lock — verification reads foreign session
649
+ // logs and can be slow; the store lock protects only our own writes.
650
+ const { plan: preflight } = await requirePlan(planId);
651
+ requireStep(preflight, step);
652
+ const proofs = await verify(evidence);
653
+ return locked(async () => {
654
+ const { plan } = await requirePlan(planId);
655
+ const current = requireStep(plan, step);
656
+ const next = withStep(plan, step, (s) => ({
657
+ ...s,
658
+ status: "proven",
659
+ proofs: mergeProofs(current.proofs, proofs),
660
+ }));
661
+ await writePlan(next);
662
+ // Proof lifetime (§2.4): pin every cited session so TTL eviction
663
+ // cannot orphan a live proven record.
664
+ await appendRetentionPins(proofs.map((p) => p.sessionId), fence(retentionPath));
665
+ return next;
666
+ });
667
+ },
668
+ async writeGoal(input) {
669
+ if (input.title.trim() === "") {
670
+ throw new ContinuityStoreError("writeGoal(): title must be non-empty");
671
+ }
672
+ return locked(async () => {
673
+ const goals = await readGoals();
674
+ const iso = now().toISOString();
675
+ const goal = {
676
+ id: nextId("goal", goals.map((g) => g.id)),
677
+ title: input.title.trim(),
678
+ status: "open",
679
+ ...(input.target !== undefined ? { target: input.target } : {}),
680
+ ...(input.current !== undefined ? { current: input.current } : {}),
681
+ ...(input.unit !== undefined ? { unit: input.unit } : {}),
682
+ createdAt: iso,
683
+ updatedAt: iso,
684
+ };
685
+ await writeGoals([...goals, goal]);
686
+ return goal;
687
+ });
688
+ },
689
+ async updateGoal(goalId, patch) {
690
+ if (!GOAL_ID_REGEX.test(goalId)) {
691
+ throw new ContinuityStoreError(`invalid goalId "${goalId}" — expected goal-NNNN`);
692
+ }
693
+ // The proven transition is machine-checked for goals exactly like plan
694
+ // steps (§2.4: plan steps AND goals carry the ladder).
695
+ let proofs = [];
696
+ if (patch.status === "proven") {
697
+ if (patch.evidence === undefined || patch.evidence.length === 0) {
698
+ throw new ContinuityStoreError(`continuity-store: marking ${goalId} proven requires evidence — run the action first, then cite its toolUseId(s).`);
699
+ }
700
+ proofs = await verify(patch.evidence);
701
+ }
702
+ return locked(async () => {
703
+ const goals = await readGoals();
704
+ const idx = goals.findIndex((g) => g.id === goalId);
705
+ if (idx < 0) {
706
+ throw new ContinuityStoreError(`continuity-store: no goal "${goalId}"`);
707
+ }
708
+ const existing = goals[idx];
709
+ const mergedProofs = proofs.length > 0 ? mergeProofs(existing.proofs ?? [], proofs) : existing.proofs;
710
+ const next = {
711
+ ...existing,
712
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
713
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
714
+ ...(patch.target !== undefined ? { target: patch.target } : {}),
715
+ ...(patch.current !== undefined ? { current: patch.current } : {}),
716
+ ...(patch.unit !== undefined ? { unit: patch.unit } : {}),
717
+ ...(mergedProofs !== undefined ? { proofs: mergedProofs } : {}),
718
+ updatedAt: now().toISOString(),
719
+ };
720
+ const nextGoals = [...goals];
721
+ nextGoals[idx] = next;
722
+ await writeGoals(nextGoals);
723
+ if (proofs.length > 0) {
724
+ await appendRetentionPins(proofs.map((p) => p.sessionId), fence(retentionPath));
725
+ }
726
+ return next;
727
+ });
728
+ },
729
+ async listGoals() {
730
+ return readGoals();
731
+ },
732
+ async verifyEvidence(refs) {
733
+ return verify(refs);
734
+ },
735
+ async renderHandoff(handoffOpts = {}) {
736
+ return renderHandoff(await gatherHandoffInput(handoffOpts.lastSessionId));
737
+ },
738
+ async writeHandoff(handoffOpts = {}) {
739
+ const rendered = renderHandoff(await gatherHandoffInput(handoffOpts.lastSessionId));
740
+ await locked(async () => {
741
+ const existing = await readText(handoffPath);
742
+ if (existing !== null && !existing.trimStart().startsWith(HANDOFF_MARKER)) {
743
+ throw new ContinuityStoreError(`continuity-store: ${handoffPath} exists but is missing the "${HANDOFF_MARKER}" marker — refusing to overwrite a user-authored file.`);
744
+ }
745
+ await writeAtomic(handoffPath, rendered);
746
+ });
747
+ return handoffPath;
748
+ },
749
+ async clear(clearScope) {
750
+ const targets = {
751
+ focus: [focusPath],
752
+ plans: [plansDir],
753
+ goals: [goalsPath],
754
+ all: [focusPath, plansDir, goalsPath, handoffPath],
755
+ };
756
+ return locked(async () => moveToTrash(targets[clearScope], fence(crewhausDir), { now }));
757
+ },
758
+ async restore(ts) {
759
+ return locked(async () => restoreFromTrash(ts, fence(crewhausDir)));
760
+ },
761
+ async listTrash() {
762
+ return listTrashDir(fence(crewhausDir));
763
+ },
764
+ };
765
+ }