@linkedclaw/cli 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { computePaBodyHash, listCruxFiles, readStaging, stagingPathFor, writeStaging } from "../src/converge/staging.js";
6
+ import { writeRunMeta } from "../src/converge/workspace.js";
7
+
8
+ const RUN_ID = "clg_run_review001";
9
+ const SOURCE_DEBATE_ID = "dbt_src001";
10
+ const PA_AGENT_ID = "agt_pa001";
11
+ const BODY = "# Synthesis\n\nSome synthesis.\n\n# Open questions\n\n(none)\n";
12
+
13
+ let tmp: string;
14
+ let stagingDir: string;
15
+
16
+ beforeEach(() => {
17
+ tmp = mkdtempSync(join(tmpdir(), "lc-review-test-"));
18
+ stagingDir = join(tmp, "converged", "staging", RUN_ID);
19
+ writeRunMeta(stagingDir, {
20
+ run_id: RUN_ID,
21
+ source_debate_id: SOURCE_DEBATE_ID,
22
+ pa_agent_id: PA_AGENT_ID,
23
+ target_corpus: tmp,
24
+ owner_role: "a",
25
+ });
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(tmp, { recursive: true, force: true });
30
+ });
31
+
32
+ function writeDoc(cruxId: string, outcome: string, attested = false, userResponse = "") {
33
+ writeStaging(stagingPathFor(stagingDir, cruxId), {
34
+ frontmatter: {
35
+ debate_id: SOURCE_DEBATE_ID,
36
+ run_id: RUN_ID,
37
+ crux_id: cruxId,
38
+ sub_debate_chain: [],
39
+ latest_sub_debate_id: null,
40
+ source_crux_map_hash: "sha256:aabb",
41
+ generation_id: "gen_review001",
42
+ generated_at: "2026-04-30T10:00:00Z",
43
+ pa_body_hash: computePaBodyHash(BODY),
44
+ outcome: outcome as any,
45
+ bilateral_mandate_intact: true,
46
+ citations_a: [],
47
+ citations_b: [],
48
+ mod_progress_summary: {},
49
+ attested_by_user: attested,
50
+ },
51
+ userResponse,
52
+ body: BODY,
53
+ });
54
+ }
55
+
56
+ // Inline the review logic (mirrors converge.ts action body)
57
+ function runReview(dir: string) {
58
+ const files = listCruxFiles(dir);
59
+ const cruxes: Array<Record<string, unknown>> = [];
60
+ for (const fn of files) {
61
+ const doc = readStaging(join(dir, fn));
62
+ const fm = doc.frontmatter;
63
+ cruxes.push({
64
+ crux_id: fm.crux_id,
65
+ outcome: fm.outcome,
66
+ bilateral_mandate_intact: fm.bilateral_mandate_intact,
67
+ attested_by_user: fm.attested_by_user,
68
+ latest_sub_debate_id: fm.latest_sub_debate_id,
69
+ has_user_response: (doc.userResponse || "").trim().length > 0,
70
+ next_action:
71
+ fm.outcome === "already_aligned" && !fm.attested_by_user ? "attest"
72
+ : fm.outcome === "needs_input" ? "clarify_or_accept"
73
+ : "accept_or_reject",
74
+ });
75
+ }
76
+ const alignedAwaitingAttest = cruxes.filter((c) => c.outcome === "already_aligned" && !c.attested_by_user);
77
+ return {
78
+ run_id: RUN_ID,
79
+ staging_dir: dir,
80
+ cruxes,
81
+ already_aligned_awaiting_attest: alignedAwaitingAttest.map((c) => c.crux_id),
82
+ };
83
+ }
84
+
85
+ describe("review: lists all staging cruxes with correct next_action", () => {
86
+ it("three docs → correct next_action per outcome", () => {
87
+ writeDoc("crux_001", "converged");
88
+ writeDoc("crux_002", "already_aligned", false); // not attested
89
+ writeDoc("crux_003", "needs_input");
90
+
91
+ const result = runReview(stagingDir);
92
+ expect(result.cruxes).toHaveLength(3);
93
+
94
+ const c001 = result.cruxes.find((c) => c.crux_id === "crux_001");
95
+ const c002 = result.cruxes.find((c) => c.crux_id === "crux_002");
96
+ const c003 = result.cruxes.find((c) => c.crux_id === "crux_003");
97
+
98
+ expect(c001?.next_action).toBe("accept_or_reject");
99
+ expect(c002?.next_action).toBe("attest");
100
+ expect(c003?.next_action).toBe("clarify_or_accept");
101
+ });
102
+
103
+ it("already_aligned_awaiting_attest includes unattested already_aligned only", () => {
104
+ writeDoc("crux_001", "converged");
105
+ writeDoc("crux_002", "already_aligned", false);
106
+ writeDoc("crux_003", "already_aligned", true); // attested
107
+
108
+ const result = runReview(stagingDir);
109
+ expect(result.already_aligned_awaiting_attest).toEqual(["crux_002"]);
110
+ expect(result.already_aligned_awaiting_attest).not.toContain("crux_003");
111
+ });
112
+
113
+ it("empty staging dir → empty cruxes, empty already_aligned_awaiting_attest", () => {
114
+ const result = runReview(stagingDir);
115
+ expect(result.cruxes).toHaveLength(0);
116
+ expect(result.already_aligned_awaiting_attest).toHaveLength(0);
117
+ });
118
+
119
+ it("has_user_response reflects whether _user_response is non-empty", () => {
120
+ writeDoc("crux_001", "needs_input", false, "My clarification here.");
121
+ writeDoc("crux_002", "needs_input", false, "");
122
+
123
+ const result = runReview(stagingDir);
124
+ const c1 = result.cruxes.find((c) => c.crux_id === "crux_001");
125
+ const c2 = result.cruxes.find((c) => c.crux_id === "crux_002");
126
+ expect(c1?.has_user_response).toBe(true);
127
+ expect(c2?.has_user_response).toBe(false);
128
+ });
129
+
130
+ it("returns run_id and staging_dir in result", () => {
131
+ const result = runReview(stagingDir);
132
+ expect(result.run_id).toBe(RUN_ID);
133
+ expect(result.staging_dir).toBe(stagingDir);
134
+ });
135
+ });
@@ -0,0 +1,286 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { load as yamlLoad } from "js-yaml";
6
+
7
+ // ─── shared test data ─────────────────────────────────────────────────────────
8
+
9
+ const RUN_ID = "clg_run11223344";
10
+ const SOURCE_DEBATE_ID = "dbt_source001";
11
+ const PA_AGENT_ID = "agt_pa001";
12
+ const USER_ID = "usr_ownerA001";
13
+ const SUB_DEBATE_ID = "dbt_sub001";
14
+ const CRUX_ID = "crux_abc";
15
+ const COMMONS_LOG_CID = "clg_log00000001";
16
+ const SUB_LOG_CID = "clg_log00000002";
17
+
18
+ const SOURCE_CRUX_MAP_DATA = { cruxes: [{ crux_id: CRUX_ID, statement: "X is true" }], version: 1 };
19
+
20
+ const RUN_EVENTS = [
21
+ {
22
+ seq: 1,
23
+ event_type: "run_started",
24
+ payload: {
25
+ source_crux_map_hash: "sha256:aabbccdd00000000000000000000000000000000000000000000000000000000",
26
+ },
27
+ appended_at: "2026-04-30T10:00:00Z",
28
+ signed_by: "sys",
29
+ },
30
+ {
31
+ seq: 2,
32
+ event_type: "sub_debate_dispatched",
33
+ payload: { crux_id: CRUX_ID, sub_debate_id: SUB_DEBATE_ID },
34
+ appended_at: "2026-04-30T10:01:00Z",
35
+ signed_by: "sys",
36
+ },
37
+ {
38
+ seq: 3,
39
+ event_type: "sub_debate_outcome_observed",
40
+ payload: { crux_id: CRUX_ID, outcome: "converged", bilateral_mandate_intact: true },
41
+ appended_at: "2026-04-30T10:05:00Z",
42
+ signed_by: "sys",
43
+ },
44
+ ];
45
+
46
+ const SOURCE_EVENTS = [
47
+ {
48
+ seq: 1,
49
+ event_type: "crux_map",
50
+ payload: {
51
+ crux_map_data: SOURCE_CRUX_MAP_DATA,
52
+ },
53
+ appended_at: "2026-04-29T10:00:00Z",
54
+ signed_by: "sys",
55
+ },
56
+ ];
57
+
58
+ const SUB_EVENTS = [
59
+ {
60
+ seq: 1,
61
+ event_type: "convergence_outcome",
62
+ payload: {
63
+ event_type: "convergence_outcome",
64
+ outcome: "converged",
65
+ bilateral_mandate_intact: true,
66
+ synthesis_text: "Both sides agree that X is true.",
67
+ open_questions: ["Is Y also true?"],
68
+ citations_a: [],
69
+ citations_b: [],
70
+ final_progress_signal: { phase: "synthesis" },
71
+ },
72
+ appended_at: "2026-04-30T10:04:00Z",
73
+ signed_by: "sys",
74
+ },
75
+ ];
76
+
77
+ // ─── helpers ──────────────────────────────────────────────────────────────────
78
+
79
+ function makeApiMock(overrides: Record<string, unknown> = {}) {
80
+ return {
81
+ getDebate: vi.fn(async (id: string) => {
82
+ if (id === SOURCE_DEBATE_ID) {
83
+ return { debate_id: SOURCE_DEBATE_ID, agent_a_id: "agt_a", agent_b_id: "agt_b", commons_log_id: COMMONS_LOG_CID, counterparty_user_id: "usr_ownerB", status: "active" };
84
+ }
85
+ if (id === SUB_DEBATE_ID) {
86
+ return { debate_id: SUB_DEBATE_ID, agent_a_id: "agt_a", agent_b_id: "agt_b", commons_log_id: SUB_LOG_CID, counterparty_user_id: null, status: "complete" };
87
+ }
88
+ return { debate_id: id, agent_a_id: "agt_a", agent_b_id: "agt_b", commons_log_id: "clg_x", counterparty_user_id: null, status: "active" };
89
+ }),
90
+ getCommonsLogEvents: vi.fn(async (cid: string) => {
91
+ if (cid === RUN_ID) return { events: RUN_EVENTS, next_offset: RUN_EVENTS.length };
92
+ if (cid === COMMONS_LOG_CID) return { events: SOURCE_EVENTS, next_offset: SOURCE_EVENTS.length };
93
+ if (cid === SUB_LOG_CID) return { events: SUB_EVENTS, next_offset: SUB_EVENTS.length };
94
+ return { events: [], next_offset: 0 };
95
+ }),
96
+ discoverPaAgentId: vi.fn(async () => PA_AGENT_ID),
97
+ findExistingMandate: vi.fn(async () => null),
98
+ issueMandate: vi.fn(async () => ({ mandate_id: "mnd_001", scope: [], expires_at: null, principal_agent_id: "agt_a", delegate_agent_id: PA_AGENT_ID })),
99
+ startRun: vi.fn(async () => ({ run_id: RUN_ID, commons_log_id: RUN_ID })),
100
+ acceptOwnerB: vi.fn(async () => ({ ok: true })),
101
+ appendCommonsLog: vi.fn(async () => ({ seq: 10 })),
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ // Mock setup — we test the run command internals by directly calling helpers
107
+ // from the modules rather than invoking the full CLI subprocess.
108
+ // Import the modules we need after mocking.
109
+
110
+ let tmp: string;
111
+
112
+ beforeEach(() => {
113
+ tmp = mkdtempSync(join(tmpdir(), "lc-run-test-"));
114
+ });
115
+
116
+ afterEach(() => {
117
+ rmSync(tmp, { recursive: true, force: true });
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ // ─── staging + hash helpers (unit-level, no API) ──────────────────────────────
122
+
123
+ describe("Owner A first call: bootstraps workspace and writes meta", async () => {
124
+ const { writeRunMeta, readRunMeta } = await import("../src/converge/workspace.js");
125
+ const { computePaBodyHash } = await import("../src/converge/staging.js");
126
+
127
+ it("writeRunMeta / readRunMeta round-trip includes owner_role=a", () => {
128
+ writeRunMeta(tmp, {
129
+ run_id: RUN_ID,
130
+ source_debate_id: SOURCE_DEBATE_ID,
131
+ pa_agent_id: PA_AGENT_ID,
132
+ target_corpus: tmp,
133
+ owner_role: "a",
134
+ });
135
+ const meta = readRunMeta(tmp);
136
+ expect(meta?.run_id).toBe(RUN_ID);
137
+ expect(meta?.owner_role).toBe("a");
138
+ });
139
+
140
+ it("computePaBodyHash is deterministic", () => {
141
+ const body = "# Synthesis\n\nContent\n";
142
+ expect(computePaBodyHash(body)).toBe(computePaBodyHash(body));
143
+ expect(computePaBodyHash(body)).toMatch(/^sha256:[0-9a-f]{64}$/);
144
+ });
145
+ });
146
+
147
+ // ─── staging file write from outcome event ─────────────────────────────────────
148
+
149
+ describe("Sync writes staging file with correct frontmatter", async () => {
150
+ const { writeStaging, stagingPathFor, readStaging } = await import("../src/converge/staging.js");
151
+ const { computePaBodyHash } = await import("../src/converge/staging.js");
152
+
153
+ it("staging file has run_id in path and correct pa_body_hash", () => {
154
+ const stagingDir = join(tmp, "converged", "staging", RUN_ID);
155
+ const op = SUB_EVENTS[0].payload as Record<string, unknown>;
156
+ const body = `# Synthesis\n\n${op.synthesis_text}\n\n# Open questions\n\n- ${(op.open_questions as string[])[0]}\n`;
157
+ const expectedHash = computePaBodyHash(body);
158
+ const path = stagingPathFor(stagingDir, CRUX_ID);
159
+ writeStaging(path, {
160
+ frontmatter: {
161
+ debate_id: SOURCE_DEBATE_ID,
162
+ run_id: RUN_ID,
163
+ crux_id: CRUX_ID,
164
+ sub_debate_chain: [SUB_DEBATE_ID],
165
+ latest_sub_debate_id: SUB_DEBATE_ID,
166
+ source_crux_map_hash: "sha256:aabb",
167
+ generation_id: `gen_${RUN_ID.slice(-8)}`,
168
+ generated_at: "2026-04-30T10:00:00Z",
169
+ pa_body_hash: expectedHash,
170
+ outcome: "converged",
171
+ bilateral_mandate_intact: true,
172
+ citations_a: [],
173
+ citations_b: [],
174
+ mod_progress_summary: { phase: "synthesis" },
175
+ attested_by_user: false,
176
+ },
177
+ userResponse: "",
178
+ body,
179
+ });
180
+ expect(existsSync(path)).toBe(true);
181
+ const loaded = readStaging(path);
182
+ expect(loaded.frontmatter.run_id).toBe(RUN_ID);
183
+ expect(loaded.frontmatter.pa_body_hash).toBe(expectedHash);
184
+ expect(path).toContain(RUN_ID);
185
+ });
186
+ });
187
+
188
+ // ─── _user_response ingestion ─────────────────────────────────────────────────
189
+
190
+ describe("_user_response ingestion clears response and appends section", async () => {
191
+ const { writeStaging, readStaging, stagingPathFor } = await import("../src/converge/staging.js");
192
+
193
+ it("pre-seeded _user_response is cleared and body gets 'Previously clarified' block", () => {
194
+ const stagingDir = join(tmp, "converged", "staging", RUN_ID);
195
+ const path = stagingPathFor(stagingDir, CRUX_ID);
196
+ const initialBody = "# Synthesis\n\nInitial content.\n";
197
+ const userText = "I believe X needs more support.";
198
+
199
+ writeStaging(path, {
200
+ frontmatter: {
201
+ debate_id: SOURCE_DEBATE_ID,
202
+ run_id: RUN_ID,
203
+ crux_id: CRUX_ID,
204
+ sub_debate_chain: [SUB_DEBATE_ID],
205
+ latest_sub_debate_id: SUB_DEBATE_ID,
206
+ source_crux_map_hash: "sha256:aabb",
207
+ generation_id: "gen_11223344",
208
+ generated_at: "2026-04-30T10:00:00Z",
209
+ pa_body_hash: "sha256:xxxx",
210
+ outcome: "needs_input",
211
+ bilateral_mandate_intact: true,
212
+ citations_a: [],
213
+ citations_b: [],
214
+ mod_progress_summary: {},
215
+ attested_by_user: false,
216
+ },
217
+ userResponse: userText,
218
+ body: initialBody,
219
+ });
220
+
221
+ // Simulate ingestion: read, clear, append
222
+ const doc = readStaging(path);
223
+ expect(doc.userResponse).toBe(userText);
224
+ const text = doc.userResponse.trim();
225
+ doc.userResponse = "";
226
+ doc.body = doc.body.trimEnd() + `\n\n# Previously clarified (round 1)\n\n${text}\n`;
227
+ writeStaging(path, doc);
228
+
229
+ const after = readStaging(path);
230
+ expect(after.userResponse).toBe("");
231
+ expect(after.body).toContain("# Previously clarified (round 1)");
232
+ expect(after.body).toContain(userText);
233
+ });
234
+ });
235
+
236
+ // ─── source hash drift detection ─────────────────────────────────────────────
237
+
238
+ describe("source-hash drift guard", async () => {
239
+ const { sha256OfCanonicalJson } = await import("../src/converge/hash.js");
240
+
241
+ it("detects when live hash differs from recorded hash", () => {
242
+ const recordedHash = "sha256:aabbccdd00000000000000000000000000000000000000000000000000000000";
243
+ const liveHash = sha256OfCanonicalJson(SOURCE_CRUX_MAP_DATA);
244
+ // These will differ since recordedHash is a placeholder
245
+ expect(liveHash).not.toBe(recordedHash);
246
+ });
247
+
248
+ it("live hash is stable for the same crux_map_data", () => {
249
+ const h1 = sha256OfCanonicalJson(SOURCE_CRUX_MAP_DATA);
250
+ const h2 = sha256OfCanonicalJson(SOURCE_CRUX_MAP_DATA);
251
+ expect(h1).toBe(h2);
252
+ });
253
+ });
254
+
255
+ // ─── idempotent mandate: findExistingMandate returns existing → issueMandate not called ──
256
+
257
+ describe("Idempotent mandate issuance", () => {
258
+ it("does not call issueMandate when findExistingMandate returns existing", async () => {
259
+ const api = makeApiMock({
260
+ findExistingMandate: vi.fn(async () => ({
261
+ mandate_id: "mnd_existing",
262
+ scope: ["debate.create", "debate.accept"],
263
+ expires_at: null,
264
+ principal_agent_id: "agt_a",
265
+ delegate_agent_id: PA_AGENT_ID,
266
+ })),
267
+ });
268
+
269
+ const existingMandate = await api.findExistingMandate("agt_a", PA_AGENT_ID, ["debate.create", "debate.accept"]);
270
+ if (!existingMandate) {
271
+ await api.issueMandate("agt_a", PA_AGENT_ID, ["debate.create", "debate.accept"]);
272
+ }
273
+
274
+ expect(api.issueMandate).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it("calls issueMandate when findExistingMandate returns null", async () => {
278
+ const api = makeApiMock();
279
+ const existing = await api.findExistingMandate("agt_a", PA_AGENT_ID, ["debate.create", "debate.accept"]);
280
+ if (!existing) {
281
+ await api.issueMandate("agt_a", PA_AGENT_ID, ["debate.create", "debate.accept"]);
282
+ }
283
+
284
+ expect(api.issueMandate).toHaveBeenCalledOnce();
285
+ });
286
+ });
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ parseStaging,
7
+ dumpStaging,
8
+ readStaging,
9
+ writeStaging,
10
+ stagingPathFor,
11
+ listCruxFiles,
12
+ computePaBodyHash,
13
+ type StagingDoc,
14
+ } from "../src/converge/staging.js";
15
+
16
+ const SAMPLE_FM = {
17
+ debate_id: "dbt_001",
18
+ run_id: "clg_run00000001",
19
+ crux_id: "crux_abc",
20
+ sub_debate_chain: ["dbt_sub001"],
21
+ latest_sub_debate_id: "dbt_sub001",
22
+ source_crux_map_hash: "sha256:aabbcc",
23
+ generation_id: "gen_00000001",
24
+ generated_at: "2026-04-30T10:00:00Z",
25
+ pa_body_hash: "sha256:ddeeff",
26
+ outcome: "converged" as const,
27
+ bilateral_mandate_intact: true,
28
+ citations_a: [],
29
+ citations_b: [],
30
+ mod_progress_summary: {},
31
+ attested_by_user: false,
32
+ };
33
+
34
+ const SAMPLE_BODY = "# Synthesis\n\nThey agree on X.\n";
35
+
36
+ const SAMPLE_DOC: StagingDoc = {
37
+ frontmatter: SAMPLE_FM,
38
+ userResponse: "",
39
+ body: SAMPLE_BODY,
40
+ };
41
+
42
+ let tmp: string;
43
+
44
+ beforeEach(() => {
45
+ tmp = mkdtempSync(join(tmpdir(), "lc-staging-test-"));
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(tmp, { recursive: true, force: true });
50
+ });
51
+
52
+ describe("parseStaging / dumpStaging", () => {
53
+ it("round-trips a doc without userResponse", () => {
54
+ const serialized = dumpStaging(SAMPLE_DOC);
55
+ const parsed = parseStaging(serialized);
56
+ expect(parsed.frontmatter.debate_id).toBe(SAMPLE_FM.debate_id);
57
+ expect(parsed.frontmatter.run_id).toBe(SAMPLE_FM.run_id);
58
+ expect(parsed.frontmatter.crux_id).toBe(SAMPLE_FM.crux_id);
59
+ expect(parsed.frontmatter.outcome).toBe(SAMPLE_FM.outcome);
60
+ expect(parsed.frontmatter.attested_by_user).toBe(false);
61
+ expect(parsed.body).toBe(SAMPLE_BODY);
62
+ });
63
+
64
+ it("userResponse is empty string after round-trip when not set", () => {
65
+ const serialized = dumpStaging(SAMPLE_DOC);
66
+ const parsed = parseStaging(serialized);
67
+ expect(parsed.userResponse).toBe("");
68
+ });
69
+
70
+ it("preserves non-empty userResponse through round-trip", () => {
71
+ const doc: StagingDoc = { ...SAMPLE_DOC, userResponse: "I think X is debatable." };
72
+ const serialized = dumpStaging(doc);
73
+ const parsed = parseStaging(serialized);
74
+ expect(parsed.userResponse).toBe("I think X is debatable.");
75
+ // _user_response should not appear in frontmatter object
76
+ expect((parsed.frontmatter as any)._user_response).toBeUndefined();
77
+ });
78
+
79
+ it("round-trip preserves arrays and nested objects in frontmatter", () => {
80
+ const doc: StagingDoc = {
81
+ ...SAMPLE_DOC,
82
+ frontmatter: {
83
+ ...SAMPLE_FM,
84
+ citations_a: [{ source: "page 3", quote: "exact text" }],
85
+ mod_progress_summary: { phase: "synthesis", rounds: 2 },
86
+ },
87
+ };
88
+ const parsed = parseStaging(dumpStaging(doc));
89
+ expect(parsed.frontmatter.citations_a).toEqual([{ source: "page 3", quote: "exact text" }]);
90
+ expect(parsed.frontmatter.mod_progress_summary).toEqual({ phase: "synthesis", rounds: 2 });
91
+ });
92
+
93
+ it("throws on missing opening ---", () => {
94
+ expect(() => parseStaging("not frontmatter\n---\nbody")).toThrow(/frontmatter/);
95
+ });
96
+
97
+ it("throws on missing closing ---", () => {
98
+ expect(() => parseStaging("---\ndebate_id: x\nbody")).toThrow(/closing/);
99
+ });
100
+
101
+ it("body appears after closing --- delimiter", () => {
102
+ const serialized = dumpStaging({ ...SAMPLE_DOC, body: "hello\nworld\n" });
103
+ expect(serialized).toContain("---\nhello\nworld\n");
104
+ });
105
+ });
106
+
107
+ describe("readStaging / writeStaging", () => {
108
+ it("write then read produces equivalent doc", () => {
109
+ const path = stagingPathFor(tmp, "crux_abc");
110
+ writeStaging(path, SAMPLE_DOC);
111
+ const loaded = readStaging(path);
112
+ expect(loaded.frontmatter.crux_id).toBe("crux_abc");
113
+ expect(loaded.body).toBe(SAMPLE_BODY);
114
+ expect(loaded.userResponse).toBe("");
115
+ });
116
+
117
+ it("writeStaging creates intermediate directories", () => {
118
+ const nested = join(tmp, "a", "b", "c");
119
+ const path = stagingPathFor(nested, "crux_xyz");
120
+ // Should not throw
121
+ writeStaging(path, SAMPLE_DOC);
122
+ const loaded = readStaging(path);
123
+ expect(loaded.frontmatter.crux_id).toBe("crux_abc");
124
+ });
125
+ });
126
+
127
+ describe("listCruxFiles", () => {
128
+ it("returns empty array for non-existent directory", () => {
129
+ expect(listCruxFiles(join(tmp, "nonexistent"))).toEqual([]);
130
+ });
131
+
132
+ it("returns .md files only", () => {
133
+ writeStaging(stagingPathFor(tmp, "crux_001"), SAMPLE_DOC);
134
+ writeStaging(stagingPathFor(tmp, "crux_002"), SAMPLE_DOC);
135
+ const files = listCruxFiles(tmp);
136
+ expect(files).toHaveLength(2);
137
+ expect(files.every((f) => f.endsWith(".md"))).toBe(true);
138
+ });
139
+
140
+ it("skips files starting with a dot", () => {
141
+ writeStaging(stagingPathFor(tmp, "crux_001"), SAMPLE_DOC);
142
+ writeFileSync(join(tmp, ".lock"), "{}");
143
+ const files = listCruxFiles(tmp);
144
+ expect(files.every((f) => !f.startsWith("."))).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe("computePaBodyHash", () => {
149
+ it("returns sha256: prefix followed by 64 hex chars", () => {
150
+ expect(computePaBodyHash("some body text")).toMatch(/^sha256:[0-9a-f]{64}$/);
151
+ });
152
+
153
+ it("is deterministic over identical input", () => {
154
+ const body = "# Synthesis\n\nContent here.\n";
155
+ expect(computePaBodyHash(body)).toBe(computePaBodyHash(body));
156
+ });
157
+
158
+ it("produces different hashes for different bodies", () => {
159
+ expect(computePaBodyHash("body A")).not.toBe(computePaBodyHash("body B"));
160
+ });
161
+ });