@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 +98 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +238 -0
- package/dist/handoff.d.ts +25 -0
- package/dist/handoff.js +100 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +765 -0
- package/dist/lock.d.ts +35 -0
- package/dist/lock.js +43 -0
- package/dist/trash.d.ts +68 -0
- package/dist/trash.js +196 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
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
|
+
}>;
|
package/dist/evidence.js
ADDED
|
@@ -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;
|
package/dist/handoff.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|