@openshain/mcp 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -194,6 +194,7 @@ const WORK_TOOLS = [
194
194
  "model.completed",
195
195
  "model.failed",
196
196
  "usage.recorded",
197
+ "conversation.compacted",
197
198
  ],
198
199
  },
199
200
  payload: { type: "object" },
@@ -213,6 +214,8 @@ const RECORDABLE_TYPES = new Set([
213
214
  "model.completed",
214
215
  "model.failed",
215
216
  "usage.recorded",
217
+ // The conversation is the client's to shorten: the runtime holds the events either way.
218
+ "conversation.compacted",
216
219
  ]);
217
220
  const SESSION_HAS_NO_TOOLS = "a session records the conversation and runs no tools: call work_create with parent set to the session's id, then call the tool inside that work";
218
221
  const NO_WORK = "no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
@@ -235,6 +238,20 @@ export async function createMcpServer(options) {
235
238
  authority: () => authority,
236
239
  });
237
240
  const works = new WorkStore(workspaceRoot);
241
+ /**
242
+ * Opens a work for one piece of work on it, and closes it however that ends. The lock a work
243
+ * holds is the single-writer rule, so it must not outlive the call that took it; releasing it
244
+ * here means no caller can forget to.
245
+ */
246
+ async function withWork(id, use) {
247
+ const opened = await works.open(id);
248
+ try {
249
+ return await use(opened);
250
+ }
251
+ finally {
252
+ await opened.close();
253
+ }
254
+ }
238
255
  const session = new Session();
239
256
  const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
240
257
  /** The current work when it can still take events; otherwise the reason it cannot. */
@@ -290,6 +307,11 @@ export async function createMcpServer(options) {
290
307
  const work = await works.get(id);
291
308
  if (isTerminal(work.status))
292
309
  return failure(`work ${id} is already ${work.status}`);
310
+ // Selecting is what lets a client record into a work. Another person's conversation is
311
+ // theirs: a client that could select it could write what they never said.
312
+ if (work.principal !== config.principal.id) {
313
+ return failure(`work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`);
314
+ }
293
315
  session.select(id);
294
316
  return json({ ...work, history: workHistory(await works.events(id)) });
295
317
  }
@@ -315,18 +337,14 @@ export async function createMcpServer(options) {
315
337
  }
316
338
  const { question } = input;
317
339
  const callId = newCallId();
318
- const opened = await works.open(gate.id);
319
- try {
340
+ await withWork(gate.id, async (opened) => {
320
341
  await opened.append({
321
342
  type: "tool.called",
322
343
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
323
344
  });
324
345
  await opened.append({ type: "human.input_requested", payload: { callId, question } });
325
346
  await opened.transition("waiting_input", "the agent asked the person a question");
326
- }
327
- finally {
328
- await opened.close();
329
- }
347
+ });
330
348
  return json({ pending: true, call_id: callId, question });
331
349
  }
332
350
  case "work_answer": {
@@ -342,8 +360,7 @@ export async function createMcpServer(options) {
342
360
  if (!pending.some((q) => q.callId === callId)) {
343
361
  return failure(`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`);
344
362
  }
345
- const opened = await works.open(gate.id);
346
- try {
363
+ return await withWork(gate.id, async (opened) => {
347
364
  await opened.append({ type: "human.input_provided", payload: { callId, answer } });
348
365
  await opened.append({
349
366
  type: "tool.completed",
@@ -351,10 +368,7 @@ export async function createMcpServer(options) {
351
368
  });
352
369
  await opened.transition("in_progress", "the person answered");
353
370
  return json(await opened.current());
354
- }
355
- finally {
356
- await opened.close();
357
- }
371
+ });
358
372
  }
359
373
  case "work_record": {
360
374
  const { work_id, type, payload } = input;
@@ -372,17 +386,13 @@ export async function createMcpServer(options) {
372
386
  if (type === "usage.recorded" && parsed.kind !== "model_inference") {
373
387
  return failure("usage.recorded from a client must have kind model_inference");
374
388
  }
375
- const opened = await works.open(id);
376
- try {
389
+ return await withWork(id, async (opened) => {
377
390
  const status = (await opened.current()).status;
378
391
  if (isTerminal(status))
379
392
  return failure(`work ${id} is already ${status}`);
380
393
  const event = await opened.append({ type, payload: parsed });
381
394
  return json({ id: event.id, seq: event.seq });
382
- }
383
- finally {
384
- await opened.close();
385
- }
395
+ });
386
396
  }
387
397
  case "context": {
388
398
  const now = new Date();
@@ -402,8 +412,7 @@ export async function createMcpServer(options) {
402
412
  const current = session.current;
403
413
  if (current && !isTerminal((await works.get(current)).status)) {
404
414
  const callId = newCallId();
405
- const opened = await works.open(current);
406
- try {
415
+ await withWork(current, async (opened) => {
407
416
  await opened.append({
408
417
  type: "tool.called",
409
418
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
@@ -412,10 +421,7 @@ export async function createMcpServer(options) {
412
421
  type: "tool.completed",
413
422
  payload: { callId, content: [{ type: "json", value: info }], isError: false },
414
423
  });
415
- }
416
- finally {
417
- await opened.close();
418
- }
424
+ });
419
425
  }
420
426
  return result;
421
427
  }
@@ -444,8 +450,7 @@ export async function createMcpServer(options) {
444
450
  if (approval.approvers && !approval.approvers.includes(by)) {
445
451
  return failure(`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`);
446
452
  }
447
- const opened = await works.open(workId);
448
- try {
453
+ return await withWork(workId, async (opened) => {
449
454
  // Under the lock: another connection may have decided this approval in between.
450
455
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
451
456
  return failure(`approval ${approvalId} was already decided`);
@@ -475,10 +480,7 @@ export async function createMcpServer(options) {
475
480
  work_id: workId,
476
481
  result: { content: result.content, isError: result.isError ?? false },
477
482
  });
478
- }
479
- finally {
480
- await opened.close();
481
- }
483
+ });
482
484
  }
483
485
  case "review_decide": {
484
486
  const { approval_id: approvalId, decision, reviewer, interpretation, modified_input: modifiedInput, effective_from: effectiveFrom, effective_until: effectiveUntil, applies_to: appliesTo, } = input;
@@ -523,8 +525,7 @@ export async function createMcpServer(options) {
523
525
  }
524
526
  record = parsed.data;
525
527
  }
526
- const opened = await works.open(workId);
527
- try {
528
+ return await withWork(workId, async (opened) => {
528
529
  // Under the lock: another connection may have decided this approval in between.
529
530
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
530
531
  return failure(`approval ${approvalId} was already decided`);
@@ -573,10 +574,7 @@ export async function createMcpServer(options) {
573
574
  decision_file: relative(workspaceRoot, file),
574
575
  result: { content: ran.content, isError: ran.isError ?? false },
575
576
  });
576
- }
577
- finally {
578
- await opened.close();
579
- }
577
+ });
580
578
  }
581
579
  case "work_list": {
582
580
  const { works: all, problems } = await works.list();
@@ -602,7 +600,7 @@ export async function createMcpServer(options) {
602
600
  if ("refused" in gate)
603
601
  return gate.refused;
604
602
  const { summary, artifacts } = input;
605
- const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
603
+ const work = await withWork(gate.id, (opened) => complete(workspaceRoot, opened, summary, artifacts ?? []));
606
604
  session.clear();
607
605
  return json(work);
608
606
  }
@@ -611,13 +609,9 @@ export async function createMcpServer(options) {
611
609
  if ("refused" in gate)
612
610
  return gate.refused;
613
611
  const { reason, detail } = input;
614
- const opened = await works.open(gate.id);
615
- try {
612
+ await withWork(gate.id, async (opened) => {
616
613
  await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
617
- }
618
- finally {
619
- await opened.close();
620
- }
614
+ });
621
615
  session.clear();
622
616
  return json(await works.get(gate.id));
623
617
  }
@@ -634,8 +628,7 @@ export async function createMcpServer(options) {
634
628
  if (work.status === "waiting_approval") {
635
629
  return failure(`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`);
636
630
  }
637
- const opened = await works.open(gate.id);
638
- try {
631
+ return await withWork(gate.id, async (opened) => {
639
632
  const limit = config.limits.maxToolCalls;
640
633
  if (countToolCalls(await opened.events()) >= limit) {
641
634
  const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
@@ -647,10 +640,7 @@ export async function createMcpServer(options) {
647
640
  }
648
641
  const result = await callTool(opened, { id: newCallId(), name, input });
649
642
  return toMcpResult(result);
650
- }
651
- finally {
652
- await opened.close();
653
- }
643
+ });
654
644
  }
655
645
  }
656
646
  }
@@ -674,38 +664,32 @@ export async function createMcpServer(options) {
674
664
  * wrote; every path must be inside the workspace, and the runtime hashes them all. A path no tool
675
665
  * of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
676
666
  */
677
- async function complete(works, workspaceRoot, id, summary, claimed) {
667
+ async function complete(workspaceRoot, opened, summary, claimed) {
678
668
  for (const { path } of claimed)
679
669
  await resolveWorkspacePath(workspaceRoot, path);
680
- const opened = await works.open(id);
681
- try {
682
- const events = await opened.events();
683
- const refs = [];
684
- const byPath = new Map();
685
- for (const event of writesWithAfter(events)) {
686
- refs.push(event.id);
687
- for (const { path, sha256 } of event.payload.after ?? [])
688
- byPath.set(path, sha256);
689
- }
690
- const written = new Set(byPath.keys());
691
- for (const { path, sha256 } of claimed)
692
- if (!byPath.has(path))
693
- byPath.set(path, sha256 ?? "");
694
- const artifacts = [];
695
- for (const [path, reported] of byPath) {
696
- const artifact = await verifyArtifact(workspaceRoot, path, reported);
697
- artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
698
- }
699
- await opened.append({
700
- type: "evidence.recorded",
701
- payload: { claim: summary, refs, artifacts },
702
- });
703
- await opened.append({ type: "work.completed", payload: { summary } });
704
- return opened.current();
670
+ const events = await opened.events();
671
+ const refs = [];
672
+ const byPath = new Map();
673
+ for (const event of writesWithAfter(events)) {
674
+ refs.push(event.id);
675
+ for (const { path, sha256 } of event.payload.after ?? [])
676
+ byPath.set(path, sha256);
705
677
  }
706
- finally {
707
- await opened.close();
678
+ const written = new Set(byPath.keys());
679
+ for (const { path, sha256 } of claimed)
680
+ if (!byPath.has(path))
681
+ byPath.set(path, sha256 ?? "");
682
+ const artifacts = [];
683
+ for (const [path, reported] of byPath) {
684
+ const artifact = await verifyArtifact(workspaceRoot, path, reported);
685
+ artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
708
686
  }
687
+ await opened.append({
688
+ type: "evidence.recorded",
689
+ payload: { claim: summary, refs, artifacts },
690
+ });
691
+ await opened.append({ type: "work.completed", payload: { summary } });
692
+ return opened.current();
709
693
  }
710
694
  function writesWithAfter(events) {
711
695
  return events.filter((e) => e.type === "tool.completed" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openshain/mcp",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server that exposes an openshain workspace to any agent",
5
5
  "keywords": [
6
6
  "openshain",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "1.30.0",
49
- "@openshain/core": "0.4.1"
49
+ "@openshain/core": "0.5.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@openshain/tools": "0.4.1"
52
+ "@openshain/tools": "0.5.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"
package/src/server.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  uuidv7,
41
41
  verifyArtifact,
42
42
  type Work,
43
+ type WorkHandle,
43
44
  type WorkId,
44
45
  WorkStore,
45
46
  workHistory,
@@ -254,6 +255,7 @@ const WORK_TOOLS: Tool[] = [
254
255
  "model.completed",
255
256
  "model.failed",
256
257
  "usage.recorded",
258
+ "conversation.compacted",
257
259
  ],
258
260
  },
259
261
  payload: { type: "object" },
@@ -275,6 +277,8 @@ const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
275
277
  "model.completed",
276
278
  "model.failed",
277
279
  "usage.recorded",
280
+ // The conversation is the client's to shorten: the runtime holds the events either way.
281
+ "conversation.compacted",
278
282
  ]);
279
283
 
280
284
  const SESSION_HAS_NO_TOOLS =
@@ -305,6 +309,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
305
309
  authority: () => authority,
306
310
  });
307
311
  const works = new WorkStore(workspaceRoot);
312
+
313
+ /**
314
+ * Opens a work for one piece of work on it, and closes it however that ends. The lock a work
315
+ * holds is the single-writer rule, so it must not outlive the call that took it; releasing it
316
+ * here means no caller can forget to.
317
+ */
318
+ async function withWork<T>(id: WorkId, use: (opened: WorkHandle) => Promise<T>): Promise<T> {
319
+ const opened = await works.open(id);
320
+ try {
321
+ return await use(opened);
322
+ } finally {
323
+ await opened.close();
324
+ }
325
+ }
308
326
  const session = new Session();
309
327
  const server = new Server(
310
328
  { name: "openshain", version: pkg.version },
@@ -371,6 +389,13 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
371
389
  const id = parseWorkId((input as { id: string }).id);
372
390
  const work = await works.get(id);
373
391
  if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
392
+ // Selecting is what lets a client record into a work. Another person's conversation is
393
+ // theirs: a client that could select it could write what they never said.
394
+ if (work.principal !== config.principal.id) {
395
+ return failure(
396
+ `work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`,
397
+ );
398
+ }
374
399
  session.select(id);
375
400
  return json({ ...work, history: workHistory(await works.events(id)) });
376
401
  }
@@ -394,17 +419,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
394
419
  }
395
420
  const { question } = input as { question: string };
396
421
  const callId = newCallId();
397
- const opened = await works.open(gate.id);
398
- try {
422
+ await withWork(gate.id, async (opened) => {
399
423
  await opened.append({
400
424
  type: "tool.called",
401
425
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
402
426
  });
403
427
  await opened.append({ type: "human.input_requested", payload: { callId, question } });
404
428
  await opened.transition("waiting_input", "the agent asked the person a question");
405
- } finally {
406
- await opened.close();
407
- }
429
+ });
408
430
  return json({ pending: true, call_id: callId, question });
409
431
  }
410
432
  case "work_answer": {
@@ -421,8 +443,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
421
443
  `no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
422
444
  );
423
445
  }
424
- const opened = await works.open(gate.id);
425
- try {
446
+ return await withWork(gate.id, async (opened) => {
426
447
  await opened.append({ type: "human.input_provided", payload: { callId, answer } });
427
448
  await opened.append({
428
449
  type: "tool.completed",
@@ -430,9 +451,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
430
451
  });
431
452
  await opened.transition("in_progress", "the person answered");
432
453
  return json(await opened.current());
433
- } finally {
434
- await opened.close();
435
- }
454
+ });
436
455
  }
437
456
  case "work_record": {
438
457
  const { work_id, type, payload } = input as {
@@ -456,15 +475,12 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
456
475
  if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
457
476
  return failure("usage.recorded from a client must have kind model_inference");
458
477
  }
459
- const opened = await works.open(id);
460
- try {
478
+ return await withWork(id, async (opened) => {
461
479
  const status = (await opened.current()).status;
462
480
  if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
463
481
  const event = await opened.append({ type, payload: parsed } as never);
464
482
  return json({ id: event.id, seq: event.seq });
465
- } finally {
466
- await opened.close();
467
- }
483
+ });
468
484
  }
469
485
  case "context": {
470
486
  const now = new Date();
@@ -484,8 +500,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
484
500
  const current = session.current;
485
501
  if (current && !isTerminal((await works.get(current)).status)) {
486
502
  const callId = newCallId();
487
- const opened = await works.open(current);
488
- try {
503
+ await withWork(current, async (opened) => {
489
504
  await opened.append({
490
505
  type: "tool.called",
491
506
  payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
@@ -494,9 +509,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
494
509
  type: "tool.completed",
495
510
  payload: { callId, content: [{ type: "json", value: info }], isError: false },
496
511
  });
497
- } finally {
498
- await opened.close();
499
- }
512
+ });
500
513
  }
501
514
  return result;
502
515
  }
@@ -531,8 +544,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
531
544
  `${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`,
532
545
  );
533
546
  }
534
- const opened = await works.open(workId);
535
- try {
547
+ return await withWork(workId, async (opened) => {
536
548
  // Under the lock: another connection may have decided this approval in between.
537
549
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
538
550
  return failure(`approval ${approvalId} was already decided`);
@@ -566,9 +578,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
566
578
  work_id: workId,
567
579
  result: { content: result.content, isError: result.isError ?? false },
568
580
  });
569
- } finally {
570
- await opened.close();
571
- }
581
+ });
572
582
  }
573
583
  case "review_decide": {
574
584
  const {
@@ -638,8 +648,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
638
648
  }
639
649
  record = parsed.data;
640
650
  }
641
- const opened = await works.open(workId);
642
- try {
651
+ return await withWork(workId, async (opened) => {
643
652
  // Under the lock: another connection may have decided this approval in between.
644
653
  if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
645
654
  return failure(`approval ${approvalId} was already decided`);
@@ -693,9 +702,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
693
702
  decision_file: relative(workspaceRoot, file),
694
703
  result: { content: ran.content, isError: ran.isError ?? false },
695
704
  });
696
- } finally {
697
- await opened.close();
698
- }
705
+ });
699
706
  }
700
707
  case "work_list": {
701
708
  const { works: all, problems } = await works.list();
@@ -723,7 +730,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
723
730
  summary: string;
724
731
  artifacts?: { path: string; sha256?: string }[];
725
732
  };
726
- const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
733
+ const work = await withWork(gate.id, (opened) =>
734
+ complete(workspaceRoot, opened, summary, artifacts ?? []),
735
+ );
727
736
  session.clear();
728
737
  return json(work);
729
738
  }
@@ -731,12 +740,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
731
740
  const gate = await openWork();
732
741
  if ("refused" in gate) return gate.refused;
733
742
  const { reason, detail } = input as { reason: string; detail?: string };
734
- const opened = await works.open(gate.id);
735
- try {
743
+ await withWork(gate.id, async (opened) => {
736
744
  await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
737
- } finally {
738
- await opened.close();
739
- }
745
+ });
740
746
  session.clear();
741
747
  return json(await works.get(gate.id));
742
748
  }
@@ -755,8 +761,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
755
761
  `work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`,
756
762
  );
757
763
  }
758
- const opened = await works.open(gate.id);
759
- try {
764
+ return await withWork(gate.id, async (opened) => {
760
765
  const limit = config.limits.maxToolCalls;
761
766
  if (countToolCalls(await opened.events()) >= limit) {
762
767
  const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
@@ -768,9 +773,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
768
773
  }
769
774
  const result = await callTool(opened, { id: newCallId(), name, input });
770
775
  return toMcpResult(result);
771
- } finally {
772
- await opened.close();
773
- }
776
+ });
774
777
  }
775
778
  }
776
779
  }
@@ -799,38 +802,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
799
802
  * of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
800
803
  */
801
804
  async function complete(
802
- works: WorkStore,
803
805
  workspaceRoot: string,
804
- id: WorkId,
806
+ opened: WorkHandle,
805
807
  summary: string,
806
808
  claimed: { path: string; sha256?: string }[],
807
809
  ): Promise<Work> {
808
810
  for (const { path } of claimed) await resolveWorkspacePath(workspaceRoot, path);
809
- const opened = await works.open(id);
810
- try {
811
- const events = await opened.events();
812
- const refs: string[] = [];
813
- const byPath = new Map<string, string>();
814
- for (const event of writesWithAfter(events)) {
815
- refs.push(event.id);
816
- for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
817
- }
818
- const written = new Set(byPath.keys());
819
- for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
820
- const artifacts: Artifact[] = [];
821
- for (const [path, reported] of byPath) {
822
- const artifact = await verifyArtifact(workspaceRoot, path, reported);
823
- artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
824
- }
825
- await opened.append({
826
- type: "evidence.recorded",
827
- payload: { claim: summary, refs, artifacts },
828
- });
829
- await opened.append({ type: "work.completed", payload: { summary } });
830
- return opened.current();
831
- } finally {
832
- await opened.close();
811
+ const events = await opened.events();
812
+ const refs: string[] = [];
813
+ const byPath = new Map<string, string>();
814
+ for (const event of writesWithAfter(events)) {
815
+ refs.push(event.id);
816
+ for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
817
+ }
818
+ const written = new Set(byPath.keys());
819
+ for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
820
+ const artifacts: Artifact[] = [];
821
+ for (const [path, reported] of byPath) {
822
+ const artifact = await verifyArtifact(workspaceRoot, path, reported);
823
+ artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
833
824
  }
825
+ await opened.append({
826
+ type: "evidence.recorded",
827
+ payload: { claim: summary, refs, artifacts },
828
+ });
829
+ await opened.append({ type: "work.completed", payload: { summary } });
830
+ return opened.current();
834
831
  }
835
832
 
836
833
  function writesWithAfter(events: AnyEvent[]): Event<"tool.completed">[] {