@gmickel/gno 1.37.1 → 1.39.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 (47) hide show
  1. package/assets/skill/README.md +2 -0
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.37.1.zip → gno-browser-clipper-v1.39.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.39.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +1 -1
  6. package/spec/cli.md +243 -26
  7. package/spec/output-schemas/agents-mutation.schema.json +108 -0
  8. package/spec/output-schemas/agents-verify.schema.json +89 -0
  9. package/src/cli/commands/agents/block.ts +164 -0
  10. package/src/cli/commands/agents/commands.ts +413 -0
  11. package/src/cli/commands/agents/engine.ts +417 -0
  12. package/src/cli/commands/agents/harnesses.ts +298 -0
  13. package/src/cli/commands/agents/index.ts +35 -0
  14. package/src/cli/commands/cleanup.ts +8 -2
  15. package/src/cli/commands/collection/clear-embeddings.ts +6 -1
  16. package/src/cli/commands/completion/scripts.ts +5 -0
  17. package/src/cli/commands/doctor-activation.ts +5 -1
  18. package/src/cli/commands/doctor.ts +72 -2
  19. package/src/cli/commands/embed.ts +227 -194
  20. package/src/cli/commands/index-cmd.ts +74 -50
  21. package/src/cli/commands/init.ts +5 -1
  22. package/src/cli/commands/profile-apply.ts +5 -1
  23. package/src/cli/commands/setup-activation.ts +2 -1
  24. package/src/cli/commands/setup.ts +2 -1
  25. package/src/cli/commands/shared.ts +5 -1
  26. package/src/cli/commands/status.ts +5 -1
  27. package/src/cli/commands/tags.ts +18 -3
  28. package/src/cli/commands/update.ts +34 -27
  29. package/src/cli/commands/vec.ts +13 -4
  30. package/src/cli/errors.ts +3 -2
  31. package/src/cli/program.ts +449 -194
  32. package/src/config/defaults.ts +2 -0
  33. package/src/config/index.ts +3 -0
  34. package/src/config/types.ts +32 -1
  35. package/src/core/file-lock.ts +16 -4
  36. package/src/core/write-lease.ts +354 -0
  37. package/src/embed/backlog.ts +9 -1
  38. package/src/embed/retry.ts +116 -3
  39. package/src/sdk/client.ts +3 -1
  40. package/src/sdk/embed.ts +8 -3
  41. package/src/sdk/types.ts +2 -0
  42. package/src/serve/embed-scheduler.ts +8 -0
  43. package/src/serve/resident-runtime.ts +5 -1
  44. package/src/store/sqlite/adapter.ts +28 -4
  45. package/src/store/sqlite/scoped-index.ts +5 -1
  46. package/src/store/vector/sqlite-vec.ts +2 -1
  47. package/browser-extension/artifacts/gno-browser-clipper-v1.37.1.zip.sha256 +0 -1
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Plan/apply engine for `gno agents` — marker-managed block installation.
3
+ *
4
+ * Guarantees:
5
+ * - Only the owned block changes; content outside markers is byte-identical.
6
+ * - Backup-first: an existing file is copied to `<file>.gno-agents.bak.<ts>`
7
+ * before any write; the write itself lands via a sibling temp file and an
8
+ * atomic rename, so a failed write leaves the live file untouched.
9
+ * - Idempotent: re-running when current is a no-op (no write, no backup).
10
+ * - Symlink-aware: writes go through the resolved real file; targets that
11
+ * resolve to the same real file are written once.
12
+ * - Fail-closed: malformed markers, non-UTF-8 content, or any I/O failure
13
+ * produce an `error` row and no write — the command then prints the block
14
+ * so the operator can apply it by hand.
15
+ *
16
+ * @module src/cli/commands/agents/engine
17
+ */
18
+
19
+ // node:fs/promises: Bun has no chmod (the backup and the replacement must
20
+ // keep the source file's mode), no atomic rename, and no unlink.
21
+ import { chmod, rename, stat, unlink } from "node:fs/promises";
22
+
23
+ import type { ExtractedBlock } from "./block.js";
24
+ import type { ResolvedTarget } from "./harnesses.js";
25
+
26
+ import { CliError } from "../../errors.js";
27
+ import { extractBlock, renderBlock } from "./block.js";
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Types
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ export type PlanAction =
34
+ | "install" // no block yet — append it
35
+ | "update" // block present but differs — replace in place
36
+ | "current" // block present and identical — no-op
37
+ | "remove" // uninstall: block present — remove it
38
+ | "absent" // uninstall: no block — no-op
39
+ | "covered" // import chain or same real file — another target owns it
40
+ | "not-detected" // harness not installed on this machine — skipped
41
+ | "error"; // fail-closed (malformed markers, unreadable file)
42
+
43
+ export type PlanMode = "install" | "uninstall";
44
+
45
+ export interface TargetPlan {
46
+ target: ResolvedTarget;
47
+ action: PlanAction;
48
+ /** Target id that covers this one (import chain or shared real file). */
49
+ via?: string;
50
+ /** Human-readable detail (e.g. error guidance). */
51
+ detail?: string;
52
+ /** Existing file content ("" when the file does not exist yet). */
53
+ oldContent: string;
54
+ /** Content after the change (equal to oldContent for no-op actions). */
55
+ newContent: string;
56
+ fileExists: boolean;
57
+ /** The existing file began with a UTF-8 BOM; the write re-prepends it. */
58
+ bom?: boolean;
59
+ /**
60
+ * For `action: "error"`: exit-code category — VALIDATION for bad content
61
+ * (malformed markers, non-UTF-8), RUNTIME for filesystem failures.
62
+ */
63
+ errorCode?: "VALIDATION" | "RUNTIME";
64
+ }
65
+
66
+ const UTF8_BOM = "";
67
+ const utf8Fatal = new TextDecoder("utf-8", { fatal: true });
68
+
69
+ /**
70
+ * Decode an instruction file's bytes without normalizing operator content:
71
+ * a UTF-8 BOM is split off (and reported) rather than dropped, and any
72
+ * non-UTF-8 sequence is a hard error rather than a silent replacement
73
+ * character — rewriting such a file would corrupt bytes outside the markers.
74
+ */
75
+ export function decodeInstructionFile(
76
+ bytes: Uint8Array,
77
+ path: string
78
+ ): { content: string; bom: boolean } {
79
+ const bom =
80
+ bytes.length >= 3 &&
81
+ bytes[0] === 0xef &&
82
+ bytes[1] === 0xbb &&
83
+ bytes[2] === 0xbf;
84
+ const body = bom ? bytes.subarray(3) : bytes;
85
+ try {
86
+ return { content: utf8Fatal.decode(body), bom };
87
+ } catch {
88
+ throw new CliError(
89
+ "VALIDATION",
90
+ `${path} is not valid UTF-8; refusing to rewrite it (bytes outside the GNO agents block would be altered). Convert the file to UTF-8, then re-run.`
91
+ );
92
+ }
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // Content Transforms
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Append or replace the managed block; bytes outside the block untouched.
101
+ * A fresh install goes at the end of the file, separated by one blank line
102
+ * (a file without a final newline gets that newline first).
103
+ */
104
+ export function withBlock(
105
+ oldContent: string,
106
+ block: ExtractedBlock | null,
107
+ rendered: string
108
+ ): string {
109
+ if (block) {
110
+ return (
111
+ oldContent.slice(0, block.start) + rendered + oldContent.slice(block.end)
112
+ );
113
+ }
114
+ if (oldContent.length === 0) {
115
+ return `${rendered}\n`;
116
+ }
117
+ const terminator = oldContent.endsWith("\n") ? "" : "\n";
118
+ return `${oldContent}${terminator}\n${rendered}\n`;
119
+ }
120
+
121
+ /**
122
+ * Remove the managed block plus the newline that terminates it and the blank
123
+ * line install put above it (when one is there). Operator content is never
124
+ * touched beyond that single separator.
125
+ */
126
+ export function withoutBlock(
127
+ oldContent: string,
128
+ block: ExtractedBlock
129
+ ): string {
130
+ let start = block.start;
131
+ let end = block.end;
132
+ if (oldContent[end] === "\n") {
133
+ end += 1;
134
+ }
135
+ if (oldContent.slice(start - 2, start) === "\n\n") {
136
+ start -= 1;
137
+ }
138
+ return oldContent.slice(0, start) + oldContent.slice(end);
139
+ }
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // Planning
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+
145
+ async function readFileIfExists(
146
+ path: string
147
+ ): Promise<{ exists: boolean; content: string; bom: boolean }> {
148
+ const file = Bun.file(path);
149
+ if (!(await file.exists())) {
150
+ return { exists: false, content: "", bom: false };
151
+ }
152
+ // Bytes, not .text(): text() strips a BOM and replaces invalid sequences,
153
+ // which would silently alter operator-owned bytes on the rewrite.
154
+ const { content, bom } = decodeInstructionFile(await file.bytes(), path);
155
+ return { exists: true, content, bom };
156
+ }
157
+
158
+ function noopPlan(
159
+ target: ResolvedTarget,
160
+ action: PlanAction,
161
+ extra: Partial<TargetPlan> = {}
162
+ ): TargetPlan {
163
+ return {
164
+ target,
165
+ action,
166
+ oldContent: "",
167
+ newContent: "",
168
+ fileExists: false,
169
+ ...extra,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Build a per-target plan. Fail-closed per file: a target with malformed
175
+ * markers or an unreadable file gets an `error` plan (and never a write);
176
+ * other targets proceed.
177
+ */
178
+ export async function planTargets(
179
+ targets: ResolvedTarget[],
180
+ mode: PlanMode
181
+ ): Promise<TargetPlan[]> {
182
+ const plans: TargetPlan[] = [];
183
+ /** Real-file identity → id of the target that owns the write. */
184
+ const owners = new Map<string, string>();
185
+
186
+ // A detected covered target (e.g. grok → claude) is only truly covered
187
+ // when its covering target's file converges too — so the covering target
188
+ // is planned even when its own harness is not detected on this machine.
189
+ const requiredCovering = new Set<string>();
190
+ for (const t of targets) {
191
+ if (t.detected && t.coveredBy) {
192
+ requiredCovering.add(t.coveredBy);
193
+ }
194
+ }
195
+
196
+ for (const target of targets) {
197
+ if (!(target.detected || requiredCovering.has(target.id))) {
198
+ plans.push(noopPlan(target, "not-detected"));
199
+ continue;
200
+ }
201
+ if (target.coveredBy) {
202
+ const coveringResolved = targets.some((t) => t.id === target.coveredBy);
203
+ plans.push(
204
+ coveringResolved
205
+ ? noopPlan(target, "covered", {
206
+ via: target.coveredBy,
207
+ detail: `covered via ${target.coveredBy}`,
208
+ })
209
+ : noopPlan(target, "error", {
210
+ errorCode: "VALIDATION",
211
+ detail: `covered via ${target.coveredBy}, but ${target.coveredBy} was not resolved in this run`,
212
+ })
213
+ );
214
+ continue;
215
+ }
216
+ const owner = owners.get(target.realFile);
217
+ if (owner) {
218
+ plans.push(
219
+ noopPlan(target, "covered", {
220
+ via: owner,
221
+ detail: `covered via ${owner} (same file)`,
222
+ })
223
+ );
224
+ continue;
225
+ }
226
+ owners.set(target.realFile, target.id);
227
+
228
+ let exists = false;
229
+ let content = "";
230
+ let bom = false;
231
+ let extraction: ReturnType<typeof extractBlock>;
232
+ try {
233
+ ({ exists, content, bom } = await readFileIfExists(target.file));
234
+ extraction = extractBlock(content, target.file);
235
+ } catch (err) {
236
+ plans.push(
237
+ noopPlan(target, "error", {
238
+ errorCode:
239
+ err instanceof CliError && err.code === "VALIDATION"
240
+ ? "VALIDATION"
241
+ : "RUNTIME",
242
+ detail: err instanceof Error ? err.message : String(err),
243
+ oldContent: content,
244
+ newContent: content,
245
+ fileExists: exists,
246
+ })
247
+ );
248
+ continue;
249
+ }
250
+
251
+ const base = { target, oldContent: content, fileExists: exists, bom };
252
+ if (mode === "uninstall") {
253
+ plans.push(
254
+ extraction.found
255
+ ? {
256
+ ...base,
257
+ action: "remove",
258
+ newContent: withoutBlock(content, extraction.block),
259
+ }
260
+ : { ...base, action: "absent", newContent: content }
261
+ );
262
+ continue;
263
+ }
264
+
265
+ const newContent = withBlock(
266
+ content,
267
+ extraction.found ? extraction.block : null,
268
+ renderBlock()
269
+ );
270
+ plans.push({
271
+ ...base,
272
+ action: !extraction.found
273
+ ? "install"
274
+ : newContent === content
275
+ ? "current"
276
+ : "update",
277
+ newContent,
278
+ });
279
+ }
280
+
281
+ return plans;
282
+ }
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────────
285
+ // Apply
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+
288
+ const WRITE_ACTIONS: PlanAction[] = ["install", "update", "remove"];
289
+
290
+ export function planWrites(plan: TargetPlan): boolean {
291
+ return WRITE_ACTIONS.includes(plan.action);
292
+ }
293
+
294
+ function describe(err: unknown): string {
295
+ return err instanceof Error ? err.message : String(err);
296
+ }
297
+
298
+ /**
299
+ * Apply one plan: back up an existing file, then write the new content via a
300
+ * sibling temp file + atomic rename, through the resolved real file so
301
+ * operator symlink schemes stay intact. Returns the backup path (null when
302
+ * the file did not exist). Any failure throws RUNTIME with the live file
303
+ * unchanged (a backup already made is reported, not removed).
304
+ */
305
+ export async function applyPlan(plan: TargetPlan): Promise<string | null> {
306
+ if (!planWrites(plan)) {
307
+ return null;
308
+ }
309
+ const writePath = plan.target.realFile;
310
+ const bytes = (plan.bom ? UTF8_BOM : "") + plan.newContent;
311
+
312
+ if (!plan.fileExists) {
313
+ try {
314
+ await Bun.write(writePath, bytes);
315
+ } catch (err) {
316
+ throw new CliError(
317
+ "RUNTIME",
318
+ `Write failed for ${writePath}: ${describe(err)}`
319
+ );
320
+ }
321
+ return null;
322
+ }
323
+
324
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
325
+ const backupPath = `${writePath}.gno-agents.bak.${timestamp}`;
326
+ const tempPath = `${writePath}.gno-agents.tmp`;
327
+ try {
328
+ // Keep the source mode on both copies: a 0600 instruction file must not
329
+ // gain a world-readable backup or replacement.
330
+ const mode = (await stat(writePath)).mode & 0o777;
331
+ await Bun.write(backupPath, Bun.file(writePath));
332
+ await chmod(backupPath, mode);
333
+ await Bun.write(tempPath, bytes);
334
+ await chmod(tempPath, mode);
335
+ await rename(tempPath, writePath);
336
+ } catch (err) {
337
+ // Best-effort: never accumulate temp files across repeated failures.
338
+ await unlink(tempPath).catch(() => {});
339
+ throw new CliError(
340
+ "RUNTIME",
341
+ `Write failed for ${writePath}: ${describe(err)}; the live file is unchanged`
342
+ );
343
+ }
344
+ return backupPath;
345
+ }
346
+
347
+ // ─────────────────────────────────────────────────────────────────────────────
348
+ // Unified Diff (dry-run)
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Minimal unified diff. Block install/update/remove is a single contiguous
353
+ * change, so common prefix/suffix with one hunk is exact.
354
+ */
355
+ export function unifiedDiff(
356
+ oldContent: string,
357
+ newContent: string,
358
+ path: string
359
+ ): string {
360
+ if (oldContent === newContent) {
361
+ return "";
362
+ }
363
+
364
+ const oldLines = oldContent.split("\n");
365
+ const newLines = newContent.split("\n");
366
+
367
+ let prefix = 0;
368
+ while (
369
+ prefix < oldLines.length &&
370
+ prefix < newLines.length &&
371
+ oldLines[prefix] === newLines[prefix]
372
+ ) {
373
+ prefix += 1;
374
+ }
375
+
376
+ let suffix = 0;
377
+ while (
378
+ suffix < oldLines.length - prefix &&
379
+ suffix < newLines.length - prefix &&
380
+ oldLines[oldLines.length - 1 - suffix] ===
381
+ newLines[newLines.length - 1 - suffix]
382
+ ) {
383
+ suffix += 1;
384
+ }
385
+
386
+ const oldChanged = oldLines.slice(prefix, oldLines.length - suffix);
387
+ const newChanged = newLines.slice(prefix, newLines.length - suffix);
388
+
389
+ const CONTEXT = 3;
390
+ const ctxBefore = oldLines.slice(Math.max(0, prefix - CONTEXT), prefix);
391
+ const ctxAfterStart = oldLines.length - suffix;
392
+ const ctxAfter = oldLines.slice(ctxAfterStart, ctxAfterStart + CONTEXT);
393
+
394
+ const oldStart = Math.max(1, prefix - CONTEXT + 1);
395
+ const newStart = oldStart;
396
+ const oldCount = ctxBefore.length + oldChanged.length + ctxAfter.length;
397
+ const newCount = ctxBefore.length + newChanged.length + ctxAfter.length;
398
+
399
+ const lines: string[] = [
400
+ `--- ${path}`,
401
+ `+++ ${path}`,
402
+ `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
403
+ ];
404
+ for (const line of ctxBefore) {
405
+ lines.push(` ${line}`);
406
+ }
407
+ for (const line of oldChanged) {
408
+ lines.push(`-${line}`);
409
+ }
410
+ for (const line of newChanged) {
411
+ lines.push(`+${line}`);
412
+ }
413
+ for (const line of ctxAfter) {
414
+ lines.push(` ${line}`);
415
+ }
416
+ return lines.join("\n");
417
+ }