@gmickel/gno 1.43.0 → 1.45.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.
Files changed (33) hide show
  1. package/assets/skill/SKILL.md +3 -0
  2. package/assets/skill/recipes/memory-file-decision.md +76 -0
  3. package/assets/skill/recipes/memory-scoped-recall.md +66 -0
  4. package/assets/skill/recipes/memory-supersede-fact.md +68 -0
  5. package/browser-extension/artifacts/{gno-browser-clipper-v1.43.0.zip → gno-browser-clipper-v1.45.0.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v1.45.0.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +89 -8
  10. package/spec/mcp.md +12 -0
  11. package/spec/output-schemas/changes-follow-event.schema.json +35 -0
  12. package/spec/output-schemas/index-receipt.schema.json +135 -0
  13. package/spec/output-schemas/process-status.schema.json +76 -0
  14. package/src/cli/commands/agents/block.ts +9 -8
  15. package/src/cli/commands/changes-follow.ts +167 -0
  16. package/src/cli/commands/changes.ts +63 -0
  17. package/src/cli/commands/daemon.ts +35 -0
  18. package/src/cli/commands/doctor.ts +71 -0
  19. package/src/cli/commands/embed.ts +236 -178
  20. package/src/cli/commands/index-cmd.ts +238 -57
  21. package/src/cli/program.ts +94 -4
  22. package/src/config/types.ts +48 -0
  23. package/src/core/capture-sync.ts +144 -0
  24. package/src/core/capture.ts +10 -0
  25. package/src/core/findings-records.ts +381 -0
  26. package/src/core/findings-run-state.ts +282 -0
  27. package/src/embed/stage-state.ts +199 -0
  28. package/src/mcp/tools/capture.ts +91 -136
  29. package/src/serve/capture-service.ts +227 -53
  30. package/src/serve/findings-pass.ts +335 -0
  31. package/src/serve/resident-runtime.ts +42 -0
  32. package/src/serve/routes/api.ts +14 -14
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.43.0.zip.sha256 +0 -1
@@ -15,16 +15,21 @@ import type { ToolContext } from "../server";
15
15
 
16
16
  import {
17
17
  buildCaptureReceipt,
18
+ CaptureSyncError,
19
+ ensureCapturedFileIndexed,
18
20
  listCaptureDiskRelPaths,
19
21
  planCapture,
22
+ syncCapturedFile,
20
23
  type CaptureInput as SharedCaptureInput,
21
24
  type CaptureReceipt,
25
+ type SyncCapturedFileResult,
22
26
  } from "../../core/capture";
23
27
  import { writeCapturePlanFile } from "../../core/capture-write";
24
28
  import { MCP_ERRORS } from "../../core/errors";
25
29
  import { withWriteLock } from "../../core/file-lock";
30
+ import { recordContentMutation } from "../../core/mutation-generations";
26
31
  import { normalizeCollectionName } from "../../core/validation";
27
- import { defaultSyncService, withContentTypeRules } from "../../ingestion";
32
+ import { DEFAULT_LOCK_WAIT_MS } from "../../core/write-lease";
28
33
  import { runTool, type ToolResult } from "./index";
29
34
 
30
35
  interface CaptureInput extends Omit<
@@ -102,6 +107,14 @@ function buildSharedInput(
102
107
  };
103
108
  }
104
109
 
110
+ /** Surface a sync failure as an MCP tool error the `CODE: message` way. */
111
+ function rethrowCaptureError(error: unknown): never {
112
+ if (error instanceof CaptureSyncError) {
113
+ throw new Error(`${error.code}: ${error.message}`);
114
+ }
115
+ throw error;
116
+ }
117
+
105
118
  export function handleCapture(
106
119
  args: CaptureInput,
107
120
  ctx: ToolContext
@@ -114,154 +127,96 @@ export function handleCapture(
114
127
  throw new Error("Write tools disabled. Start MCP with --enable-write.");
115
128
  }
116
129
 
117
- return await withWriteLock(ctx.writeLockPath, async () => {
118
- const collectionName = normalizeCollectionName(args.collection);
119
- const collection = ctx.collections.find(
120
- (c) => c.name.toLowerCase() === collectionName
130
+ const collectionName = normalizeCollectionName(args.collection);
131
+ const collection = ctx.collections.find(
132
+ (c) => c.name.toLowerCase() === collectionName
133
+ );
134
+ if (!collection) {
135
+ throw new Error(
136
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
121
137
  );
122
- if (!collection) {
123
- throw new Error(
124
- `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
125
- );
126
- }
127
-
128
- const existingDocs = await ctx.store.listDocuments(collectionName);
129
- if (!existingDocs.ok) {
130
- throw new Error(existingDocs.error.message);
131
- }
138
+ }
132
139
 
133
- let plan;
134
- try {
135
- plan = planCapture({
136
- input: buildSharedInput(args, collection.name),
137
- existingRelPaths: existingDocs.value.map((doc) => doc.relPath),
138
- diskRelPaths: await listCaptureDiskRelPaths(collection.path),
139
- });
140
- } catch (error) {
141
- const message =
142
- error instanceof Error ? error.message : String(error);
143
- throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
144
- }
140
+ // Write + lexical sync complete under the shared write lease: the tool
141
+ // succeeds only once the capture is retrievable (v1.38 contention
142
+ // contract: wait for the lease, LOCKED when it stays busy).
143
+ return await withWriteLock(
144
+ ctx.writeLockPath,
145
+ async () => {
146
+ const existingDocs = await ctx.store.listDocuments(collectionName);
147
+ if (!existingDocs.ok) {
148
+ throw new Error(existingDocs.error.message);
149
+ }
145
150
 
146
- assertNotSensitive(plan.relPath);
151
+ let plan;
152
+ try {
153
+ plan = planCapture({
154
+ input: buildSharedInput(args, collection.name),
155
+ existingRelPaths: existingDocs.value.map((doc) => doc.relPath),
156
+ diskRelPaths: await listCaptureDiskRelPaths(collection.path),
157
+ });
158
+ } catch (error) {
159
+ const message =
160
+ error instanceof Error ? error.message : String(error);
161
+ throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
162
+ }
147
163
 
148
- const absPath = join(collection.path, plan.relPath);
149
- const existingFile = Bun.file(absPath);
150
- const exists = await existingFile.exists();
164
+ assertNotSensitive(plan.relPath);
151
165
 
152
- if (plan.openedExisting) {
153
- const docResult = await ctx.store.getDocument(
154
- collectionName,
155
- plan.relPath
156
- );
157
- const existingDoc = docResult.ok ? docResult.value : undefined;
158
- return buildCaptureReceipt({
159
- plan,
166
+ const absPath = join(collection.path, plan.relPath);
167
+ const syncInput = {
168
+ collection,
169
+ store: ctx.store,
170
+ relPath: plan.relPath,
160
171
  absPath,
161
- docid: existingDoc?.docid ?? "",
162
- sync: {
163
- status: existingDoc ? "completed" : "skipped",
164
- reason: existingDoc
165
- ? "Existing capture already indexed."
166
- : "Existing capture opened from disk but is not indexed yet.",
167
- },
168
- serverInstanceId: ctx.serverInstanceId,
169
- }) as McpCaptureResult;
170
- }
171
-
172
- await mkdir(dirname(absPath), { recursive: true });
173
- await writeCapturePlanFile(plan, absPath);
172
+ config: ctx.config,
173
+ };
174
+
175
+ let synced: SyncCapturedFileResult;
176
+ let overwritten = false;
177
+ try {
178
+ if (plan.openedExisting) {
179
+ synced = await ensureCapturedFileIndexed(syncInput);
180
+ } else {
181
+ overwritten =
182
+ (await Bun.file(absPath).exists()) && args.overwrite === true;
183
+ await mkdir(dirname(absPath), { recursive: true });
184
+ await writeCapturePlanFile(plan, absPath);
185
+ synced = await syncCapturedFile(syncInput);
186
+ }
187
+ } catch (error) {
188
+ rethrowCaptureError(error);
189
+ }
190
+ if (synced.result) {
191
+ recordContentMutation(synced.result, ctx.markContentMutation);
192
+ }
174
193
 
175
- const results = await defaultSyncService.syncFiles(
176
- collection,
177
- ctx.store,
178
- [plan.relPath],
179
- withContentTypeRules(
180
- { runUpdateCmd: false, gitPull: false },
181
- ctx.config
182
- )
183
- );
184
- const syncResult = results[0];
185
- if (!syncResult) {
186
- return buildCaptureReceipt({
187
- plan,
188
- absPath,
189
- docid: "",
190
- sync: {
191
- status: "failed",
192
- error: "RUNTIME: Sync result missing",
193
- },
194
- overwritten: exists && args.overwrite === true,
195
- serverInstanceId: ctx.serverInstanceId,
196
- }) as McpCaptureResult;
197
- }
198
- if (syncResult.status === "error") {
199
- return buildCaptureReceipt({
200
- plan,
201
- absPath,
202
- docid: "",
203
- sync: {
204
- status: "failed",
205
- error: `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
206
- syncResult.errorMessage ?? "Unknown error"
207
- }`,
208
- },
209
- overwritten: exists && args.overwrite === true,
210
- serverInstanceId: ctx.serverInstanceId,
211
- }) as McpCaptureResult;
212
- }
213
- if (syncResult.status === "added" || syncResult.status === "updated") {
214
- ctx.markContentMutation?.();
215
- }
194
+ const isMarkdown =
195
+ plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
196
+ if (!isMarkdown && !plan.openedExisting && plan.tags.length > 0) {
197
+ const tagResult = await ctx.store.setDocTags(
198
+ synced.documentId,
199
+ plan.tags,
200
+ "user"
201
+ );
202
+ if (!tagResult.ok) {
203
+ console.error(
204
+ `[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
205
+ );
206
+ }
207
+ }
216
208
 
217
- let docid = syncResult.docid;
218
- let documentId: number | undefined;
219
- const docResult = await ctx.store.getDocument(
220
- collectionName,
221
- plan.relPath
222
- );
223
- if (docResult.ok && docResult.value) {
224
- docid = docid ?? docResult.value.docid;
225
- documentId = docResult.value.id;
226
- }
227
- if (!docid) {
228
209
  return buildCaptureReceipt({
229
210
  plan,
230
211
  absPath,
231
- docid: "",
232
- sync: {
233
- status: "failed",
234
- error: "RUNTIME: Document missing after sync",
235
- },
236
- overwritten: exists && args.overwrite === true,
212
+ docid: synced.docid,
213
+ sync: synced.sync,
214
+ overwritten,
237
215
  serverInstanceId: ctx.serverInstanceId,
238
216
  }) as McpCaptureResult;
239
- }
240
-
241
- const isMarkdown =
242
- plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
243
- if (!isMarkdown && plan.tags.length > 0 && documentId) {
244
- const tagResult = await ctx.store.setDocTags(
245
- documentId,
246
- plan.tags,
247
- "user"
248
- );
249
- if (!tagResult.ok) {
250
- console.error(
251
- `[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
252
- );
253
- }
254
- }
255
-
256
- return buildCaptureReceipt({
257
- plan,
258
- absPath,
259
- docid,
260
- sync: { status: "completed" },
261
- overwritten: exists && args.overwrite === true,
262
- serverInstanceId: ctx.serverInstanceId,
263
- }) as McpCaptureResult;
264
- });
217
+ },
218
+ DEFAULT_LOCK_WAIT_MS
219
+ );
265
220
  },
266
221
  formatCaptureResult
267
222
  );
@@ -20,15 +20,23 @@ import type { DocumentEventBus } from "./doc-events";
20
20
  import type { EmbedScheduler } from "./embed-scheduler";
21
21
  import type { CollectionWatchService } from "./watch-service";
22
22
 
23
+ import { getIndexDbPath } from "../app/constants";
23
24
  import {
24
25
  buildCaptureReceipt,
26
+ CaptureSyncError,
27
+ type CaptureSyncPaths,
28
+ ensureCapturedFileIndexed,
25
29
  extractCaptureSourceFromFrontmatter,
26
30
  hashCaptureContent,
27
31
  listCaptureDiskRelPaths,
28
32
  planCapture,
33
+ syncCapturedFile,
29
34
  } from "../core/capture";
30
35
  import { writeCapturePlanFile } from "../core/capture-write";
36
+ import { MCP_ERRORS } from "../core/errors";
37
+ import { withWriteLock } from "../core/file-lock";
31
38
  import { recordContentMutation } from "../core/mutation-generations";
39
+ import { DEFAULT_LOCK_WAIT_MS, writeLeasePath } from "../core/write-lease";
32
40
  import {
33
41
  type CollectionSyncResult,
34
42
  defaultSyncService,
@@ -40,6 +48,8 @@ import { startJob } from "./jobs";
40
48
 
41
49
  export interface ResidentCaptureContext {
42
50
  config: Config;
51
+ /** Resident server context; only the index name is read (lease path). */
52
+ current?: { indexName?: string };
43
53
  scheduler: EmbedScheduler | null;
44
54
  eventBus: DocumentEventBus | null;
45
55
  watchService: CollectionWatchService | null;
@@ -47,6 +57,78 @@ export interface ResidentCaptureContext {
47
57
  markContentMutation?: () => void;
48
58
  }
49
59
 
60
+ export interface ResidentCaptureDependencies {
61
+ /**
62
+ * `await-sync` (the `/api/capture` contract): write + lexical sync complete
63
+ * under the shared write lease before the response, `201` on create.
64
+ * `job` (browser clipper): write, then `202` with a sync job to poll.
65
+ */
66
+ mode?: "await-sync" | "job";
67
+ syncPaths?: CaptureSyncPaths;
68
+ syncCollection?: typeof defaultSyncService.syncCollection;
69
+ /** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */
70
+ lockPath?: string;
71
+ lockWaitMs?: number;
72
+ }
73
+
74
+ export interface ResidentCaptureErrorShape {
75
+ code: string;
76
+ message: string;
77
+ status: number;
78
+ details?: Record<string, unknown>;
79
+ }
80
+
81
+ const HTTP_OK = 200;
82
+ const HTTP_CREATED = 201;
83
+ const HTTP_ACCEPTED = 202;
84
+ const HTTP_CONFLICT = 409;
85
+ const HTTP_INTERNAL = 500;
86
+
87
+ /**
88
+ * Map a capture execution failure to its wire shape: lease busy is `LOCKED`
89
+ * (409, the MCP write-lock code), a written-but-unsynced capture is
90
+ * `CAPTURE_SYNC_FAILED` (500) carrying the write half of the receipt, and
91
+ * anything else is `RUNTIME` (500).
92
+ */
93
+ export const classifyResidentCaptureError = (
94
+ error: unknown,
95
+ planned?: Extract<ResidentCapturePlanResult, { ok: true }>
96
+ ): ResidentCaptureErrorShape => {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ if (error instanceof CaptureSyncError) {
99
+ return {
100
+ code: error.code,
101
+ message,
102
+ status: HTTP_INTERNAL,
103
+ details: {
104
+ absPath: error.absPath,
105
+ relPath: error.relPath,
106
+ ...(planned
107
+ ? {
108
+ uri: `gno://${planned.collection.name}/${planned.plan.relPath}`,
109
+ contentHash: planned.plan.contentHash,
110
+ }
111
+ : {}),
112
+ },
113
+ };
114
+ }
115
+ if (message.startsWith(`${MCP_ERRORS.LOCKED.code}:`)) {
116
+ return { code: MCP_ERRORS.LOCKED.code, message, status: HTTP_CONFLICT };
117
+ }
118
+ return {
119
+ code: "RUNTIME",
120
+ message: `Failed to capture document: ${message}`,
121
+ status: HTTP_INTERNAL,
122
+ };
123
+ };
124
+
125
+ const resolveCaptureLockPath = (
126
+ context: ResidentCaptureContext,
127
+ dependencies: ResidentCaptureDependencies
128
+ ): string =>
129
+ dependencies.lockPath ??
130
+ writeLeasePath(getIndexDbPath(context.current?.indexName));
131
+
50
132
  export type ResidentCapturePlanResult =
51
133
  | {
52
134
  ok: true;
@@ -172,57 +254,36 @@ const syncResidentCollection = async (
172
254
  return result;
173
255
  };
174
256
 
175
- export const executeResidentCapturePlan = async (
257
+ const emitCaptureCreated = (
258
+ context: ResidentCaptureContext,
259
+ collection: Collection,
260
+ relPath: string
261
+ ): void => {
262
+ context.scheduler?.notifySyncComplete([relPath]);
263
+ context.eventBus?.emit({
264
+ type: "document-changed",
265
+ uri: `gno://${collection.name}/${relPath}`,
266
+ collection: collection.name,
267
+ relPath,
268
+ origin: "create",
269
+ changedAt: new Date().toISOString(),
270
+ });
271
+ };
272
+
273
+ /**
274
+ * Write the planned capture, then start the legacy sync job (browser
275
+ * clipper contract: `202` + `sync.status: "pending"`).
276
+ */
277
+ const executeCaptureAsJob = async (
176
278
  context: ResidentCaptureContext,
177
279
  store: SqliteAdapter,
178
280
  planned: Extract<ResidentCapturePlanResult, { ok: true }>,
179
- dependencies: {
180
- syncCollection?: typeof defaultSyncService.syncCollection;
181
- } = {}
281
+ dependencies: ResidentCaptureDependencies
182
282
  ): Promise<{ body: unknown; status: number }> => {
183
283
  const { collection, fullPath, plan } = planned;
184
- if (plan.provenanceConflict) {
185
- return {
186
- body: buildCaptureReceipt({
187
- plan,
188
- absPath: fullPath,
189
- sync: {
190
- status: "skipped",
191
- reason:
192
- "Existing capture has absent or different browser provenance.",
193
- },
194
- }),
195
- status: 409,
196
- };
197
- }
198
- if (plan.openedExisting) {
199
- const existingDocument = await store.getDocument(
200
- collection.name,
201
- plan.relPath
202
- );
203
- if (!existingDocument.ok) {
204
- throw new Error(existingDocument.error.message);
205
- }
206
- return {
207
- body: buildCaptureReceipt({
208
- plan,
209
- absPath: fullPath,
210
- docid: existingDocument.value?.docid,
211
- sync: existingDocument.value
212
- ? { status: "completed" }
213
- : {
214
- status: "skipped",
215
- reason: "Existing file is not indexed yet.",
216
- },
217
- }),
218
- status: 200,
219
- };
220
- }
221
-
222
284
  await mkdir(dirname(fullPath), { recursive: true });
223
285
  context.watchService?.suppress(fullPath);
224
286
  await writeCapturePlanFile(plan, fullPath);
225
- const gnoUri = `gno://${collection.name}/${plan.relPath}`;
226
287
  const syncCollection =
227
288
  dependencies.syncCollection ??
228
289
  defaultSyncService.syncCollection.bind(defaultSyncService);
@@ -235,15 +296,7 @@ export const executeResidentCapturePlan = async (
235
296
  store,
236
297
  syncCollection
237
298
  );
238
- context.scheduler?.notifySyncComplete([plan.relPath]);
239
- context.eventBus?.emit({
240
- type: "document-changed",
241
- uri: gnoUri,
242
- collection: collection.name,
243
- relPath: plan.relPath,
244
- origin: "create",
245
- changedAt: new Date().toISOString(),
246
- });
299
+ emitCaptureCreated(context, collection, plan.relPath);
247
300
  return {
248
301
  collections: [result],
249
302
  totalDurationMs: result.durationMs,
@@ -274,10 +327,131 @@ export const executeResidentCapturePlan = async (
274
327
  error: jobResult.error,
275
328
  },
276
329
  }),
277
- status: 202,
330
+ status: HTTP_ACCEPTED,
278
331
  };
279
332
  };
280
333
 
334
+ /**
335
+ * Write + lexical sync under the shared write lease; the response is sent
336
+ * only once the capture is retrievable. Throws `CaptureSyncError` when the
337
+ * file landed but sync failed, and the `LOCKED` error when the lease stays
338
+ * busy past `lockWaitMs`.
339
+ */
340
+ const executeCaptureAwaitingSync = async (
341
+ context: ResidentCaptureContext,
342
+ store: SqliteAdapter,
343
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>,
344
+ dependencies: ResidentCaptureDependencies
345
+ ): Promise<{ body: unknown; status: number }> => {
346
+ const { collection, fullPath, plan } = planned;
347
+ return withWriteLock(
348
+ resolveCaptureLockPath(context, dependencies),
349
+ async () => {
350
+ await mkdir(dirname(fullPath), { recursive: true });
351
+ context.watchService?.suppress(fullPath);
352
+ await writeCapturePlanFile(plan, fullPath);
353
+ const synced = await syncCapturedFile({
354
+ collection,
355
+ store,
356
+ relPath: plan.relPath,
357
+ absPath: fullPath,
358
+ config: context.config,
359
+ syncPaths: dependencies.syncPaths,
360
+ });
361
+ if (synced.result) {
362
+ recordContentMutation(synced.result, context.markContentMutation);
363
+ }
364
+ emitCaptureCreated(context, collection, plan.relPath);
365
+ return {
366
+ body: buildCaptureReceipt({
367
+ plan,
368
+ absPath: fullPath,
369
+ docid: synced.docid,
370
+ sync: synced.sync,
371
+ }),
372
+ status: HTTP_CREATED,
373
+ };
374
+ },
375
+ dependencies.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS
376
+ );
377
+ };
378
+
379
+ /**
380
+ * `open_existing`: an indexed file needs no lease; a disk-only file is synced
381
+ * under the lease so opening it is also a retrievable success.
382
+ */
383
+ const openExistingCapture = async (
384
+ context: ResidentCaptureContext,
385
+ store: SqliteAdapter,
386
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>,
387
+ dependencies: ResidentCaptureDependencies
388
+ ): Promise<{ body: unknown; status: number }> => {
389
+ const { collection, fullPath, plan } = planned;
390
+ const syncInput = {
391
+ collection,
392
+ store,
393
+ relPath: plan.relPath,
394
+ absPath: fullPath,
395
+ config: context.config,
396
+ syncPaths: dependencies.syncPaths,
397
+ };
398
+ const existingDocument = await store.getDocument(
399
+ collection.name,
400
+ plan.relPath
401
+ );
402
+ if (!existingDocument.ok) {
403
+ throw new Error(existingDocument.error.message);
404
+ }
405
+ const synced = existingDocument.value
406
+ ? await ensureCapturedFileIndexed(syncInput)
407
+ : await withWriteLock(
408
+ resolveCaptureLockPath(context, dependencies),
409
+ () => ensureCapturedFileIndexed(syncInput),
410
+ dependencies.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS
411
+ );
412
+ if (synced.result) {
413
+ recordContentMutation(synced.result, context.markContentMutation);
414
+ }
415
+ return {
416
+ body: buildCaptureReceipt({
417
+ plan,
418
+ absPath: fullPath,
419
+ docid: synced.docid,
420
+ sync: synced.sync,
421
+ }),
422
+ status: HTTP_OK,
423
+ };
424
+ };
425
+
426
+ export const executeResidentCapturePlan = async (
427
+ context: ResidentCaptureContext,
428
+ store: SqliteAdapter,
429
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>,
430
+ dependencies: ResidentCaptureDependencies = {}
431
+ ): Promise<{ body: unknown; status: number }> => {
432
+ const { fullPath, plan } = planned;
433
+ if (plan.provenanceConflict) {
434
+ return {
435
+ body: buildCaptureReceipt({
436
+ plan,
437
+ absPath: fullPath,
438
+ sync: {
439
+ status: "skipped",
440
+ reason:
441
+ "Existing capture has absent or different browser provenance.",
442
+ },
443
+ }),
444
+ status: HTTP_CONFLICT,
445
+ };
446
+ }
447
+ if (plan.openedExisting) {
448
+ return openExistingCapture(context, store, planned, dependencies);
449
+ }
450
+ return (dependencies.mode ?? "job") === "job"
451
+ ? executeCaptureAsJob(context, store, planned, dependencies)
452
+ : executeCaptureAwaitingSync(context, store, planned, dependencies);
453
+ };
454
+
281
455
  export const browserClipIdempotencyPlan = (
282
456
  planned: Extract<ResidentCapturePlanResult, { ok: true }>
283
457
  ): ClipperIdempotencyPlan => {