@gmickel/gno 1.22.0 → 1.24.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 (59) hide show
  1. package/README.md +33 -12
  2. package/assets/skill/SKILL.md +41 -19
  3. package/package.json +1 -1
  4. package/spec/cli.md +127 -20
  5. package/spec/evals-agentic.md +35 -0
  6. package/spec/evals.md +6 -0
  7. package/spec/mcp.md +18 -0
  8. package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
  9. package/spec/output-schemas/query-diagnose.schema.json +89 -2
  10. package/spec/output-schemas/setup-activation-result.schema.json +456 -0
  11. package/spec/output-schemas/setup-command-result.schema.json +93 -0
  12. package/spec/output-schemas/setup-receipt.schema.json +258 -0
  13. package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
  14. package/src/app/context-runtime-types.ts +3 -0
  15. package/src/app/context-runtime.ts +1 -0
  16. package/src/app/context-surface.ts +4 -2
  17. package/src/cli/commands/ask.ts +31 -20
  18. package/src/cli/commands/completion/scripts.ts +2 -0
  19. package/src/cli/commands/context-build.ts +17 -7
  20. package/src/cli/commands/embed.ts +7 -2
  21. package/src/cli/commands/query.ts +58 -37
  22. package/src/cli/commands/search.ts +29 -19
  23. package/src/cli/commands/setup-activation.ts +324 -0
  24. package/src/cli/commands/setup-semantic.ts +591 -0
  25. package/src/cli/commands/setup.ts +410 -0
  26. package/src/cli/commands/vsearch.ts +31 -22
  27. package/src/cli/options.ts +39 -0
  28. package/src/cli/program.ts +112 -0
  29. package/src/cli/setup-semantic-worker.ts +177 -0
  30. package/src/config/defaults.ts +10 -1
  31. package/src/config/types.ts +71 -0
  32. package/src/core/config-mutation.ts +94 -64
  33. package/src/core/file-lock.ts +70 -31
  34. package/src/core/folder-setup-planning.ts +453 -0
  35. package/src/core/folder-setup.ts +490 -0
  36. package/src/core/project-affinity-surface.ts +114 -0
  37. package/src/core/project-affinity.ts +330 -0
  38. package/src/core/setup-activation.ts +309 -0
  39. package/src/core/setup-receipt.ts +321 -0
  40. package/src/core/validation.ts +20 -1
  41. package/src/mcp/tools/ask.ts +10 -1
  42. package/src/mcp/tools/context.ts +18 -0
  43. package/src/mcp/tools/index.ts +13 -2
  44. package/src/mcp/tools/query.ts +12 -0
  45. package/src/mcp/tools/search.ts +7 -0
  46. package/src/mcp/tools/vsearch.ts +7 -0
  47. package/src/pipeline/diagnose.ts +48 -3
  48. package/src/pipeline/explain.ts +54 -13
  49. package/src/pipeline/hybrid.ts +100 -59
  50. package/src/pipeline/project-affinity.ts +162 -0
  51. package/src/pipeline/search.ts +76 -10
  52. package/src/pipeline/types.ts +9 -0
  53. package/src/pipeline/vsearch.ts +117 -91
  54. package/src/sdk/client.ts +80 -20
  55. package/src/sdk/index.ts +2 -0
  56. package/src/sdk/types.ts +20 -7
  57. package/src/serve/connectors.ts +29 -2
  58. package/src/serve/context-capsule.ts +18 -1
  59. package/src/serve/routes/api.ts +69 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Durable, one-shot semantic handoff for `gno setup`.
3
+ *
4
+ * This is deliberately independent of the resident runtime. The setup parent
5
+ * records a local job and starts one detached Bun process; that process embeds
6
+ * the selected collection and exits.
7
+ *
8
+ * @module src/cli/commands/setup-semantic
9
+ */
10
+
11
+ // node:fs provides append-only descriptors for detached child stdio; Bun.spawn
12
+ // accepts numeric descriptors but Bun has no append-open equivalent.
13
+ import { closeSync, openSync } from "node:fs";
14
+ // node:fs/promises provides private atomic file replacement without a Bun equivalent.
15
+ import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
16
+ // node:path has no Bun path utilities.
17
+ import { dirname, join } from "node:path";
18
+
19
+ import type { FolderSetupReceipt } from "../../core/setup-receipt";
20
+
21
+ import { VERSION } from "../../app/constants";
22
+ import { canonicalizeIndexName } from "../../app/index-name";
23
+ import { withWriteLock } from "../../core/file-lock";
24
+ import {
25
+ setupFingerprint,
26
+ setupRootFingerprint,
27
+ } from "../../core/setup-receipt";
28
+ import { isProcessAlive } from "../detach";
29
+
30
+ export const SETUP_SEMANTIC_SCHEMA_VERSION = "1.0" as const;
31
+ export const SETUP_SEMANTIC_STATUSES = [
32
+ "scheduled",
33
+ "running",
34
+ "completed",
35
+ "failed",
36
+ "pending",
37
+ "skipped",
38
+ ] as const;
39
+
40
+ export type SetupSemanticStatus = (typeof SETUP_SEMANTIC_STATUSES)[number];
41
+
42
+ export interface SetupSemanticReceipt {
43
+ schemaVersion: typeof SETUP_SEMANTIC_SCHEMA_VERSION;
44
+ status: SetupSemanticStatus;
45
+ generatedAt: string;
46
+ startedAt: string | null;
47
+ completedAt: string | null;
48
+ jobId: string;
49
+ collection: string;
50
+ indexName: string;
51
+ folderFingerprint: string;
52
+ pid: number | null;
53
+ offline: boolean;
54
+ setupReceiptFingerprint: string;
55
+ setupReceiptPath: string;
56
+ receiptPath: string;
57
+ logPath: string;
58
+ resumeCommand: string;
59
+ counts: {
60
+ embedded: number;
61
+ errors: number;
62
+ } | null;
63
+ error: {
64
+ message: string;
65
+ remediation: string;
66
+ } | null;
67
+ }
68
+
69
+ interface SpawnedSemanticWorker {
70
+ pid: number;
71
+ }
72
+
73
+ export interface ScheduleSetupSemanticOptions {
74
+ setupReceipt: FolderSetupReceipt;
75
+ dataDir: string;
76
+ configPath: string;
77
+ indexName: string;
78
+ offline: boolean;
79
+ disabled?: boolean;
80
+ now?: () => Date;
81
+ spawnWorker?: (
82
+ receipt: SetupSemanticReceipt
83
+ ) => Promise<SpawnedSemanticWorker>;
84
+ processIsAlive?: (pid: number) => boolean;
85
+ }
86
+
87
+ const MAX_ERROR_LENGTH = 500;
88
+ const MAX_REMEDIATION_LENGTH = 8192;
89
+ const LOCK_TIMEOUT_MS = 5000;
90
+ const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
91
+ const COLLECTION_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
92
+ const SEMANTIC_RECEIPT_KEYS = [
93
+ "schemaVersion",
94
+ "status",
95
+ "generatedAt",
96
+ "startedAt",
97
+ "completedAt",
98
+ "jobId",
99
+ "collection",
100
+ "indexName",
101
+ "folderFingerprint",
102
+ "pid",
103
+ "offline",
104
+ "setupReceiptFingerprint",
105
+ "setupReceiptPath",
106
+ "receiptPath",
107
+ "logPath",
108
+ "resumeCommand",
109
+ "counts",
110
+ "error",
111
+ ] as const;
112
+
113
+ function nowIso(now?: () => Date): string {
114
+ return (now ?? (() => new Date()))().toISOString();
115
+ }
116
+
117
+ function boundedMessage(value: unknown): string {
118
+ const message = value instanceof Error ? value.message : String(value);
119
+ return message.slice(0, MAX_ERROR_LENGTH) || "Unknown semantic setup error";
120
+ }
121
+
122
+ function quoteShellArg(value: string): string {
123
+ if (/^[a-zA-Z0-9_./:@+-]+$/.test(value)) {
124
+ return value;
125
+ }
126
+ return `'${value.replaceAll("'", "'\\''")}'`;
127
+ }
128
+
129
+ export function buildSetupSemanticResumeCommand(input: {
130
+ indexName: string;
131
+ configPath: string;
132
+ offline: boolean;
133
+ collection: string;
134
+ }): string {
135
+ const flags = [
136
+ "--index",
137
+ quoteShellArg(input.indexName),
138
+ "--config",
139
+ quoteShellArg(input.configPath),
140
+ ];
141
+ if (input.offline) {
142
+ flags.push("--offline");
143
+ }
144
+ return `gno ${flags.join(" ")} embed ${quoteShellArg(input.collection)}`;
145
+ }
146
+
147
+ export function getSetupSemanticReceiptPath(input: {
148
+ dataDir: string;
149
+ indexName: string;
150
+ folderRealpath: string;
151
+ }): string {
152
+ return join(
153
+ input.dataDir,
154
+ "setup-semantic",
155
+ canonicalizeIndexName(input.indexName),
156
+ `${setupRootFingerprint(input.folderRealpath)}.json`
157
+ );
158
+ }
159
+
160
+ export function serializeSetupSemanticReceipt(
161
+ receipt: SetupSemanticReceipt
162
+ ): string {
163
+ return `${JSON.stringify(receipt, null, 2)}\n`;
164
+ }
165
+
166
+ export async function persistSetupSemanticReceipt(
167
+ receipt: SetupSemanticReceipt
168
+ ): Promise<void> {
169
+ if (!isSetupSemanticReceipt(receipt, receipt.receiptPath)) {
170
+ throw new Error("Refusing to persist an invalid semantic setup receipt");
171
+ }
172
+ const receiptDir = dirname(receipt.receiptPath);
173
+ await mkdir(receiptDir, { recursive: true, mode: 0o700 });
174
+ await chmod(receiptDir, 0o700);
175
+ await mkdir(dirname(receipt.logPath), { recursive: true, mode: 0o700 });
176
+ await chmod(dirname(receipt.logPath), 0o700);
177
+
178
+ const temporaryPath = `${receipt.receiptPath}.tmp.${crypto.randomUUID()}`;
179
+ let temporaryFile: Awaited<ReturnType<typeof open>> | null = null;
180
+ try {
181
+ temporaryFile = await open(temporaryPath, "wx", 0o600);
182
+ await temporaryFile.writeFile(
183
+ serializeSetupSemanticReceipt(receipt),
184
+ "utf8"
185
+ );
186
+ await temporaryFile.sync();
187
+ await temporaryFile.close();
188
+ temporaryFile = null;
189
+ await rename(temporaryPath, receipt.receiptPath);
190
+ await chmod(receipt.receiptPath, 0o600);
191
+ } catch (error) {
192
+ await temporaryFile?.close().catch(() => undefined);
193
+ await unlink(temporaryPath).catch(() => undefined);
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ export async function loadSetupSemanticReceipt(
199
+ path: string
200
+ ): Promise<SetupSemanticReceipt | null> {
201
+ const file = Bun.file(path);
202
+ if (!(await file.exists())) {
203
+ return null;
204
+ }
205
+ try {
206
+ const value: unknown = await file.json();
207
+ if (!isSetupSemanticReceipt(value, path)) {
208
+ return null;
209
+ }
210
+ return value;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function isNullableString(value: unknown): value is string | null {
217
+ return value === null || typeof value === "string";
218
+ }
219
+
220
+ function isIsoDate(value: string | null): boolean {
221
+ return value === null || Number.isFinite(Date.parse(value));
222
+ }
223
+
224
+ function hasExactReceiptKeys(receipt: Record<string, unknown>): boolean {
225
+ const expected = new Set<string>(SEMANTIC_RECEIPT_KEYS);
226
+ const keys = Object.keys(receipt);
227
+ return (
228
+ keys.length === SEMANTIC_RECEIPT_KEYS.length &&
229
+ keys.every((key) => expected.has(key))
230
+ );
231
+ }
232
+
233
+ function hasValidStatusState(receipt: SetupSemanticReceipt): boolean {
234
+ switch (receipt.status) {
235
+ case "scheduled":
236
+ return (
237
+ receipt.startedAt === null &&
238
+ receipt.completedAt === null &&
239
+ receipt.counts === null &&
240
+ receipt.error === null
241
+ );
242
+ case "running":
243
+ return (
244
+ receipt.pid !== null &&
245
+ receipt.startedAt !== null &&
246
+ receipt.completedAt === null &&
247
+ receipt.counts === null &&
248
+ receipt.error === null
249
+ );
250
+ case "completed":
251
+ return (
252
+ receipt.pid === null &&
253
+ receipt.completedAt !== null &&
254
+ receipt.counts !== null &&
255
+ receipt.error === null
256
+ );
257
+ case "failed":
258
+ return (
259
+ receipt.pid === null &&
260
+ receipt.completedAt !== null &&
261
+ receipt.error !== null
262
+ );
263
+ case "pending":
264
+ return (
265
+ receipt.completedAt === null &&
266
+ receipt.counts === null &&
267
+ receipt.error !== null
268
+ );
269
+ case "skipped":
270
+ return (
271
+ receipt.completedAt !== null &&
272
+ receipt.counts === null &&
273
+ receipt.error === null
274
+ );
275
+ default:
276
+ return false;
277
+ }
278
+ }
279
+
280
+ function isSetupSemanticReceipt(
281
+ value: unknown,
282
+ expectedPath: string
283
+ ): value is SetupSemanticReceipt {
284
+ if (typeof value !== "object" || value === null) {
285
+ return false;
286
+ }
287
+ const receipt = value as Record<string, unknown>;
288
+ const error = receipt.error;
289
+ const counts = receipt.counts;
290
+ return (
291
+ hasExactReceiptKeys(receipt) &&
292
+ receipt.schemaVersion === SETUP_SEMANTIC_SCHEMA_VERSION &&
293
+ SETUP_SEMANTIC_STATUSES.includes(receipt.status as SetupSemanticStatus) &&
294
+ typeof receipt.generatedAt === "string" &&
295
+ isIsoDate(receipt.generatedAt) &&
296
+ isNullableString(receipt.startedAt) &&
297
+ isIsoDate(receipt.startedAt) &&
298
+ isNullableString(receipt.completedAt) &&
299
+ isIsoDate(receipt.completedAt) &&
300
+ typeof receipt.jobId === "string" &&
301
+ FINGERPRINT_PATTERN.test(receipt.jobId) &&
302
+ typeof receipt.collection === "string" &&
303
+ COLLECTION_PATTERN.test(receipt.collection) &&
304
+ typeof receipt.indexName === "string" &&
305
+ receipt.indexName.length > 0 &&
306
+ receipt.indexName.length <= 64 &&
307
+ typeof receipt.folderFingerprint === "string" &&
308
+ FINGERPRINT_PATTERN.test(receipt.folderFingerprint) &&
309
+ (receipt.pid === null ||
310
+ (typeof receipt.pid === "number" &&
311
+ Number.isInteger(receipt.pid) &&
312
+ receipt.pid > 0)) &&
313
+ typeof receipt.offline === "boolean" &&
314
+ typeof receipt.setupReceiptFingerprint === "string" &&
315
+ FINGERPRINT_PATTERN.test(receipt.setupReceiptFingerprint) &&
316
+ typeof receipt.setupReceiptPath === "string" &&
317
+ receipt.setupReceiptPath.length > 0 &&
318
+ receipt.receiptPath === expectedPath &&
319
+ typeof receipt.logPath === "string" &&
320
+ receipt.logPath.length > 0 &&
321
+ typeof receipt.resumeCommand === "string" &&
322
+ receipt.resumeCommand.startsWith("gno ") &&
323
+ receipt.resumeCommand.includes(" embed ") &&
324
+ (counts === null ||
325
+ (typeof counts === "object" &&
326
+ counts !== null &&
327
+ Object.keys(counts).length === 2 &&
328
+ Number.isInteger((counts as Record<string, unknown>).embedded) &&
329
+ Number((counts as Record<string, unknown>).embedded) >= 0 &&
330
+ Number.isInteger((counts as Record<string, unknown>).errors) &&
331
+ Number((counts as Record<string, unknown>).errors) >= 0)) &&
332
+ (error === null ||
333
+ (typeof error === "object" &&
334
+ error !== null &&
335
+ Object.keys(error).length === 2 &&
336
+ typeof (error as Record<string, unknown>).message === "string" &&
337
+ ((error as Record<string, unknown>).message as string).length > 0 &&
338
+ ((error as Record<string, unknown>).message as string).length <=
339
+ MAX_ERROR_LENGTH &&
340
+ typeof (error as Record<string, unknown>).remediation === "string" &&
341
+ ((error as Record<string, unknown>).remediation as string).length > 0 &&
342
+ ((error as Record<string, unknown>).remediation as string).length <=
343
+ MAX_REMEDIATION_LENGTH)) &&
344
+ hasValidStatusState(receipt as unknown as SetupSemanticReceipt)
345
+ );
346
+ }
347
+
348
+ export function setupSemanticSourceFingerprint(
349
+ receipt: FolderSetupReceipt
350
+ ): string {
351
+ return setupFingerprint({
352
+ schemaVersion: receipt.schemaVersion,
353
+ status: receipt.status,
354
+ input: receipt.input,
355
+ fingerprints: receipt.fingerprints,
356
+ collection: {
357
+ name: receipt.collection.name,
358
+ path: receipt.collection.path,
359
+ },
360
+ paths: receipt.paths,
361
+ activation: receipt.activation
362
+ ? {
363
+ collection: receipt.activation.collection,
364
+ fingerprint: receipt.activation.fingerprint,
365
+ ready: receipt.activation.ready,
366
+ evidence: receipt.activation.evidence,
367
+ }
368
+ : null,
369
+ });
370
+ }
371
+
372
+ function createSemanticReceipt(
373
+ options: ScheduleSetupSemanticOptions,
374
+ status: SetupSemanticStatus
375
+ ): SetupSemanticReceipt {
376
+ const setupReceipt = options.setupReceipt;
377
+ const collection = setupReceipt.collection.name;
378
+ if (!collection) {
379
+ throw new Error("Completed setup receipt has no collection");
380
+ }
381
+ const indexName = canonicalizeIndexName(options.indexName);
382
+ const receiptPath = getSetupSemanticReceiptPath({
383
+ dataDir: options.dataDir,
384
+ indexName,
385
+ folderRealpath: setupReceipt.input.folder,
386
+ });
387
+ const generatedAt = nowIso(options.now);
388
+ const setupReceiptFingerprint = setupSemanticSourceFingerprint(setupReceipt);
389
+ return {
390
+ schemaVersion: SETUP_SEMANTIC_SCHEMA_VERSION,
391
+ status,
392
+ generatedAt,
393
+ startedAt: null,
394
+ completedAt: status === "skipped" ? generatedAt : null,
395
+ jobId: setupFingerprint({
396
+ setupReceiptFingerprint,
397
+ packageVersion: VERSION,
398
+ indexName,
399
+ configPath: options.configPath,
400
+ offline: options.offline,
401
+ }),
402
+ collection,
403
+ indexName,
404
+ folderFingerprint: setupReceipt.input.folderFingerprint,
405
+ pid: null,
406
+ offline: options.offline,
407
+ setupReceiptFingerprint,
408
+ setupReceiptPath: setupReceipt.paths.receipt,
409
+ receiptPath,
410
+ logPath: join(
411
+ options.dataDir,
412
+ "setup-semantic",
413
+ indexName,
414
+ `${setupReceipt.input.folderFingerprint}.log`
415
+ ),
416
+ resumeCommand: buildSetupSemanticResumeCommand({
417
+ indexName,
418
+ configPath: options.configPath,
419
+ offline: options.offline,
420
+ collection,
421
+ }),
422
+ counts: null,
423
+ error: null,
424
+ };
425
+ }
426
+
427
+ async function defaultSpawnWorker(
428
+ receipt: SetupSemanticReceipt
429
+ ): Promise<SpawnedSemanticWorker> {
430
+ const workerPath = join(import.meta.dir, "..", "setup-semantic-worker.ts");
431
+ const descriptor = openSync(receipt.logPath, "a", 0o600);
432
+ try {
433
+ const child = Bun.spawn({
434
+ cmd: [process.execPath, workerPath, receipt.receiptPath, receipt.jobId],
435
+ stdio: ["ignore", descriptor, descriptor],
436
+ detached: true,
437
+ env: process.env,
438
+ });
439
+ child.unref();
440
+ return { pid: child.pid };
441
+ } finally {
442
+ closeSync(descriptor);
443
+ }
444
+ }
445
+
446
+ function existingReceiptMatches(
447
+ existing: SetupSemanticReceipt,
448
+ expected: SetupSemanticReceipt
449
+ ): boolean {
450
+ return (
451
+ existing.jobId === expected.jobId &&
452
+ existing.collection === expected.collection &&
453
+ existing.indexName === expected.indexName &&
454
+ existing.setupReceiptFingerprint === expected.setupReceiptFingerprint &&
455
+ existing.setupReceiptPath === expected.setupReceiptPath &&
456
+ existing.offline === expected.offline
457
+ );
458
+ }
459
+
460
+ /**
461
+ * Schedule one collection-scoped semantic worker without waiting for model
462
+ * download or embedding.
463
+ */
464
+ export async function scheduleSetupSemantic(
465
+ options: ScheduleSetupSemanticOptions
466
+ ): Promise<SetupSemanticReceipt> {
467
+ const initial = createSemanticReceipt(
468
+ options,
469
+ options.disabled ? "skipped" : "scheduled"
470
+ );
471
+ const lockPath = `${initial.receiptPath}.lock`;
472
+
473
+ try {
474
+ return await withWriteLock(
475
+ lockPath,
476
+ async () => {
477
+ const existing = await loadSetupSemanticReceipt(initial.receiptPath);
478
+ const processAlive = options.processIsAlive ?? isProcessAlive;
479
+ if (options.disabled) {
480
+ const existingIsLive =
481
+ existing !== null &&
482
+ (existing.status === "scheduled" ||
483
+ existing.status === "running" ||
484
+ existing.status === "pending" ||
485
+ existing.status === "skipped") &&
486
+ existing.pid !== null &&
487
+ processAlive(existing.pid);
488
+ const skippedBase = existingIsLive ? existing : initial;
489
+ const generatedAt = nowIso(options.now);
490
+ const skipped: SetupSemanticReceipt = {
491
+ ...skippedBase,
492
+ status: "skipped",
493
+ generatedAt,
494
+ completedAt: generatedAt,
495
+ pid: existingIsLive ? existing.pid : null,
496
+ counts: null,
497
+ error: null,
498
+ };
499
+ await persistSetupSemanticReceipt(skipped);
500
+ return skipped;
501
+ }
502
+ if (existing && existingReceiptMatches(existing, initial)) {
503
+ if (existing.status === "completed") {
504
+ return existing;
505
+ }
506
+ if (
507
+ (existing.status === "scheduled" ||
508
+ existing.status === "running" ||
509
+ existing.status === "pending" ||
510
+ existing.status === "skipped") &&
511
+ existing.pid !== null &&
512
+ processAlive(existing.pid)
513
+ ) {
514
+ return existing;
515
+ }
516
+ }
517
+ if (
518
+ existing &&
519
+ !existingReceiptMatches(existing, initial) &&
520
+ (existing.status === "scheduled" ||
521
+ existing.status === "running" ||
522
+ existing.status === "pending" ||
523
+ existing.status === "skipped") &&
524
+ existing.pid !== null &&
525
+ processAlive(existing.pid)
526
+ ) {
527
+ // The active worker owns this receipt identity. Replacing it would
528
+ // make its final update fail the jobId check and strand the durable
529
+ // state. Preserve it; a later setup rerun can schedule the new
530
+ // identity after this process exits.
531
+ return existing;
532
+ }
533
+
534
+ await persistSetupSemanticReceipt(initial);
535
+ try {
536
+ const spawned = await (options.spawnWorker ?? defaultSpawnWorker)(
537
+ initial
538
+ );
539
+ const scheduled: SetupSemanticReceipt = {
540
+ ...initial,
541
+ pid: spawned.pid,
542
+ generatedAt: nowIso(options.now),
543
+ };
544
+ await persistSetupSemanticReceipt(scheduled);
545
+ return scheduled;
546
+ } catch (error) {
547
+ const pending: SetupSemanticReceipt = {
548
+ ...initial,
549
+ status: "pending",
550
+ generatedAt: nowIso(options.now),
551
+ error: {
552
+ message: boundedMessage(error),
553
+ remediation: `Run: ${initial.resumeCommand}`,
554
+ },
555
+ };
556
+ await persistSetupSemanticReceipt(pending);
557
+ return pending;
558
+ }
559
+ },
560
+ LOCK_TIMEOUT_MS
561
+ );
562
+ } catch (error) {
563
+ return {
564
+ ...initial,
565
+ status: "pending",
566
+ generatedAt: nowIso(options.now),
567
+ error: {
568
+ message: boundedMessage(error),
569
+ remediation: `Run: ${initial.resumeCommand}`,
570
+ },
571
+ };
572
+ }
573
+ }
574
+
575
+ export async function updateSetupSemanticReceipt(
576
+ receiptPath: string,
577
+ jobId: string,
578
+ update: (
579
+ receipt: SetupSemanticReceipt
580
+ ) => SetupSemanticReceipt | Promise<SetupSemanticReceipt>
581
+ ): Promise<SetupSemanticReceipt> {
582
+ return withWriteLock(`${receiptPath}.lock`, async () => {
583
+ const receipt = await loadSetupSemanticReceipt(receiptPath);
584
+ if (!receipt || receipt.jobId !== jobId) {
585
+ throw new Error("Semantic setup receipt identity changed");
586
+ }
587
+ const next = await update(receipt);
588
+ await persistSetupSemanticReceipt(next);
589
+ return next;
590
+ });
591
+ }