@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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @crewhaus/continuity-store
2
+
3
+ v0.3.0 Goal 1 (design §2.2–§2.7): the continuity substrate. Human-readable,
4
+ user-clearable focus/plans/goals/handoff artifacts under
5
+ `.crewhaus/state/<specName>/`, with the **claimed → proven proof ladder**
6
+ machine-checked against session event logs. No model call anywhere in this
7
+ package — everything is deterministic infrastructure.
8
+
9
+ ## Storage layout
10
+
11
+ ```
12
+ .crewhaus/state/<spec>/
13
+ focus.md # marker-gated (<!-- crewhaus:focus -->), body capped
14
+ # at focusMaxChars; carries the REQ-nnn requirements
15
+ # ledger and the active-plan pointer
16
+ plans/plan-NNNN-<slug>.md # YAML frontmatter + numbered steps with ladder
17
+ # statuses and frozen proof excerpts
18
+ goals.yaml # {id, title, status, target?, current?, unit?}
19
+ handoff.md # deterministic teardown render (same inputs →
20
+ # identical bytes)
21
+ .lock # advisory single-writer lock
22
+ ```
23
+
24
+ Why files + markers: matches project-memory's `LESSONS.md` precedent — a
25
+ user-authored `focus.md`/`handoff.md` without the crewhaus marker is **never
26
+ overwritten**. Why JSONL-adjacent formats: everything is inspectable with
27
+ `cat`/`git diff`, and every write is tmp+rename atomic.
28
+
29
+ ```ts
30
+ import { createContinuityStore } from "@crewhaus/continuity-store";
31
+
32
+ const store = createContinuityStore({ specName: "support-bot" });
33
+ await store.writeFocus("Ship CSV export");
34
+ await store.createPlan({ title: "Ship CSV export", steps: ["Parse", "Export"] });
35
+ await store.setStepStatus("plan-0001", 1, "claimed"); // always free
36
+ await store.proveStep("plan-0001", 1, [{ toolUseId: "tu_…" }]); // machine-checked
37
+ await store.writeHandoff({ lastSessionId: "sess_…" });
38
+ ```
39
+
40
+ ## The proof ladder (§2.4)
41
+
42
+ Statuses: `open → in_progress → claimed → proven`.
43
+
44
+ - `claimed` never requires anything — zero friction, no gaming pressure.
45
+ - `proven` is earned: `proveStep` / `updateGoal({status: "proven"})` resolve
46
+ each cited `toolUseId` against the append-only session JSONLs — including
47
+ **sub-agent child sessions**, discovered by walking the `sub_agent_start`
48
+ bracket events (`childSessionId`). Missing ids and `isError: true` results
49
+ reject with instructive errors (`no verified evidence for tu_…: run the
50
+ action first, then complete the step with its toolUseId.`).
51
+
52
+ **Proof lifetime**: session transcripts are TTL-evicted, so on every proven
53
+ transition the store (a) appends a retention pin for the cited session to
54
+ `.crewhaus/retention.json` (the pin contract session-store and
55
+ `crewhaus retention` honor) and (b) freezes a `{toolName, inputHash,
56
+ resultDigest}` excerpt into the plan/goal record — evidence outlives the
57
+ transcript.
58
+
59
+ ## Requirements ledger (§2.3)
60
+
61
+ `appendRequirement({text, source: {sessionId, turn}, status?})` records the
62
+ user's words **verbatim** (there is deliberately no paraphrase field) as
63
+ `REQ-nnn` entries with `(user, sess_…, turn N)` attribution inside `focus.md`.
64
+ Updating an existing id requires the identical text — paraphrase attempts are
65
+ rejected. The ledger is byte-capped (16 KB) with oldest-first eviction and a
66
+ `[ledger truncated]` marker.
67
+
68
+ ## Clearing (§2.6): trash + undo, never hard-delete
69
+
70
+ `clear("focus" | "plans" | "goals" | "all")` moves files to
71
+ `.crewhaus/trash/<ISO-ts>/…` preserving relative paths; `restore(ts)` puts
72
+ them back (fail-closed on conflicts); `listTrash()` enumerates snapshots.
73
+ The `moveToTrash(paths, crewhausDir)` helper is exported for other `.crewhaus`
74
+ stores to adopt the same clearing story.
75
+
76
+ ## Locking (§7.6)
77
+
78
+ Mutations take an advisory `.lock` (O_EXCL create): wait up to 2 s → steal if
79
+ the lock's mtime is >30 s stale (a `lock_stolen` warning is recorded) → fail
80
+ with an error naming the holder pid. Reads never lock; atomic writes keep
81
+ readers consistent regardless.
82
+
83
+ ## Scoping + tenancy (§2.7)
84
+
85
+ - `scope: {kind: "spec"}` (default) — one store per spec.
86
+ - `scope: {kind: "session", sessionId}` — nests under
87
+ `<spec>/sessions/<sessionId>/` for per-conversation state (channel daemons).
88
+ - Tenant contexts (ambient `withTenant` or an explicit `tenant` option) apply
89
+ the same fail-closed path fencing session-store enforces: any resolved path
90
+ outside the tenant's root throws (CWE-1230).
91
+
92
+ ## Consumers
93
+
94
+ `@crewhaus/tool-plan` wraps this store as RegisteredTools (FocusRead/Write,
95
+ PlanRead/Update/Complete, Goal*, MemoryClear). Runtime wiring (the mutable
96
+ tail block, `context_evicted` externalization, handoff-at-teardown) lands in
97
+ the runtime-core continuity PR; CLI verbs (`crewhaus memory clear|restore|show`)
98
+ in the apps-cli PR.
@@ -0,0 +1,73 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ export declare const DEFAULT_SESSION_ROOT_DIR = ".crewhaus/sessions";
3
+ export type EvidenceRef = {
4
+ readonly toolUseId: string;
5
+ /** The session whose log carries the id. Optional when a default session
6
+ * is supplied at verification time. */
7
+ readonly sessionId?: string;
8
+ };
9
+ export type EvidenceVerdict = "verified" | "missing" | "error_result";
10
+ /** The proof excerpt frozen into the citing plan/goal record so evidence
11
+ * outlives the transcript's TTL (design §2.4). */
12
+ export type FrozenProof = {
13
+ readonly toolUseId: string;
14
+ /** The session (parent or child) whose log resolved the id. */
15
+ readonly sessionId: string;
16
+ readonly toolName: string;
17
+ /** `sha256:<hex>` over the verbatim `tool_use` input JSON. */
18
+ readonly inputHash: string;
19
+ /** Whitespace-collapsed excerpt (≤240 chars) of the `tool_result` text —
20
+ * human-checkable even after the raw transcript is evicted. */
21
+ readonly resultDigest: string;
22
+ readonly verifiedAt: string;
23
+ };
24
+ export type EvidenceResolution = {
25
+ readonly ref: EvidenceRef;
26
+ readonly verdict: EvidenceVerdict;
27
+ /** Present iff `verdict === "verified"`. */
28
+ readonly proof?: FrozenProof;
29
+ /** Human-readable detail for rejected refs. */
30
+ readonly detail?: string;
31
+ };
32
+ /** Thrown by `verifyEvidence` on the first non-verified ref. Carries the
33
+ * failing ref + verdict so callers (tool-plan) can emit an `action_proof`
34
+ * event for the rejected attempt. */
35
+ export declare class EvidenceError extends CrewhausError {
36
+ readonly name = "EvidenceError";
37
+ readonly toolUseId: string;
38
+ readonly verdict: EvidenceVerdict;
39
+ constructor(message: string, toolUseId: string, verdict: EvidenceVerdict);
40
+ }
41
+ export type VerifyEvidenceOptions = {
42
+ /** Where session `.jsonl` logs live. Default `.crewhaus/sessions`. */
43
+ readonly sessionRootDir?: string;
44
+ /** Session assumed for refs that omit `sessionId`. */
45
+ readonly defaultSessionId?: string;
46
+ /** Bracket-walk depth cap. Default 8. */
47
+ readonly maxDepth?: number;
48
+ readonly now?: () => Date;
49
+ };
50
+ /**
51
+ * Resolve each ref against the session logs WITHOUT throwing — one verdict
52
+ * per ref, in input order. `verifyEvidence` is the throwing wrapper the
53
+ * `proven` transition uses; this form exists so callers can audit rejected
54
+ * attempts (`action_proof` events with `verdict: "missing"`).
55
+ */
56
+ export declare function resolveEvidence(refs: readonly EvidenceRef[], opts?: VerifyEvidenceOptions): Promise<readonly EvidenceResolution[]>;
57
+ /**
58
+ * The `proven` gate: resolve every ref and throw an instructive
59
+ * `EvidenceError` on the first one that is missing or errored. Returns the
60
+ * frozen proofs (one per ref) on success.
61
+ */
62
+ export declare function verifyEvidence(refs: readonly EvidenceRef[], opts?: VerifyEvidenceOptions): Promise<readonly FrozenProof[]>;
63
+ /**
64
+ * Proof lifetime, mechanism (a): pin the cited sessions in
65
+ * `.crewhaus/retention.json` (the `pins` contract session-store's TTL
66
+ * eviction and `crewhaus retention` both honor) so a transcript cited by a
67
+ * live `proven` record is never TTL-evicted out from under it. Read-modify-
68
+ * write preserves every other key in the file verbatim; the write is
69
+ * tmp+rename atomic. Absent file → created with `{version: 1, pins: […]}`.
70
+ */
71
+ export declare function appendRetentionPins(sessionIds: readonly string[], retentionPath: string): Promise<{
72
+ readonly added: readonly string[];
73
+ }>;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The proof ladder's machine check (design §2.4): `proven` is earned by
3
+ * citing `toolUseId`s that RESOLVE against the append-only session event
4
+ * logs — the substrate runtime-core already writes verbatim (`tool_use` with
5
+ * full input, `tool_result` with full output + `isError`). Narration can
6
+ * never produce a ✓.
7
+ *
8
+ * Resolution walks the cited session's JSONL and, when the id is not found
9
+ * there, descends into child sessions via the `sub_agent_start` bracket
10
+ * events (whose payloads record `childSessionId`) — so a researcher
11
+ * sub-agent's tool calls are valid proof for the parent's plan.
12
+ *
13
+ * Proof-evidence lifetime (§2.4, judge-mandated): session JSONLs are
14
+ * TTL-evicted, which would silently degrade every `proven` to unverifiable.
15
+ * Verification therefore returns a FROZEN excerpt — `{toolName, inputHash,
16
+ * resultDigest}` — that the store writes into the citing plan/goal record,
17
+ * and the store additionally pins the cited session in
18
+ * `.crewhaus/retention.json` (see `appendRetentionPins`).
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync } from "node:fs";
22
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
23
+ import { dirname } from "node:path";
24
+ import { CrewhausError } from "@crewhaus/errors";
25
+ import { openEventLog } from "@crewhaus/event-log";
26
+ export const DEFAULT_SESSION_ROOT_DIR = ".crewhaus/sessions";
27
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
28
+ /** How deep the sub-agent bracket walk descends before giving up. */
29
+ const DEFAULT_MAX_DEPTH = 8;
30
+ /** Frozen `resultDigest` excerpt length (chars). */
31
+ const RESULT_DIGEST_MAX_CHARS = 240;
32
+ /** Thrown by `verifyEvidence` on the first non-verified ref. Carries the
33
+ * failing ref + verdict so callers (tool-plan) can emit an `action_proof`
34
+ * event for the rejected attempt. */
35
+ export class EvidenceError extends CrewhausError {
36
+ name = "EvidenceError";
37
+ toolUseId;
38
+ verdict;
39
+ constructor(message, toolUseId, verdict) {
40
+ super("runtime", message);
41
+ this.toolUseId = toolUseId;
42
+ this.verdict = verdict;
43
+ }
44
+ }
45
+ function collapse(text, maxChars) {
46
+ const t = text.replace(/\s+/g, " ").trim();
47
+ return t.length > maxChars ? `${t.slice(0, maxChars - 1).trimEnd()}…` : t;
48
+ }
49
+ function resultText(content) {
50
+ if (typeof content === "string")
51
+ return content;
52
+ if (Array.isArray(content)) {
53
+ return content
54
+ .filter((b) => b.type === "text" && typeof b.text === "string")
55
+ .map((b) => b.text)
56
+ .join("\n");
57
+ }
58
+ return "";
59
+ }
60
+ async function scanSession(sessionId, toolUseId, sessionRootDir) {
61
+ const log = await openEventLog(sessionId, { rootDir: sessionRootDir });
62
+ let toolUse;
63
+ let toolResult;
64
+ const childSessionIds = [];
65
+ for await (const ev of log.read()) {
66
+ if (ev.kind === "tool_use") {
67
+ const p = ev.payload;
68
+ if (p.id === toolUseId) {
69
+ toolUse = { name: typeof p.name === "string" ? p.name : "unknown", input: p.input };
70
+ }
71
+ }
72
+ else if (ev.kind === "tool_result") {
73
+ const p = ev.payload;
74
+ if (p.toolUseId === toolUseId) {
75
+ toolResult = { content: p.content, isError: p.isError === true };
76
+ }
77
+ }
78
+ else if (ev.kind === "sub_agent_start") {
79
+ const p = ev.payload;
80
+ if (typeof p.childSessionId === "string" && SESSION_ID_REGEX.test(p.childSessionId)) {
81
+ childSessionIds.push(p.childSessionId);
82
+ }
83
+ }
84
+ }
85
+ await log.close();
86
+ return {
87
+ ...(toolUse !== undefined ? { toolUse } : {}),
88
+ ...(toolResult !== undefined ? { toolResult } : {}),
89
+ childSessionIds,
90
+ };
91
+ }
92
+ /**
93
+ * Resolve each ref against the session logs WITHOUT throwing — one verdict
94
+ * per ref, in input order. `verifyEvidence` is the throwing wrapper the
95
+ * `proven` transition uses; this form exists so callers can audit rejected
96
+ * attempts (`action_proof` events with `verdict: "missing"`).
97
+ */
98
+ export async function resolveEvidence(refs, opts = {}) {
99
+ const sessionRootDir = opts.sessionRootDir ?? DEFAULT_SESSION_ROOT_DIR;
100
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
101
+ const now = opts.now ?? (() => new Date());
102
+ const resolutions = [];
103
+ for (const ref of refs) {
104
+ const rootSession = ref.sessionId ?? opts.defaultSessionId;
105
+ if (rootSession === undefined) {
106
+ resolutions.push({
107
+ ref,
108
+ verdict: "missing",
109
+ detail: `no verified evidence for ${ref.toolUseId}: no sessionId to resolve it against — cite {toolUseId, sessionId} explicitly.`,
110
+ });
111
+ continue;
112
+ }
113
+ if (!SESSION_ID_REGEX.test(rootSession)) {
114
+ resolutions.push({
115
+ ref,
116
+ verdict: "missing",
117
+ detail: `no verified evidence for ${ref.toolUseId}: "${rootSession}" is not a valid sessionId (expected sess_<16 hex>).`,
118
+ });
119
+ continue;
120
+ }
121
+ // Breadth-first over the session and its sub-agent children.
122
+ const queue = [
123
+ { sessionId: rootSession, depth: 0 },
124
+ ];
125
+ const visited = new Set();
126
+ let resolution;
127
+ while (queue.length > 0 && resolution === undefined) {
128
+ const item = queue.shift();
129
+ if (visited.has(item.sessionId))
130
+ continue;
131
+ visited.add(item.sessionId);
132
+ const scan = await scanSession(item.sessionId, ref.toolUseId, sessionRootDir);
133
+ if (scan.toolUse !== undefined && scan.toolResult !== undefined) {
134
+ if (scan.toolResult.isError) {
135
+ resolution = {
136
+ ref,
137
+ verdict: "error_result",
138
+ detail: `evidence ${ref.toolUseId} resolved in ${item.sessionId} but its tool_result has isError: true — a failed call cannot prove a step. Rerun the action and cite the successful toolUseId.`,
139
+ };
140
+ }
141
+ else {
142
+ const inputJson = JSON.stringify(scan.toolUse.input);
143
+ resolution = {
144
+ ref,
145
+ verdict: "verified",
146
+ proof: {
147
+ toolUseId: ref.toolUseId,
148
+ sessionId: item.sessionId,
149
+ toolName: scan.toolUse.name,
150
+ inputHash: `sha256:${createHash("sha256")
151
+ .update(inputJson ?? "null")
152
+ .digest("hex")}`,
153
+ resultDigest: collapse(resultText(scan.toolResult.content), RESULT_DIGEST_MAX_CHARS),
154
+ verifiedAt: now().toISOString(),
155
+ },
156
+ };
157
+ }
158
+ }
159
+ else if (item.depth < maxDepth) {
160
+ for (const child of scan.childSessionIds) {
161
+ queue.push({ sessionId: child, depth: item.depth + 1 });
162
+ }
163
+ }
164
+ }
165
+ resolutions.push(resolution ?? {
166
+ ref,
167
+ verdict: "missing",
168
+ detail: `no verified evidence for ${ref.toolUseId}: run the action first, then complete the step with its toolUseId.`,
169
+ });
170
+ }
171
+ return resolutions;
172
+ }
173
+ /**
174
+ * The `proven` gate: resolve every ref and throw an instructive
175
+ * `EvidenceError` on the first one that is missing or errored. Returns the
176
+ * frozen proofs (one per ref) on success.
177
+ */
178
+ export async function verifyEvidence(refs, opts = {}) {
179
+ if (refs.length === 0) {
180
+ throw new EvidenceError("no verified evidence: cite at least one {toolUseId} — run the action first, then complete the step with its toolUseId.", "", "missing");
181
+ }
182
+ const resolutions = await resolveEvidence(refs, opts);
183
+ const proofs = [];
184
+ for (const r of resolutions) {
185
+ if (r.verdict !== "verified" || r.proof === undefined) {
186
+ throw new EvidenceError(r.detail ??
187
+ `no verified evidence for ${r.ref.toolUseId}: run the action first, then complete the step with its toolUseId.`, r.ref.toolUseId, r.verdict);
188
+ }
189
+ proofs.push(r.proof);
190
+ }
191
+ return proofs;
192
+ }
193
+ /**
194
+ * Proof lifetime, mechanism (a): pin the cited sessions in
195
+ * `.crewhaus/retention.json` (the `pins` contract session-store's TTL
196
+ * eviction and `crewhaus retention` both honor) so a transcript cited by a
197
+ * live `proven` record is never TTL-evicted out from under it. Read-modify-
198
+ * write preserves every other key in the file verbatim; the write is
199
+ * tmp+rename atomic. Absent file → created with `{version: 1, pins: […]}`.
200
+ */
201
+ export async function appendRetentionPins(sessionIds, retentionPath) {
202
+ const valid = [...new Set(sessionIds.filter((id) => SESSION_ID_REGEX.test(id)))];
203
+ if (valid.length === 0)
204
+ return { added: [] };
205
+ let config = { version: 1 };
206
+ if (existsSync(retentionPath)) {
207
+ let raw;
208
+ try {
209
+ raw = await readFile(retentionPath, "utf8");
210
+ }
211
+ catch (err) {
212
+ throw new CrewhausError("config", `continuity-store: cannot read ${retentionPath}`, err);
213
+ }
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(raw);
217
+ }
218
+ catch (err) {
219
+ throw new CrewhausError("config", `continuity-store: ${retentionPath} is malformed JSON — fix it before pinning proof sessions (a half-understood retention policy must not be rewritten).`, err);
220
+ }
221
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
222
+ throw new CrewhausError("config", `continuity-store: ${retentionPath} must be a JSON object — fix it before pinning proof sessions.`);
223
+ }
224
+ config = parsed;
225
+ }
226
+ const existing = Array.isArray(config["pins"])
227
+ ? config["pins"].filter((p) => typeof p === "string")
228
+ : [];
229
+ const added = valid.filter((id) => !existing.includes(id));
230
+ if (added.length === 0)
231
+ return { added: [] };
232
+ config["pins"] = [...existing, ...added];
233
+ await mkdir(dirname(retentionPath), { recursive: true });
234
+ const tmpPath = `${retentionPath}.tmp`;
235
+ await writeFile(tmpPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
236
+ await rename(tmpPath, retentionPath);
237
+ return { added };
238
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Deterministic handoff rendering (design §2.2/§2.8): `handoff.md` is rebuilt
3
+ * from store state at teardown with NO model call — same inputs, identical
4
+ * bytes (the determinism is test-pinned). It renders what session 2 needs to
5
+ * pick up: current focus, the active plan's steps with claimed-vs-proven
6
+ * marked DISTINCTLY (a claim is never conflated with a machine-verified ✓),
7
+ * open goals, unresolved requirements, derived next actions, and the last
8
+ * session id.
9
+ */
10
+ import type { Goal, PlanRecord, Requirement } from "./types";
11
+ export declare const HANDOFF_MARKER = "<!-- crewhaus:handoff -->";
12
+ export type HandoffInput = {
13
+ readonly focusBody: string;
14
+ readonly activePlan: PlanRecord | null;
15
+ /** Every plan in the store (used for the "other open plans" section). */
16
+ readonly plans: readonly PlanRecord[];
17
+ readonly goals: readonly Goal[];
18
+ readonly requirements: readonly Requirement[];
19
+ readonly lastSessionId?: string;
20
+ };
21
+ /** `[proven]` vs `[claimed — unverified]`: the ladder's two top rungs are
22
+ * never rendered alike (design §2.4). */
23
+ export declare function renderStatus(status: string): string;
24
+ /** Pure renderer — no clock, no filesystem, no model. */
25
+ export declare function renderHandoff(input: HandoffInput): string;
@@ -0,0 +1,100 @@
1
+ export const HANDOFF_MARKER = "<!-- crewhaus:handoff -->";
2
+ const NONE = "_none_";
3
+ /** `[proven]` vs `[claimed — unverified]`: the ladder's two top rungs are
4
+ * never rendered alike (design §2.4). */
5
+ export function renderStatus(status) {
6
+ return status === "claimed" ? "[claimed — unverified]" : `[${status}]`;
7
+ }
8
+ function renderStepLine(step) {
9
+ const proofSuffix = step.status === "proven" && step.proofs.length > 0
10
+ ? ` — proof: ${step.proofs.map((p) => `${p.toolUseId} (${p.sessionId})`).join(", ")}`
11
+ : "";
12
+ return `${step.index}. ${renderStatus(step.status)} ${step.text}${proofSuffix}`;
13
+ }
14
+ function planSummary(plan) {
15
+ const proven = plan.steps.filter((s) => s.status === "proven").length;
16
+ return `${plan.id} — ${plan.title} (${proven}/${plan.steps.length} steps proven)`;
17
+ }
18
+ function renderGoalLine(goal) {
19
+ const progress = goal.target !== undefined
20
+ ? ` (${goal.current ?? 0}/${goal.target}${goal.unit !== undefined ? ` ${goal.unit}` : ""})`
21
+ : "";
22
+ return `- ${goal.id} ${renderStatus(goal.status)} ${goal.title}${progress}`;
23
+ }
24
+ function renderRequirementLine(req) {
25
+ return `- ${req.id} [${req.status}] ${JSON.stringify(req.text)} (user, ${req.source.sessionId}, turn ${req.source.turn})`;
26
+ }
27
+ function nextActions(plan) {
28
+ if (plan === null)
29
+ return [];
30
+ const actions = [];
31
+ for (const step of plan.steps) {
32
+ if (step.status === "claimed") {
33
+ actions.push(`Verify or redo: ${step.text} (${plan.id} step ${step.index} is claimed but unproven)`);
34
+ }
35
+ }
36
+ for (const step of plan.steps) {
37
+ if (step.status === "in_progress") {
38
+ actions.push(`Continue: ${step.text} (${plan.id} step ${step.index})`);
39
+ }
40
+ }
41
+ for (const step of plan.steps) {
42
+ if (step.status === "open") {
43
+ actions.push(`Do: ${step.text} (${plan.id} step ${step.index})`);
44
+ }
45
+ }
46
+ return actions.slice(0, 5);
47
+ }
48
+ /** Pure renderer — no clock, no filesystem, no model. */
49
+ export function renderHandoff(input) {
50
+ const lines = [HANDOFF_MARKER, "# Handoff", "", "## Focus", ""];
51
+ lines.push(input.focusBody.trim() !== "" ? input.focusBody.trim() : "_no focus set_");
52
+ lines.push("", "## Active plan", "");
53
+ if (input.activePlan !== null) {
54
+ lines.push(planSummary(input.activePlan), "");
55
+ for (const step of input.activePlan.steps) {
56
+ lines.push(renderStepLine(step));
57
+ }
58
+ if (input.activePlan.steps.length === 0)
59
+ lines.push(NONE);
60
+ }
61
+ else {
62
+ lines.push(NONE);
63
+ }
64
+ const others = input.plans.filter((p) => p.id !== input.activePlan?.id && p.steps.some((s) => s.status !== "proven"));
65
+ if (others.length > 0) {
66
+ lines.push("", "## Other open plans", "");
67
+ for (const plan of others)
68
+ lines.push(`- ${planSummary(plan)}`);
69
+ }
70
+ lines.push("", "## Goals", "");
71
+ const openGoals = input.goals.filter((g) => g.status !== "proven");
72
+ if (openGoals.length > 0) {
73
+ for (const goal of openGoals)
74
+ lines.push(renderGoalLine(goal));
75
+ }
76
+ else {
77
+ lines.push(NONE);
78
+ }
79
+ lines.push("", "## Unresolved requirements", "");
80
+ const unresolved = input.requirements.filter((r) => r.status === "open");
81
+ if (unresolved.length > 0) {
82
+ for (const req of unresolved)
83
+ lines.push(renderRequirementLine(req));
84
+ }
85
+ else {
86
+ lines.push(NONE);
87
+ }
88
+ lines.push("", "## Next actions", "");
89
+ const actions = nextActions(input.activePlan);
90
+ if (actions.length > 0) {
91
+ actions.forEach((action, i) => lines.push(`${i + 1}. ${action}`));
92
+ }
93
+ else {
94
+ lines.push(NONE);
95
+ }
96
+ lines.push("", "## Last session", "");
97
+ lines.push(input.lastSessionId ?? "_unknown_");
98
+ lines.push("");
99
+ return lines.join("\n");
100
+ }
@@ -0,0 +1,118 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import { type Tenant } from "@crewhaus/tenancy";
3
+ import { type EvidenceRef, type FrozenProof } from "./evidence";
4
+ import { type AcquireLockOptions } from "./lock";
5
+ import { type MoveToTrashResult, type RestoreResult, type TrashSnapshot } from "./trash";
6
+ import type { ClaimableStatus, FocusState, Goal, LadderStatus, PlanRecord, Requirement, RequirementStatus } from "./types";
7
+ export { type AcquireLockOptions, type LockHandle, type LockPolicy, ContinuityLockError, DEFAULT_LOCK_POLICY, acquireLock, withLock, } from "./lock";
8
+ export { type MoveToTrashResult, type PurgeTrashResult, type RestoreResult, type TrashSnapshot, TRASH_DIR_NAME, TRASH_PURGE_AFTER_MS, TrashError, listTrash, moveToTrash, parseTrashTimestamp, purgeTrash, restoreFromTrash, } from "./trash";
9
+ export { type EvidenceRef, type EvidenceResolution, type EvidenceVerdict, type FrozenProof, type VerifyEvidenceOptions, DEFAULT_SESSION_ROOT_DIR, EvidenceError, appendRetentionPins, resolveEvidence, verifyEvidence, } from "./evidence";
10
+ export { type HandoffInput, HANDOFF_MARKER, renderHandoff, renderStatus } from "./handoff";
11
+ export type { ClaimableStatus, FocusState, Goal, LadderStatus, PlanRecord, PlanStep, Requirement, RequirementStatus, } from "./types";
12
+ export declare const DEFAULT_ROOT_DIR = ".crewhaus/state";
13
+ export declare const DEFAULT_FOCUS_MAX_CHARS = 4096;
14
+ /** §2.3 ledger cap: oldest-first eviction with a `[ledger truncated]` marker. */
15
+ export declare const REQUIREMENTS_LEDGER_MAX_BYTES = 16384;
16
+ export declare const FOCUS_MARKER = "<!-- crewhaus:focus -->";
17
+ export declare class ContinuityStoreError extends CrewhausError {
18
+ readonly name = "ContinuityStoreError";
19
+ constructor(message: string, cause?: unknown);
20
+ }
21
+ export type ContinuityScope = {
22
+ readonly kind: "spec";
23
+ } | {
24
+ readonly kind: "session";
25
+ readonly sessionId: string;
26
+ };
27
+ export type ClearScope = "focus" | "plans" | "goals" | "all";
28
+ export interface ContinuityStoreOptions {
29
+ readonly specName: string;
30
+ /** Default `.crewhaus/state` (or `<tenantRoot>/state` under a tenant). */
31
+ readonly rootDir?: string;
32
+ /** Default `{kind: "spec"}`. Session scope nests the store under
33
+ * `<spec>/sessions/<sessionId>/` (design §2.7). */
34
+ readonly scope?: ContinuityScope;
35
+ /** Explicit tenant to fence against (in addition to any ambient
36
+ * `withTenant` context, which is always honored). */
37
+ readonly tenant?: Tenant;
38
+ /** Where session `.jsonl` event logs live, for proof verification.
39
+ * Default: the `sessions` sibling of the state root
40
+ * (`.crewhaus/sessions`). */
41
+ readonly sessionRootDir?: string;
42
+ /** Default session for evidence refs that omit `sessionId`. */
43
+ readonly sessionId?: string;
44
+ /** Hard cap on the focus body (design §2.1 `focusMaxChars`). Default 4096. */
45
+ readonly focusMaxChars?: number;
46
+ readonly now?: () => Date;
47
+ /** Lock policy overrides + warning sink (lock steals, §7.6). */
48
+ readonly lock?: AcquireLockOptions;
49
+ }
50
+ export type AppendRequirementInput = {
51
+ /** Existing `REQ-nnn` to update (status move), or omitted to mint the next
52
+ * id. Updating requires `text` to match the stored entry VERBATIM. */
53
+ readonly id?: string;
54
+ /** The requirement, verbatim — never paraphrase. */
55
+ readonly text: string;
56
+ readonly source: {
57
+ readonly sessionId: string;
58
+ readonly turn: number;
59
+ };
60
+ readonly status?: RequirementStatus;
61
+ };
62
+ export type UpdateGoalInput = {
63
+ readonly title?: string;
64
+ readonly status?: LadderStatus;
65
+ readonly target?: number;
66
+ readonly current?: number;
67
+ readonly unit?: string;
68
+ /** Required when `status: "proven"` — verified like a plan step. */
69
+ readonly evidence?: readonly EvidenceRef[];
70
+ };
71
+ export interface ContinuityStore {
72
+ /** The scoped directory this store reads and writes. */
73
+ dir(): string;
74
+ readFocus(): Promise<FocusState | null>;
75
+ writeFocus(body: string): Promise<void>;
76
+ setActivePlan(planId: string | null): Promise<void>;
77
+ appendRequirement(input: AppendRequirementInput): Promise<Requirement>;
78
+ listRequirements(): Promise<readonly Requirement[]>;
79
+ createPlan(input: {
80
+ title: string;
81
+ steps?: readonly string[];
82
+ }): Promise<PlanRecord>;
83
+ getPlan(planId: string): Promise<PlanRecord | null>;
84
+ listPlans(): Promise<readonly PlanRecord[]>;
85
+ getActivePlan(): Promise<PlanRecord | null>;
86
+ addStep(planId: string, text: string): Promise<PlanRecord>;
87
+ /** Ladder moves below `proven` — always free (§2.4). */
88
+ setStepStatus(planId: string, step: number, status: ClaimableStatus): Promise<PlanRecord>;
89
+ /** The `proven` transition: verifies evidence against session event logs,
90
+ * freezes proof excerpts into the plan record, and pins the cited
91
+ * sessions in `.crewhaus/retention.json`. */
92
+ proveStep(planId: string, step: number, evidence: readonly EvidenceRef[]): Promise<PlanRecord>;
93
+ writeGoal(input: {
94
+ title: string;
95
+ target?: number;
96
+ current?: number;
97
+ unit?: string;
98
+ }): Promise<Goal>;
99
+ updateGoal(goalId: string, patch: UpdateGoalInput): Promise<Goal>;
100
+ listGoals(): Promise<readonly Goal[]>;
101
+ verifyEvidence(refs: readonly EvidenceRef[]): Promise<readonly FrozenProof[]>;
102
+ renderHandoff(opts?: {
103
+ lastSessionId?: string;
104
+ }): Promise<string>;
105
+ /** Renders and writes `handoff.md`; returns the file path. */
106
+ writeHandoff(opts?: {
107
+ lastSessionId?: string;
108
+ }): Promise<string>;
109
+ clear(scope: ClearScope): Promise<MoveToTrashResult>;
110
+ restore(ts: string): Promise<RestoreResult>;
111
+ listTrash(): Promise<readonly TrashSnapshot[]>;
112
+ }
113
+ /**
114
+ * Construct a continuity store. Lazy: directories and files are created on
115
+ * the first write. Reads never take the lock; every mutation runs under the
116
+ * advisory `.lock` (§7.6) and lands via tmp+rename.
117
+ */
118
+ export declare function createContinuityStore(opts: ContinuityStoreOptions): ContinuityStore;