@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,969 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { Command } from "commander";
5
+ import { runCommand } from "../output.js";
6
+ import { buildContext, type CliContext } from "../context.js";
7
+ import { LinkedClawError } from "../errors.js";
8
+ import { makeConvergeApi } from "../converge/api.js";
9
+ import { sha256OfCanonicalJson } from "../converge/hash.js";
10
+ import { acquireLock } from "../converge/lock.js";
11
+ import {
12
+ computePaBodyHash,
13
+ dumpStaging,
14
+ listCruxFiles,
15
+ readStaging,
16
+ stagingPathFor,
17
+ writeStaging,
18
+ type StagingFrontmatter,
19
+ } from "../converge/staging.js";
20
+ import { readRunMeta, resolveWorkspace, writeRunMeta } from "../converge/workspace.js";
21
+ import type {
22
+ CommonsLogEvent,
23
+ ConvergenceAttestation,
24
+ ConvergenceDecisionAction,
25
+ CruxDecisionRequest,
26
+ RunMeta,
27
+ RunStateSummary,
28
+ RunWorkspace,
29
+ } from "../converge/types.js";
30
+
31
+ // ─── inline helpers ──────────────────────────────────────────────────────────
32
+
33
+ function resolveAbs(p: string): string {
34
+ return isAbsolute(p) ? p : resolve(process.cwd(), p);
35
+ }
36
+
37
+ async function getMyUserId(ctx: CliContext): Promise<string> {
38
+ const me = await ctx.consumer.getMe();
39
+ if (!me.user_id) throw new LinkedClawError("no_user_id", "Could not determine user_id from /api/v1/me");
40
+ return me.user_id;
41
+ }
42
+
43
+ function recomputeSourceCruxMapHash(events: CommonsLogEvent[]): string | null {
44
+ const ev = [...events].reverse().find((e) => e.event_type === "crux_map");
45
+ if (!ev) return null;
46
+ return sha256OfCanonicalJson(ev.payload.crux_map_data);
47
+ }
48
+
49
+ function recordedSourceHash(events: CommonsLogEvent[]): string | null {
50
+ const ev = events.find((e) => e.event_type === "run_started");
51
+ if (!ev) return null;
52
+ return typeof ev.payload.source_crux_map_hash === "string" ? ev.payload.source_crux_map_hash : null;
53
+ }
54
+
55
+ function buildPaBody(op: Record<string, unknown>): string {
56
+ const synthesis = typeof op.synthesis_text === "string" ? op.synthesis_text : "";
57
+ const questions = Array.isArray(op.open_questions) ? op.open_questions : [];
58
+ const qText = questions.map((q: unknown) => `- ${String(q)}`).join("\n");
59
+ return `# Synthesis\n\n${synthesis}\n\n# Open questions\n\n${qText || "(none)"}\n`;
60
+ }
61
+
62
+ function countPreviouslyClarifiedSections(body: string): number {
63
+ const m = body.match(/^# Previously clarified \(round \d+\)/gm);
64
+ return m ? m.length : 0;
65
+ }
66
+
67
+ function slugify(s: string, maxLen = 64): string {
68
+ const base = s.trim().toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-+|-+$/g, "")
71
+ .slice(0, maxLen)
72
+ .replace(/-+$/g, "");
73
+ return base || "untitled";
74
+ }
75
+
76
+ function extractSynthesisSlug(body: string, maxLen = 32): string {
77
+ const synthIdx = body.indexOf("# Synthesis");
78
+ const search = synthIdx >= 0 ? body.slice(synthIdx) : body;
79
+ const lines = search.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
80
+ const first = lines[0] ?? "";
81
+ return slugify(first, maxLen);
82
+ }
83
+
84
+ function tryGitAdd(absPath: string): string | null {
85
+ try {
86
+ const r = spawnSync("git", ["add", absPath], { encoding: "utf8" });
87
+ if (r.error) return `git_add_failed: ${r.error.message}`;
88
+ if (r.status !== 0) return `git_add_failed: ${(r.stderr || "").trim() || `exit ${r.status}`}`;
89
+ return null;
90
+ } catch (e) {
91
+ return `git_add_failed: ${(e as Error).message}`;
92
+ }
93
+ }
94
+
95
+ function assertSafeCruxId(cruxId: string): void {
96
+ if (!cruxId || cruxId.includes("/") || cruxId.includes("\\") || cruxId.includes("..") || cruxId.includes("\0")) {
97
+ throw new LinkedClawError("invalid_crux_id", `Invalid crux_id for local file operation: ${cruxId}`);
98
+ }
99
+ }
100
+
101
+ function assertInside(parentDir: string, childPath: string): void {
102
+ const parent = resolve(parentDir);
103
+ const child = resolve(childPath);
104
+ const rel = relative(parent, child);
105
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return;
106
+ throw new LinkedClawError("path_escape", `Refusing local path outside ${parentDir}: ${childPath}`);
107
+ }
108
+
109
+ function safeStagingPathFor(stagingDir: string, cruxId: string): string {
110
+ assertSafeCruxId(cruxId);
111
+ const path = stagingPathFor(stagingDir, cruxId);
112
+ assertInside(stagingDir, path);
113
+ return path;
114
+ }
115
+
116
+ function safeAcceptedPath(finalDir: string, cruxId: string, synthSlug: string): string {
117
+ assertSafeCruxId(cruxId);
118
+ const path = join(finalDir, `${cruxId}__${synthSlug}.md`);
119
+ assertInside(finalDir, path);
120
+ return path;
121
+ }
122
+
123
+ function computeDecisionBodyHash(
124
+ synthesisText: string,
125
+ citationsA: Array<Record<string, unknown>>,
126
+ citationsB: Array<Record<string, unknown>>,
127
+ ): string {
128
+ return sha256OfCanonicalJson({
129
+ citations_a: citationsA,
130
+ citations_b: citationsB,
131
+ synthesis_text: synthesisText,
132
+ });
133
+ }
134
+
135
+ function eventKind(ev: CommonsLogEvent): string {
136
+ return typeof ev.payload.event_type === "string" ? ev.payload.event_type : ev.event_type;
137
+ }
138
+
139
+ function decisionEventTypeForAction(action: ConvergenceDecisionAction): string {
140
+ if (action === "accept") return "accept_attestation";
141
+ if (action === "reject") return "reject_attestation";
142
+ return "attest_only";
143
+ }
144
+
145
+ function latestConvergenceMapEvent(events: CommonsLogEvent[]): CommonsLogEvent {
146
+ const operatorUserId = process.env.LINKEDCLAW_OPERATOR_USER_ID ?? "usr_operator";
147
+ const reversed = [...events].reverse();
148
+ const ev = reversed.find((e) => eventKind(e) === "convergence_map" && e.signed_by === operatorUserId);
149
+ if (!ev) {
150
+ throw new LinkedClawError(
151
+ "convergence_map_not_found",
152
+ `No PA-signed convergence_map event found for this run (expected signed_by=${operatorUserId}).`,
153
+ );
154
+ }
155
+ return ev;
156
+ }
157
+
158
+ function latestConvergenceMap(events: CommonsLogEvent[]): Record<string, unknown> {
159
+ return latestConvergenceMapEvent(events).payload;
160
+ }
161
+
162
+ function getCruxFromMap(map: Record<string, unknown>, cruxId: string): Record<string, unknown> {
163
+ const cruxes = Array.isArray(map.cruxes) ? map.cruxes : [];
164
+ const crux = cruxes.find(
165
+ (c: unknown) => c && typeof c === "object" && (c as Record<string, unknown>).crux_id === cruxId,
166
+ );
167
+ if (!crux || typeof crux !== "object") {
168
+ throw new LinkedClawError("crux_not_found", `crux ${cruxId} not found in latest convergence_map.`);
169
+ }
170
+ return crux as Record<string, unknown>;
171
+ }
172
+
173
+ function citationsFromCrux(value: unknown): Array<Record<string, unknown>> {
174
+ if (!Array.isArray(value)) return [];
175
+ return value.filter((v): v is Record<string, unknown> => v != null && typeof v === "object" && !Array.isArray(v));
176
+ }
177
+
178
+ function classifyDecisionAttestation(
179
+ action: ConvergenceDecisionAction,
180
+ outcome: string,
181
+ bilateralMandateIntact: boolean,
182
+ synthesisEdited = false,
183
+ ): ConvergenceAttestation {
184
+ if (action === "attest") return "user_attested_no_dialog";
185
+ if (action === "reject") return "user_attested_with_network_context";
186
+ if (outcome === "already_aligned") {
187
+ throw new LinkedClawError(
188
+ "attest_required",
189
+ "outcome=already_aligned must be decided with `lc converge attest`.",
190
+ );
191
+ }
192
+ if ((outcome === "converged" || outcome === "partial_overlap") && bilateralMandateIntact && !synthesisEdited) {
193
+ return "bilateral_convergence";
194
+ }
195
+ return "user_attested_with_network_context";
196
+ }
197
+
198
+ function decisionPayloadCitations(value: unknown): Array<Record<string, unknown>> {
199
+ return citationsFromCrux(value);
200
+ }
201
+
202
+ function stringFromPayload(payload: Record<string, unknown>, key: string): string {
203
+ const value = payload[key];
204
+ if (typeof value !== "string" || value.length === 0) {
205
+ throw new LinkedClawError("decision_payload_invalid", `terminal decision missing ${key}.`);
206
+ }
207
+ return value;
208
+ }
209
+
210
+ function booleanFromPayload(payload: Record<string, unknown>, key: string): boolean {
211
+ const value = payload[key];
212
+ if (typeof value !== "boolean") {
213
+ throw new LinkedClawError("decision_payload_invalid", `terminal decision missing ${key}.`);
214
+ }
215
+ return value;
216
+ }
217
+
218
+ function attestationFromPayload(payload: Record<string, unknown>): ConvergenceAttestation {
219
+ const value = stringFromPayload(payload, "attestation");
220
+ if (
221
+ value !== "bilateral_convergence" &&
222
+ value !== "user_attested_with_network_context" &&
223
+ value !== "user_attested_no_dialog"
224
+ ) {
225
+ throw new LinkedClawError("decision_payload_invalid", `terminal decision has invalid attestation: ${value}`);
226
+ }
227
+ return value;
228
+ }
229
+
230
+ function terminalOutcomeFromPayload(payload: Record<string, unknown>): StagingFrontmatter["outcome"] {
231
+ const value = stringFromPayload(payload, "terminal_outcome");
232
+ if (
233
+ value !== "converged" &&
234
+ value !== "partial_overlap" &&
235
+ value !== "needs_input" &&
236
+ value !== "irreconcilable" &&
237
+ value !== "already_aligned"
238
+ ) {
239
+ throw new LinkedClawError("decision_payload_invalid", `terminal decision has invalid outcome: ${value}`);
240
+ }
241
+ return value;
242
+ }
243
+
244
+ async function buildCruxDecisionRequest(
245
+ api: ReturnType<typeof makeConvergeApi>,
246
+ ws: RunWorkspace,
247
+ cruxId: string,
248
+ action: ConvergenceDecisionAction,
249
+ opts: { message?: string } = {},
250
+ ): Promise<CruxDecisionRequest> {
251
+ assertSafeCruxId(cruxId);
252
+ const { events } = await api.getCommonsLogEvents(ws.runId, { limit: 5000 });
253
+ const map = latestConvergenceMap(events);
254
+ const crux = getCruxFromMap(map, cruxId);
255
+ const generationId = typeof crux.generation_id === "string" ? crux.generation_id : "";
256
+ const sourceHash = typeof map.source_crux_map_hash === "string" ? map.source_crux_map_hash : "";
257
+ const outcome = typeof crux.outcome === "string" ? crux.outcome : "";
258
+ const latestSubDebateId =
259
+ typeof crux.latest_sub_debate_id === "string" ? crux.latest_sub_debate_id : null;
260
+ const bilateralMandateIntact =
261
+ typeof crux.bilateral_mandate_intact_at_outcome === "boolean"
262
+ ? crux.bilateral_mandate_intact_at_outcome
263
+ : typeof crux.bilateral_mandate_intact === "boolean"
264
+ ? crux.bilateral_mandate_intact
265
+ : false;
266
+ const synthesisText = typeof crux.synthesis_text === "string" ? crux.synthesis_text : "";
267
+ const citationsA = citationsFromCrux(crux.citations_a);
268
+ const citationsB = citationsFromCrux(crux.citations_b);
269
+
270
+ if (!generationId) throw new LinkedClawError("missing_generation_id", `crux ${cruxId} has no generation_id.`);
271
+ if (!sourceHash) throw new LinkedClawError("missing_source_hash", "latest convergence_map has no source_crux_map_hash.");
272
+ if (!outcome) throw new LinkedClawError("missing_outcome", `crux ${cruxId} has no outcome.`);
273
+ if (action === "accept" && outcome === "already_aligned") {
274
+ throw new LinkedClawError(
275
+ "attest_required",
276
+ "outcome=already_aligned must be decided with `lc converge attest`.",
277
+ );
278
+ }
279
+ if (!synthesisText) throw new LinkedClawError("missing_synthesis_text", `crux ${cruxId} has no synthesis_text.`);
280
+
281
+ const paBodyHash = computeDecisionBodyHash(synthesisText, citationsA, citationsB);
282
+ let acceptedSynthesisText = synthesisText;
283
+ let synthesisEdited = false;
284
+ if (action === "accept") {
285
+ const stagingPath = safeStagingPathFor(ws.stagingDir, cruxId);
286
+ if (existsSync(stagingPath)) {
287
+ const doc = readStaging(stagingPath);
288
+ synthesisEdited = computePaBodyHash(doc.body) !== doc.frontmatter.pa_body_hash;
289
+ if (synthesisEdited) acceptedSynthesisText = doc.body;
290
+ }
291
+ }
292
+ const acceptedBodyHash = computeDecisionBodyHash(acceptedSynthesisText, citationsA, citationsB);
293
+ return {
294
+ convergence_map_generation_id: generationId,
295
+ source_crux_map_hash: sourceHash,
296
+ latest_sub_debate_id: latestSubDebateId,
297
+ terminal_outcome: outcome as CruxDecisionRequest["terminal_outcome"],
298
+ bilateral_mandate_intact: bilateralMandateIntact,
299
+ attestation: classifyDecisionAttestation(action, outcome, bilateralMandateIntact, synthesisEdited),
300
+ synthesis_edited: synthesisEdited,
301
+ pa_body_hash: paBodyHash,
302
+ accepted_body_hash: acceptedBodyHash,
303
+ synthesis_text: acceptedSynthesisText,
304
+ citations_a: citationsA,
305
+ citations_b: citationsB,
306
+ ...(opts.message ? { user_message: opts.message } : {}),
307
+ };
308
+ }
309
+
310
+ async function postCruxDecision(
311
+ api: ReturnType<typeof makeConvergeApi>,
312
+ ws: RunWorkspace,
313
+ cruxId: string,
314
+ action: ConvergenceDecisionAction,
315
+ opts: { message?: string } = {},
316
+ ): Promise<{ event_id: string; body: CruxDecisionRequest }> {
317
+ assertSafeCruxId(cruxId);
318
+ const body = await buildCruxDecisionRequest(api, ws, cruxId, action, opts);
319
+ const resp =
320
+ action === "accept"
321
+ ? await api.acceptCruxDecision(ws.runId, cruxId, body)
322
+ : action === "reject"
323
+ ? await api.rejectCruxDecision(ws.runId, cruxId, body)
324
+ : await api.attestCruxDecision(ws.runId, cruxId, body);
325
+ return { event_id: resp.event_id, body };
326
+ }
327
+
328
+ async function materializeAcceptedCrux(
329
+ ctx: CliContext,
330
+ api: ReturnType<typeof makeConvergeApi>,
331
+ ws: RunWorkspace,
332
+ cruxId: string,
333
+ payload: Record<string, unknown>,
334
+ opts: { message?: string } = {},
335
+ ): Promise<{ accepted_path?: string; attestation?: ConvergenceAttestation; synthesis_edited?: boolean; warning?: string }> {
336
+ const stagingPath = safeStagingPathFor(ws.stagingDir, cruxId);
337
+ if (!existsSync(stagingPath)) return { warning: `staging_not_found: ${stagingPath}` };
338
+
339
+ const doc = readStaging(stagingPath);
340
+ const fm = doc.frontmatter;
341
+ const sourceDebate = await api.getDebate(ws.sourceDebateId);
342
+ const body = stringFromPayload(payload, "synthesis_text");
343
+ const citationsA = decisionPayloadCitations(payload.citations_a);
344
+ const citationsB = decisionPayloadCitations(payload.citations_b);
345
+ const acceptedBodyHash = stringFromPayload(payload, "accepted_body_hash");
346
+ const computedAcceptedBodyHash = computeDecisionBodyHash(body, citationsA, citationsB);
347
+ if (acceptedBodyHash !== computedAcceptedBodyHash) {
348
+ return { warning: `decision_body_hash_mismatch: ${cruxId}` };
349
+ }
350
+ const paBodyHash = stringFromPayload(payload, "pa_body_hash");
351
+ const synthesisEdited = booleanFromPayload(payload, "synthesis_edited");
352
+ const attestation = attestationFromPayload(payload);
353
+ const terminalOutcome = terminalOutcomeFromPayload(payload);
354
+ const generationId = stringFromPayload(payload, "convergence_map_generation_id");
355
+ const me = await getMyUserId(ctx);
356
+ const acceptedDoc = {
357
+ frontmatter: {
358
+ ...fm,
359
+ generation_id: generationId,
360
+ pa_body_hash: paBodyHash,
361
+ outcome: terminalOutcome,
362
+ bilateral_mandate_intact: booleanFromPayload(payload, "bilateral_mandate_intact"),
363
+ citations_a: citationsA,
364
+ citations_b: citationsB,
365
+ provenance: {
366
+ signed_off_by: me,
367
+ signed_off_at: new Date().toISOString(),
368
+ accepted_generation_id: generationId,
369
+ attestation,
370
+ synthesis_edited: synthesisEdited,
371
+ ...(opts.message ? { user_message: opts.message } : {}),
372
+ ...(synthesisEdited
373
+ ? { pa_body_hash: paBodyHash, accepted_body_hash: acceptedBodyHash }
374
+ : {}),
375
+ },
376
+ },
377
+ userResponse: "",
378
+ body,
379
+ };
380
+ const topicSlug = slugify(sourceDebate.topic ?? ws.sourceDebateId);
381
+ const synthSlug = extractSynthesisSlug(body);
382
+ const finalDir = join(ws.targetCorpus, "converged", topicSlug);
383
+ const finalPath = safeAcceptedPath(finalDir, cruxId, synthSlug);
384
+
385
+ if (existsSync(finalPath) && readStaging(finalPath).body !== acceptedDoc.body) {
386
+ return { warning: `sync_conflict: ${finalPath}` };
387
+ }
388
+ mkdirSync(finalDir, { recursive: true });
389
+ if (!existsSync(finalPath) || dumpStaging(readStaging(finalPath)) !== dumpStaging(acceptedDoc)) {
390
+ writeStaging(finalPath, acceptedDoc);
391
+ }
392
+ unlinkSync(stagingPath);
393
+ const gitWarning = tryGitAdd(finalPath);
394
+ return {
395
+ accepted_path: finalPath,
396
+ attestation,
397
+ synthesis_edited: synthesisEdited,
398
+ ...(gitWarning ? { warning: gitWarning } : {}),
399
+ };
400
+ }
401
+
402
+ async function syncTerminalDecisions(
403
+ ctx: CliContext,
404
+ api: ReturnType<typeof makeConvergeApi>,
405
+ ws: RunWorkspace,
406
+ opts: {
407
+ cruxId?: string;
408
+ message?: string;
409
+ injectedTerminal?: { cruxId: string; eventType: string; payload: Record<string, unknown> };
410
+ } = {},
411
+ ): Promise<{ materialized: string[]; cleaned: string[]; warnings: string[] }> {
412
+ if (opts.cruxId) assertSafeCruxId(opts.cruxId);
413
+ const { events } = await api.getCommonsLogEvents(ws.runId, { limit: 5000 });
414
+ const warnings: string[] = [];
415
+ const mapEvent = latestConvergenceMapEvent(events);
416
+ const map = mapEvent.payload;
417
+ const cruxGeneration = new Map<string, string>();
418
+ for (const crux of Array.isArray(map.cruxes) ? map.cruxes : []) {
419
+ if (!crux || typeof crux !== "object" || Array.isArray(crux)) continue;
420
+ const c = crux as Record<string, unknown>;
421
+ if (typeof c.crux_id === "string" && typeof c.generation_id === "string") {
422
+ cruxGeneration.set(c.crux_id, c.generation_id);
423
+ }
424
+ }
425
+ const terminal = new Map<string, { eventType: string; payload: Record<string, unknown> }>();
426
+ for (const ev of [...events].sort((a, b) => a.seq - b.seq)) {
427
+ if (ev.seq <= mapEvent.seq) continue;
428
+ if (ev.signed_by !== mapEvent.signed_by) continue;
429
+ const eventType = eventKind(ev);
430
+ if (!["accept_attestation", "reject_attestation", "attest_only"].includes(eventType)) continue;
431
+ const cid = typeof ev.payload.crux_id === "string" ? ev.payload.crux_id : "";
432
+ if (!cid || terminal.has(cid)) continue;
433
+ try {
434
+ assertSafeCruxId(cid);
435
+ } catch (err) {
436
+ warnings.push(`${cid}: ${(err as Error).message}`);
437
+ continue;
438
+ }
439
+ if (ev.payload.convergence_map_generation_id !== cruxGeneration.get(cid)) continue;
440
+ terminal.set(cid, { eventType, payload: ev.payload });
441
+ }
442
+ if (opts.injectedTerminal) {
443
+ assertSafeCruxId(opts.injectedTerminal.cruxId);
444
+ terminal.set(opts.injectedTerminal.cruxId, {
445
+ eventType: opts.injectedTerminal.eventType,
446
+ payload: opts.injectedTerminal.payload,
447
+ });
448
+ }
449
+
450
+ const materialized: string[] = [];
451
+ const cleaned: string[] = [];
452
+ const release = acquireLock(ws.stagingDir);
453
+ try {
454
+ for (const [cid, terminalEvent] of terminal.entries()) {
455
+ if (opts.cruxId && cid !== opts.cruxId) continue;
456
+ const stagingPath = safeStagingPathFor(ws.stagingDir, cid);
457
+ const eventType = terminalEvent.eventType;
458
+ if (eventType === "accept_attestation") {
459
+ const result = await materializeAcceptedCrux(ctx, api, ws, cid, terminalEvent.payload, opts);
460
+ if (result.accepted_path) materialized.push(result.accepted_path);
461
+ if (result.warning) warnings.push(`${cid}: ${result.warning}`);
462
+ continue;
463
+ }
464
+ if (existsSync(stagingPath)) {
465
+ unlinkSync(stagingPath);
466
+ cleaned.push(cid);
467
+ }
468
+ }
469
+ } finally {
470
+ release();
471
+ }
472
+ return { materialized, cleaned, warnings };
473
+ }
474
+
475
+ // ─── command registration ─────────────────────────────────────────────────────
476
+
477
+ export function registerConvergeCommands(program: Command): void {
478
+ const converge = program
479
+ .command("converge")
480
+ .description("Convergence: bilateral merger of two crux maps into shared corpus");
481
+
482
+ converge
483
+ .command("run [ref]")
484
+ .description("Start a convergence run (Owner A) or accept (Owner B with --accept), then sync staging")
485
+ .option("--target-corpus <path>", "Absolute path to the target corpus directory")
486
+ .option("--staging-dir <path>", "Override staging directory")
487
+ .option("--accept", "Owner B: accept an existing run by run_id")
488
+ .option("--source-debate-id <id>", "Owner B fallback when /convergence/runs/{run_id} is unavailable")
489
+ .option("--force-regenerate", "Bypass source-hash drift check and regenerate staging files")
490
+ .option("--wait <secs>", "Poll until terminal_emitted or timeout", parseInt)
491
+ .action(
492
+ async (
493
+ ref: string | undefined,
494
+ opts: {
495
+ targetCorpus?: string;
496
+ stagingDir?: string;
497
+ accept?: boolean;
498
+ sourceDebateId?: string;
499
+ forceRegenerate?: boolean;
500
+ wait?: number;
501
+ },
502
+ ) => {
503
+ await runCommand(async () => {
504
+ const ctx = buildContext();
505
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
506
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
507
+
508
+ let ws: RunWorkspace;
509
+ const resolvedStagingDir = opts.stagingDir ? resolveAbs(opts.stagingDir) : null;
510
+ const metaExisting = resolvedStagingDir ? readRunMeta(resolvedStagingDir) : null;
511
+
512
+ if (opts.accept) {
513
+ // Owner B path: ref = run_id
514
+ if (!ref) {
515
+ throw new LinkedClawError("missing_run_id", "--accept requires the run_id as a positional argument.");
516
+ }
517
+ const runId = ref;
518
+ if (metaExisting && resolvedStagingDir) {
519
+ const meta = metaExisting;
520
+ ws = {
521
+ runId: meta.run_id,
522
+ sourceDebateId: meta.source_debate_id,
523
+ paAgentId: meta.pa_agent_id,
524
+ targetCorpus: meta.target_corpus,
525
+ stagingDir: resolvedStagingDir,
526
+ };
527
+ } else {
528
+ if (!opts.targetCorpus) {
529
+ throw new LinkedClawError(
530
+ "missing_target_corpus",
531
+ "Owner B --accept requires --target-corpus on first call.",
532
+ );
533
+ }
534
+ const targetCorpus = resolveAbs(opts.targetCorpus);
535
+ // Owner B can't read the run-log until they accept (membership
536
+ // gate). Default path: GET /convergence/runs/{run_id}. Fallback:
537
+ // user passes --source-debate-id explicitly for older servers.
538
+ let sourceDebateId: string;
539
+ let paAgentId: string;
540
+ let principalAgentId: string;
541
+ if (opts.sourceDebateId) {
542
+ sourceDebateId = opts.sourceDebateId;
543
+ paAgentId = await api.discoverPaAgentId();
544
+ const sourceDebate = await api.getDebate(sourceDebateId);
545
+ principalAgentId = sourceDebate.agent_b_id;
546
+ } else {
547
+ const runMeta = await api.getRun(runId);
548
+ sourceDebateId = runMeta.source_debate_id;
549
+ paAgentId = runMeta.pa_agent_id;
550
+ principalAgentId = runMeta.agent_b_id;
551
+ }
552
+ const existing = await api.findExistingMandate(principalAgentId, paAgentId, ["debate.create", "debate.accept"]);
553
+ if (!existing) await api.issueMandate(principalAgentId, paAgentId, ["debate.create", "debate.accept"]);
554
+ await api.acceptOwnerB(runId);
555
+ const stagingDir = join(targetCorpus, "converged", "staging", runId);
556
+ mkdirSync(stagingDir, { recursive: true });
557
+ const meta: RunMeta = {
558
+ run_id: runId,
559
+ source_debate_id: sourceDebateId,
560
+ pa_agent_id: paAgentId,
561
+ target_corpus: targetCorpus,
562
+ owner_role: "b",
563
+ };
564
+ writeRunMeta(stagingDir, meta);
565
+ ws = { runId, sourceDebateId, paAgentId, targetCorpus, stagingDir };
566
+ }
567
+ } else if (!metaExisting) {
568
+ // Owner A first call: ref = source_debate_id
569
+ if (!ref) {
570
+ throw new LinkedClawError(
571
+ "missing_source_debate_id",
572
+ "First run requires a source_debate_id argument. Use --staging-dir to resume an existing run.",
573
+ );
574
+ }
575
+ if (!opts.targetCorpus) {
576
+ throw new LinkedClawError("missing_target_corpus", "First call requires --target-corpus.");
577
+ }
578
+ const targetCorpus = resolveAbs(opts.targetCorpus);
579
+ const paAgentId = await api.discoverPaAgentId();
580
+ // Owner A's principal_agent_id is the source debate's agent_a_id.
581
+ // start_run will look up the mandate keyed on that agent.
582
+ const sourceDebate = await api.getDebate(ref);
583
+ const principalAgentId = sourceDebate.agent_a_id;
584
+ const existing = await api.findExistingMandate(principalAgentId, paAgentId, ["debate.create", "debate.accept"]);
585
+ if (!existing) await api.issueMandate(principalAgentId, paAgentId, ["debate.create", "debate.accept"]);
586
+ const { run_id } = await api.startRun(ref);
587
+ const stagingDir = join(targetCorpus, "converged", "staging", run_id);
588
+ mkdirSync(stagingDir, { recursive: true });
589
+ const meta: RunMeta = {
590
+ run_id,
591
+ source_debate_id: ref,
592
+ pa_agent_id: paAgentId,
593
+ target_corpus: targetCorpus,
594
+ owner_role: "a",
595
+ };
596
+ writeRunMeta(stagingDir, meta);
597
+ ws = { runId: run_id, sourceDebateId: ref, paAgentId, targetCorpus, stagingDir };
598
+ } else {
599
+ // Subsequent call: use existing meta from stagingDir
600
+ ws = await resolveWorkspace({ stagingDir: opts.stagingDir });
601
+ }
602
+
603
+ // ─── helper: write per-crux staging files from current crux list ─
604
+ const refreshStaging = async (
605
+ cruxes: RunStateSummary["cruxes"],
606
+ canonicalSourceHash: string,
607
+ ): Promise<void> => {
608
+ for (const c of cruxes) {
609
+ if (!c.latest_sub_debate_id) continue;
610
+ const subD = await api.getDebate(c.latest_sub_debate_id);
611
+ const subEvs = await api.getCommonsLogEvents(subD.commons_log_id, { limit: 2000 });
612
+ const outcomeEv = [...subEvs.events]
613
+ .reverse()
614
+ .find(
615
+ (e) =>
616
+ (e.payload as any).event_type === "convergence_outcome" ||
617
+ e.event_type === "convergence_outcome",
618
+ );
619
+ if (!outcomeEv) continue;
620
+ const op = outcomeEv.payload as Record<string, unknown>;
621
+ const body = buildPaBody(op);
622
+ const newPaBodyHash = computePaBodyHash(body);
623
+ const path = safeStagingPathFor(ws.stagingDir, c.crux_id);
624
+ const existingDoc = existsSync(path) ? readStaging(path) : null;
625
+ // Skip overwrite when PA hasn't emitted new content — preserves Step 1 clarification history
626
+ if (existingDoc && existingDoc.frontmatter.pa_body_hash === newPaBodyHash) continue;
627
+ const fm: StagingFrontmatter = {
628
+ debate_id: ws.sourceDebateId,
629
+ run_id: ws.runId,
630
+ crux_id: c.crux_id,
631
+ sub_debate_chain: c.sub_debate_chain,
632
+ latest_sub_debate_id: c.latest_sub_debate_id,
633
+ source_crux_map_hash: canonicalSourceHash,
634
+ generation_id: `gen_${ws.runId.slice(-8)}`,
635
+ generated_at: new Date().toISOString(),
636
+ pa_body_hash: newPaBodyHash,
637
+ outcome: (op.outcome ?? c.outcome) as StagingFrontmatter["outcome"],
638
+ bilateral_mandate_intact:
639
+ typeof op.bilateral_mandate_intact === "boolean"
640
+ ? op.bilateral_mandate_intact
641
+ : (c.bilateral_mandate_intact ?? false),
642
+ citations_a: Array.isArray(op.citations_a) ? op.citations_a : [],
643
+ citations_b: Array.isArray(op.citations_b) ? op.citations_b : [],
644
+ mod_progress_summary:
645
+ op.final_progress_signal != null &&
646
+ typeof op.final_progress_signal === "object" &&
647
+ !Array.isArray(op.final_progress_signal)
648
+ ? (op.final_progress_signal as Record<string, unknown>)
649
+ : {},
650
+ attested_by_user: existingDoc?.frontmatter.attested_by_user ?? false,
651
+ };
652
+ writeStaging(path, { frontmatter: fm, userResponse: existingDoc?.userResponse ?? "", body });
653
+ }
654
+ };
655
+
656
+ // ─── acquire lock for Steps 1–4 ──────────────────────────────────
657
+ let summary!: RunStateSummary;
658
+ let canonicalSourceHash = "";
659
+
660
+ {
661
+ const release = acquireLock(ws.stagingDir);
662
+ try {
663
+ // ─── Step 1: ingest pending _user_response blocks ──────────────
664
+ for (const fn of listCruxFiles(ws.stagingDir)) {
665
+ const path = join(ws.stagingDir, fn);
666
+ const doc = readStaging(path);
667
+ const text = (doc.userResponse || "").trim();
668
+ if (!text) continue;
669
+ const subDebateId = doc.frontmatter.latest_sub_debate_id;
670
+ if (!subDebateId) continue;
671
+ const subDebate = await api.getDebate(subDebateId);
672
+ await api.appendCommonsLog(subDebate.commons_log_id, "owner_clarification", {
673
+ event_type: "owner_clarification",
674
+ content: text,
675
+ in_response_to_event_id: null,
676
+ });
677
+ const round = countPreviouslyClarifiedSections(doc.body) + 1;
678
+ doc.userResponse = "";
679
+ doc.body = doc.body.trimEnd() + `\n\n# Previously clarified (round ${round})\n\n${text}\n`;
680
+ writeStaging(path, doc);
681
+ }
682
+
683
+ // ─── Step 2: sync from run-log ─────────────────────────────────
684
+ const { events } = await api.getCommonsLogEvents(ws.runId, { limit: 5000 });
685
+ summary = reduceRunState(ws, events);
686
+
687
+ // ─── Step 3: integrity guard ───────────────────────────────────
688
+ const sourceDebate = await api.getDebate(ws.sourceDebateId);
689
+ const sourceEvents = await api.getCommonsLogEvents(sourceDebate.commons_log_id, {
690
+ limit: 2000,
691
+ });
692
+ const liveSourceHash = recomputeSourceCruxMapHash(sourceEvents.events);
693
+ const recordedHash = recordedSourceHash(events);
694
+ if (recordedHash && liveSourceHash && liveSourceHash !== recordedHash && !opts.forceRegenerate) {
695
+ throw new LinkedClawError(
696
+ "source_crux_map_drift",
697
+ `Source crux-map changed since run started (recorded=${recordedHash} live=${liveSourceHash}). Re-run with --force-regenerate.`,
698
+ );
699
+ }
700
+ canonicalSourceHash = recordedHash ?? liveSourceHash ?? "";
701
+
702
+ // ─── Step 4: per-crux staging file refresh ─────────────────────
703
+ await refreshStaging(summary.cruxes, canonicalSourceHash);
704
+ } finally {
705
+ release();
706
+ }
707
+ }
708
+
709
+ // ─── Step 5: optional --wait polling (outside lock) ───────────────
710
+ if (opts.wait && !summary.terminal_emitted) {
711
+ const deadline = Date.now() + opts.wait * 1000;
712
+ let polledEvents: CommonsLogEvent[] | null = null;
713
+ while (Date.now() < deadline) {
714
+ await new Promise((r) => setTimeout(r, 2000));
715
+ const { events: polled } = await api.getCommonsLogEvents(ws.runId, { limit: 5000 });
716
+ if (reduceRunState(ws, polled).terminal_emitted) {
717
+ polledEvents = polled;
718
+ break;
719
+ }
720
+ }
721
+ if (polledEvents) {
722
+ summary = reduceRunState(ws, polledEvents);
723
+ const release2 = acquireLock(ws.stagingDir);
724
+ try {
725
+ await refreshStaging(summary.cruxes, canonicalSourceHash);
726
+ } finally {
727
+ release2();
728
+ }
729
+ }
730
+ }
731
+
732
+ return { run_id: ws.runId, summary, terminal_emitted: summary.terminal_emitted };
733
+ });
734
+ },
735
+ );
736
+
737
+ converge
738
+ .command("clarify <sub_debate_id> <text>")
739
+ .description("Post owner_clarification.v1 directly to a sub-debate's Commons Log")
740
+ .action(async (subDebateId: string, text: string) => {
741
+ await runCommand(async () => {
742
+ const ctx = buildContext();
743
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
744
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
745
+ const subDebate = await api.getDebate(subDebateId);
746
+ const { seq } = await api.appendCommonsLog(subDebate.commons_log_id, "owner_clarification", {
747
+ event_type: "owner_clarification",
748
+ content: text,
749
+ in_response_to_event_id: null,
750
+ });
751
+ return { sub_debate_id: subDebateId, commons_log_id: subDebate.commons_log_id, seq };
752
+ });
753
+ });
754
+
755
+ converge
756
+ .command("attest <crux_id>")
757
+ .description("POST an attest_only decision to the Convergence PA")
758
+ .option("--run-id <id>")
759
+ .option("--staging-dir <path>")
760
+ .action(async (cruxId: string, opts: { runId?: string; stagingDir?: string }) => {
761
+ await runCommand(async () => {
762
+ const ctx = buildContext();
763
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
764
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
765
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
766
+ const { event_id } = await postCruxDecision(api, ws, cruxId, "attest");
767
+ return { run_id: ws.runId, crux_id: cruxId, action: "attest", event_id, synced: false };
768
+ });
769
+ });
770
+
771
+ converge
772
+ .command("accept <crux_id>")
773
+ .description("POST an accept decision to the Convergence PA")
774
+ .option("--run-id <id>")
775
+ .option("--staging-dir <path>")
776
+ .option("--message <text>", "Optional user_message recorded in provenance")
777
+ .option("--with-sync", "Compatibility: after the PA accepts, materialize local corpus files")
778
+ .action(async (cruxId: string, opts: { runId?: string; stagingDir?: string; message?: string; withSync?: boolean }) => {
779
+ await runCommand(async () => {
780
+ const ctx = buildContext();
781
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
782
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
783
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
784
+ const { event_id, body } = await postCruxDecision(api, ws, cruxId, "accept", { message: opts.message });
785
+ if (opts.withSync) {
786
+ const sync = await syncTerminalDecisions(ctx, api, ws, {
787
+ cruxId,
788
+ message: opts.message,
789
+ injectedTerminal: {
790
+ cruxId,
791
+ eventType: decisionEventTypeForAction("accept"),
792
+ payload: { event_type: decisionEventTypeForAction("accept"), crux_id: cruxId, ...body },
793
+ },
794
+ });
795
+ return {
796
+ run_id: ws.runId,
797
+ crux_id: cruxId,
798
+ action: "accept",
799
+ event_id,
800
+ synced: true,
801
+ ...sync,
802
+ };
803
+ }
804
+ return { run_id: ws.runId, crux_id: cruxId, action: "accept", event_id, synced: false };
805
+ });
806
+ });
807
+
808
+ converge
809
+ .command("reject <crux_id>")
810
+ .description("POST a reject decision to the Convergence PA")
811
+ .option("--run-id <id>")
812
+ .option("--staging-dir <path>")
813
+ .action(async (cruxId: string, opts: { runId?: string; stagingDir?: string }) => {
814
+ await runCommand(async () => {
815
+ const ctx = buildContext();
816
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
817
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
818
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
819
+ const { event_id } = await postCruxDecision(api, ws, cruxId, "reject");
820
+ return { run_id: ws.runId, crux_id: cruxId, action: "reject", event_id, synced: false };
821
+ });
822
+ });
823
+
824
+ converge
825
+ .command("sync")
826
+ .description("Materialize PA decision events into local corpus/staging files")
827
+ .option("--run-id <id>")
828
+ .option("--staging-dir <path>")
829
+ .option("--crux-id <id>", "Limit sync to one crux")
830
+ .action(async (opts: { runId?: string; stagingDir?: string; cruxId?: string }) => {
831
+ await runCommand(async () => {
832
+ const ctx = buildContext();
833
+ if (!ctx.cfg.apiKey) throw new LinkedClawError("missing_api_key", "Run `linkedclaw login` first.");
834
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
835
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
836
+ const result = await syncTerminalDecisions(ctx, api, ws, { cruxId: opts.cruxId });
837
+ return { run_id: ws.runId, synced: true, ...result };
838
+ });
839
+ });
840
+
841
+ converge
842
+ .command("review")
843
+ .description("List staging cruxes; surface already_aligned cruxes prominently")
844
+ .option("--run-id <id>")
845
+ .option("--staging-dir <path>")
846
+ .action(async (opts: { runId?: string; stagingDir?: string }) => {
847
+ await runCommand(async () => {
848
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
849
+ const files = listCruxFiles(ws.stagingDir);
850
+ const cruxes: Array<Record<string, unknown>> = [];
851
+ for (const fn of files) {
852
+ const doc = readStaging(join(ws.stagingDir, fn));
853
+ const fm = doc.frontmatter;
854
+ cruxes.push({
855
+ crux_id: fm.crux_id,
856
+ outcome: fm.outcome,
857
+ bilateral_mandate_intact: fm.bilateral_mandate_intact,
858
+ attested_by_user: fm.attested_by_user,
859
+ latest_sub_debate_id: fm.latest_sub_debate_id,
860
+ has_user_response: (doc.userResponse || "").trim().length > 0,
861
+ next_action:
862
+ fm.outcome === "already_aligned" && !fm.attested_by_user ? "attest"
863
+ : fm.outcome === "needs_input" ? "clarify_or_accept"
864
+ : "accept_or_reject",
865
+ });
866
+ }
867
+ const alignedAwaitingAttest = cruxes.filter((c) => c.outcome === "already_aligned" && !c.attested_by_user);
868
+ return {
869
+ run_id: ws.runId,
870
+ staging_dir: ws.stagingDir,
871
+ cruxes,
872
+ already_aligned_awaiting_attest: alignedAwaitingAttest.map((c) => c.crux_id),
873
+ };
874
+ });
875
+ });
876
+
877
+ converge
878
+ .command("status")
879
+ .description("Show reduced run state from the convergence run-log")
880
+ .option("--run-id <id>")
881
+ .option("--staging-dir <path>")
882
+ .option("--all", "List every run-log event (no reduction)")
883
+ .action(async (opts) => {
884
+ await runCommand(async () => {
885
+ const ctx = buildContext();
886
+ if (!ctx.cfg.apiKey) {
887
+ throw new Error("missing apiKey — run `linkedclaw login` first");
888
+ }
889
+ const ws = await resolveWorkspace({ runId: opts.runId, stagingDir: opts.stagingDir });
890
+ const api = makeConvergeApi(ctx.cfg.cloudUrl, ctx.cfg.apiKey);
891
+ const { events } = await api.getCommonsLogEvents(ws.runId, { limit: 1000 });
892
+ if (opts.all) return { run_id: ws.runId, events };
893
+ return reduceRunState(ws, events);
894
+ });
895
+ });
896
+ }
897
+
898
+ export function reduceRunState(ws: RunWorkspace, events: CommonsLogEvent[]): RunStateSummary {
899
+ let started_at: string | null = null;
900
+ let owner_b_accepted = false;
901
+ let terminal_emitted = false;
902
+ const cruxMap = new Map<
903
+ string,
904
+ {
905
+ crux_id: string;
906
+ latest_sub_debate_id: string | null;
907
+ sub_debate_chain: string[];
908
+ outcome: string | null;
909
+ bilateral_mandate_intact: boolean | null;
910
+ }
911
+ >();
912
+
913
+ for (const ev of events) {
914
+ const p = ev.payload;
915
+ switch (ev.event_type) {
916
+ case "run_started":
917
+ started_at = ev.appended_at;
918
+ break;
919
+ case "owner_b_accepted":
920
+ owner_b_accepted = true;
921
+ break;
922
+ case "sub_debate_dispatched": {
923
+ const cruxId = p.crux_id as string;
924
+ const subDebateId = p.sub_debate_id as string;
925
+ if (!cruxMap.has(cruxId)) {
926
+ cruxMap.set(cruxId, {
927
+ crux_id: cruxId,
928
+ latest_sub_debate_id: subDebateId,
929
+ sub_debate_chain: [subDebateId],
930
+ outcome: null,
931
+ bilateral_mandate_intact: null,
932
+ });
933
+ } else {
934
+ const entry = cruxMap.get(cruxId)!;
935
+ entry.latest_sub_debate_id = subDebateId;
936
+ entry.sub_debate_chain.push(subDebateId);
937
+ }
938
+ break;
939
+ }
940
+ case "sub_debate_outcome_observed": {
941
+ const cruxId = p.crux_id as string;
942
+ const entry = cruxMap.get(cruxId);
943
+ if (entry) {
944
+ entry.outcome = p.outcome as string;
945
+ if (typeof p.bilateral_mandate_intact === "boolean") {
946
+ entry.bilateral_mandate_intact = p.bilateral_mandate_intact;
947
+ }
948
+ }
949
+ break;
950
+ }
951
+ case "convergence_map":
952
+ terminal_emitted = true;
953
+ break;
954
+ default:
955
+ if ((ev.event_type as string).startsWith("terminal_")) {
956
+ terminal_emitted = true;
957
+ }
958
+ }
959
+ }
960
+
961
+ return {
962
+ run_id: ws.runId,
963
+ source_debate_id: ws.sourceDebateId,
964
+ started_at,
965
+ owner_b_accepted,
966
+ cruxes: Array.from(cruxMap.values()),
967
+ terminal_emitted,
968
+ };
969
+ }