@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
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Capture lexical sync under the write lease (shared by MCP, REST, and CLI
3
+ * adapters). Split out of `capture.ts`; every symbol is re-exported there so
4
+ * existing import paths keep working.
5
+ *
6
+ * @module src/core/capture-sync
7
+ */
8
+
9
+ import type { Collection, Config } from "../config/types";
10
+ import type { StorePort } from "../store/types";
11
+ import type { CaptureIndexStatus } from "./capture";
12
+
13
+ import {
14
+ type CollectionSyncResult,
15
+ defaultSyncService,
16
+ withContentTypeRules,
17
+ } from "../ingestion";
18
+
19
+ export const CAPTURE_SYNC_FAILED_CODE = "CAPTURE_SYNC_FAILED";
20
+
21
+ /**
22
+ * The capture landed on disk but lexical sync did not make it retrievable.
23
+ * Carries the write receipt half (`absPath`, `relPath`) so callers can report
24
+ * the write separately from the failed sync.
25
+ */
26
+ export class CaptureSyncError extends Error {
27
+ readonly code = CAPTURE_SYNC_FAILED_CODE;
28
+ readonly absPath: string;
29
+ readonly relPath: string;
30
+
31
+ constructor(input: { absPath: string; relPath: string; cause: string }) {
32
+ super(
33
+ `Capture written to ${input.absPath} but lexical sync failed: ${input.cause}. Run gno update to retry indexing.`
34
+ );
35
+ this.name = "CaptureSyncError";
36
+ this.absPath = input.absPath;
37
+ this.relPath = input.relPath;
38
+ }
39
+ }
40
+
41
+ export type CaptureSyncPaths = typeof defaultSyncService.syncPaths;
42
+
43
+ export interface SyncCapturedFileInput {
44
+ collection: Collection;
45
+ store: StorePort;
46
+ relPath: string;
47
+ absPath: string;
48
+ config?: Pick<Config, "contentTypes">;
49
+ syncPaths?: CaptureSyncPaths;
50
+ }
51
+
52
+ export interface SyncCapturedFileResult {
53
+ docid: string;
54
+ documentId: number;
55
+ /** Null when the file was already indexed and no sync ran. */
56
+ result: CollectionSyncResult | null;
57
+ sync: CaptureIndexStatus;
58
+ }
59
+
60
+ /**
61
+ * Sync one written capture into the lexical index and prove it is
62
+ * retrievable. Callers hold the shared write lease. Throws
63
+ * `CaptureSyncError` when the sync reports an error or the document is still
64
+ * missing afterwards — capture success is retrievability, never a bare write.
65
+ */
66
+ export async function syncCapturedFile(
67
+ input: SyncCapturedFileInput
68
+ ): Promise<SyncCapturedFileResult> {
69
+ const syncPaths =
70
+ input.syncPaths ?? defaultSyncService.syncPaths.bind(defaultSyncService);
71
+ const result = await syncPaths(
72
+ input.collection,
73
+ input.store,
74
+ [input.relPath],
75
+ withContentTypeRules({ runUpdateCmd: false, gitPull: false }, input.config)
76
+ );
77
+ const fileResult = result.files?.[0];
78
+ const fail = (cause: string): never => {
79
+ throw new CaptureSyncError({
80
+ absPath: input.absPath,
81
+ relPath: input.relPath,
82
+ cause,
83
+ });
84
+ };
85
+ if (!fileResult) {
86
+ return fail("sync returned no result for the written file");
87
+ }
88
+ if (fileResult.status === "error") {
89
+ return fail(
90
+ `${fileResult.errorCode ?? "ERROR"} - ${fileResult.errorMessage ?? "Unknown error"}`
91
+ );
92
+ }
93
+ const doc = await input.store.getDocument(
94
+ input.collection.name,
95
+ input.relPath
96
+ );
97
+ if (!doc.ok) {
98
+ return fail(doc.error.message);
99
+ }
100
+ if (!doc.value) {
101
+ return fail("document is not retrievable after sync");
102
+ }
103
+ return {
104
+ docid: fileResult.docid ?? doc.value.docid,
105
+ documentId: doc.value.id,
106
+ result,
107
+ sync: { status: "completed" },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * `open_existing` half of the contract: an indexed file is returned as-is; a
113
+ * disk-only file is synced first so opening it is also a retrievable success.
114
+ */
115
+ export async function ensureCapturedFileIndexed(
116
+ input: SyncCapturedFileInput
117
+ ): Promise<SyncCapturedFileResult> {
118
+ const doc = await input.store.getDocument(
119
+ input.collection.name,
120
+ input.relPath
121
+ );
122
+ if (!doc.ok) {
123
+ throw new Error(doc.error.message);
124
+ }
125
+ if (doc.value) {
126
+ return {
127
+ docid: doc.value.docid,
128
+ documentId: doc.value.id,
129
+ result: null,
130
+ sync: {
131
+ status: "completed",
132
+ reason: "Existing capture already indexed.",
133
+ },
134
+ };
135
+ }
136
+ const synced = await syncCapturedFile(input);
137
+ return {
138
+ ...synced,
139
+ sync: {
140
+ status: "completed",
141
+ reason: "Existing capture was not indexed yet; synced before returning.",
142
+ },
143
+ };
144
+ }
@@ -994,3 +994,13 @@ export function buildLegacyEditableCopySource(input: {
994
994
  export function serializeCaptureReceipt(receipt: CaptureReceipt): string {
995
995
  return JSON.stringify(receipt, null, 2);
996
996
  }
997
+
998
+ export {
999
+ CAPTURE_SYNC_FAILED_CODE,
1000
+ type CaptureSyncPaths,
1001
+ CaptureSyncError,
1002
+ ensureCapturedFileIndexed,
1003
+ type SyncCapturedFileInput,
1004
+ type SyncCapturedFileResult,
1005
+ syncCapturedFile,
1006
+ } from "./capture-sync";
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Findings records: deterministic Markdown records for audit findings.
3
+ *
4
+ * One file per finding identity under the configured findings collection
5
+ * root. Identity is the audit finding id (hash of rule + subject/location +
6
+ * evidence fingerprint), so repeated passes upsert instead of duplicating.
7
+ * Records are ordinary Markdown sources: retrieval, egress, and deletion
8
+ * follow the normal collection rules. Nothing here touches other paths.
9
+ *
10
+ * @module src/core/findings-records
11
+ */
12
+
13
+ // node:fs/promises: realpath/stat/rename/unlink are structure ops without Bun equivalents.
14
+ import { realpath, rename, stat, unlink } from "node:fs/promises";
15
+ // node:path: join/basename have no Bun path utilities.
16
+ import { basename, join } from "node:path";
17
+
18
+ import type { AuditFinding } from "./audit-contract";
19
+
20
+ import { parseFrontmatter } from "../ingestion/frontmatter";
21
+ import { removePathRequired } from "./file-ops";
22
+
23
+ /** Resolved records older than this are deleted on the next pass. */
24
+ export const FINDINGS_RESOLVED_RETENTION_DAYS = 30;
25
+ /** Hard ceiling on records per collection; oldest resolved go first. */
26
+ export const FINDINGS_MAX_RECORDS = 2000;
27
+
28
+ const RECORD_PREFIX = "finding-";
29
+ const RECORD_ID_CHARS = 24;
30
+ const FINDING_ID_PATTERN = /^[a-f0-9]{64}$/;
31
+ const RECORD_GLOB = new Bun.Glob(`${RECORD_PREFIX}*.md`);
32
+ const RETENTION_MS = FINDINGS_RESOLVED_RETENTION_DAYS * 86_400_000;
33
+
34
+ export type FindingsRecordStatus = "open" | "resolved";
35
+
36
+ export interface FindingsRecordHeader {
37
+ path: string;
38
+ findingId: string;
39
+ ruleId: string;
40
+ status: FindingsRecordStatus;
41
+ firstSeenAt: string;
42
+ resolvedAt: string | null;
43
+ }
44
+
45
+ export interface ApplyFindingsRecordsInput {
46
+ /** Absolute findings collection root. Records are written directly beneath it. */
47
+ root: string;
48
+ findings: readonly AuditFinding[];
49
+ /** Rules that completed (pass or fail) this pass; only their records may resolve. */
50
+ settledRuleIds: ReadonlySet<string>;
51
+ /** False when the report is truncated or partial: absence proves nothing. */
52
+ allowResolve: boolean;
53
+ now: Date;
54
+ /**
55
+ * Removes one record file; must treat ENOENT as already deleted. Defaults to
56
+ * `removePathRequired`. Injectable so the listing-to-deletion race is testable
57
+ * without patching module bindings.
58
+ */
59
+ removePath?: (path: string) => Promise<void>;
60
+ }
61
+
62
+ export interface ApplyFindingsRecordsResult {
63
+ written: number;
64
+ reopened: number;
65
+ resolved: number;
66
+ deleted: number;
67
+ unchanged: number;
68
+ open: number;
69
+ }
70
+
71
+ export function findingsRecordFilename(findingId: string): string {
72
+ return `${RECORD_PREFIX}${findingId.slice(0, RECORD_ID_CHARS)}.md`;
73
+ }
74
+
75
+ const yamlScalar = (value: string | null): string =>
76
+ value === null ? "null" : JSON.stringify(value);
77
+
78
+ const yamlList = (values: readonly string[]): string =>
79
+ `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
80
+
81
+ export interface RenderFindingsRecordInput {
82
+ finding: AuditFinding;
83
+ status: FindingsRecordStatus;
84
+ firstSeenAt: string;
85
+ resolvedAt: string | null;
86
+ }
87
+
88
+ /** Deterministic record body: same inputs, same bytes. */
89
+ export function renderFindingsRecord(input: RenderFindingsRecordInput): string {
90
+ const { finding } = input;
91
+ const lines: string[] = [
92
+ "---",
93
+ `type: finding`,
94
+ `gnoFinding: true`,
95
+ `findingId: ${yamlScalar(finding.id)}`,
96
+ `rule: ${yamlScalar(finding.ruleId)}`,
97
+ `category: ${yamlScalar(finding.category)}`,
98
+ `severity: ${yamlScalar(finding.severity)}`,
99
+ `status: ${input.status}`,
100
+ `subject: ${yamlScalar(finding.subject)}`,
101
+ `location: ${yamlScalar(finding.location)}`,
102
+ `firstSeenAt: ${yamlScalar(input.firstSeenAt)}`,
103
+ `resolvedAt: ${yamlScalar(input.resolvedAt)}`,
104
+ `evidenceFingerprint: ${yamlScalar(finding.evidenceFingerprint)}`,
105
+ `source: gno-audit`,
106
+ `tags: ${yamlList(["finding", "audit", finding.category, finding.severity])}`,
107
+ "---",
108
+ "",
109
+ `# ${finding.ruleId}: ${finding.subject}`,
110
+ "",
111
+ finding.message,
112
+ "",
113
+ `- Check: ${finding.ruleId} (${finding.category}, ${finding.severity})`,
114
+ `- Subject: ${finding.subject}`,
115
+ `- Location: ${finding.location ?? "(none)"}`,
116
+ `- Status: ${input.status}`,
117
+ `- First seen: ${input.firstSeenAt}`,
118
+ `- Resolved: ${input.resolvedAt ?? "(open)"}`,
119
+ ];
120
+ if (finding.evidence.length > 0) {
121
+ lines.push("", "## Evidence", "");
122
+ for (const evidence of finding.evidence) {
123
+ const pointer = evidence.uri ?? evidence.path;
124
+ const detail = evidence.detail ? ` — ${evidence.detail}` : "";
125
+ lines.push(
126
+ `- ${evidence.kind}: ${evidence.summary}${pointer ? ` (${pointer})` : ""}${detail}`
127
+ );
128
+ }
129
+ }
130
+ if (finding.guidance.length > 0) {
131
+ lines.push("", "## Guidance", "");
132
+ for (const guidance of finding.guidance) lines.push(`- ${guidance}`);
133
+ }
134
+ lines.push(
135
+ "",
136
+ "Written by the GNO daemon findings pass. Report-only: fix the subject source; this record resolves on the next pass.",
137
+ ""
138
+ );
139
+ return lines.join("\n");
140
+ }
141
+
142
+ const metadataString = (
143
+ metadata: Record<string, unknown>,
144
+ key: string
145
+ ): string | null => {
146
+ const value = metadata[key];
147
+ return typeof value === "string" && value.length > 0 ? value : null;
148
+ };
149
+
150
+ /** Parse one candidate file; null unless it is a record this writer owns. */
151
+ export function parseFindingsRecordHeader(
152
+ path: string,
153
+ content: string
154
+ ): FindingsRecordHeader | null {
155
+ const { metadata } = parseFrontmatter(content);
156
+ const marker = metadata.gnoFinding;
157
+ if (marker !== true && marker !== "true") return null;
158
+ const findingId = metadataString(metadata, "findingId");
159
+ if (!findingId || !FINDING_ID_PATTERN.test(findingId)) return null;
160
+ if (basename(path) !== findingsRecordFilename(findingId)) return null;
161
+ const status = metadataString(metadata, "status");
162
+ if (status !== "open" && status !== "resolved") return null;
163
+ const ruleId = metadataString(metadata, "rule");
164
+ const firstSeenAt = metadataString(metadata, "firstSeenAt");
165
+ if (!ruleId || !firstSeenAt) return null;
166
+ const resolvedAt = metadataString(metadata, "resolvedAt");
167
+ return {
168
+ path,
169
+ findingId,
170
+ ruleId,
171
+ status,
172
+ firstSeenAt,
173
+ resolvedAt: resolvedAt === "null" ? null : resolvedAt,
174
+ };
175
+ }
176
+
177
+ export async function listFindingsRecords(
178
+ root: string
179
+ ): Promise<FindingsRecordHeader[]> {
180
+ const headers: FindingsRecordHeader[] = [];
181
+ for await (const name of RECORD_GLOB.scan({
182
+ cwd: root,
183
+ onlyFiles: true,
184
+ dot: false,
185
+ })) {
186
+ if (name.includes("/") || name.includes("\\")) continue;
187
+ const path = join(root, name);
188
+ try {
189
+ const header = parseFindingsRecordHeader(
190
+ path,
191
+ await Bun.file(path).text()
192
+ );
193
+ if (header) headers.push(header);
194
+ } catch {
195
+ // Unreadable candidate: not ours to touch.
196
+ }
197
+ }
198
+ return headers.sort((left, right) =>
199
+ left.findingId < right.findingId ? -1 : 1
200
+ );
201
+ }
202
+
203
+ async function writeRecordAtomically(
204
+ path: string,
205
+ content: string
206
+ ): Promise<void> {
207
+ const temporaryPath = `${path}.tmp`;
208
+ await Bun.write(temporaryPath, content, { createPath: false, mode: 0o600 });
209
+ try {
210
+ await rename(temporaryPath, path);
211
+ } catch (error) {
212
+ await unlink(temporaryPath).catch(() => undefined);
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ async function resolveRecordRoot(root: string): Promise<string> {
218
+ const resolved = await realpath(root);
219
+ const info = await stat(resolved);
220
+ if (!info.isDirectory()) {
221
+ throw new Error(`findings collection root is not a directory: ${root}`);
222
+ }
223
+ return resolved;
224
+ }
225
+
226
+ /**
227
+ * Age of a resolved record. A missing or unparsable `resolvedAt` reads as
228
+ * "just now" (age 0): the record then ages out through the normal retention
229
+ * window instead of being deleted on the very next pass, and the retention
230
+ * comparator never sees NaN.
231
+ */
232
+ const resolvedAge = (header: FindingsRecordHeader, now: Date): number => {
233
+ const resolvedAt = header.resolvedAt ? Date.parse(header.resolvedAt) : NaN;
234
+ return Number.isFinite(resolvedAt)
235
+ ? Math.max(0, now.getTime() - resolvedAt)
236
+ : 0;
237
+ };
238
+
239
+ /**
240
+ * Upsert current findings, resolve vanished ones, apply bounded retention.
241
+ * Only files that parse as records this writer produced are ever rewritten
242
+ * or deleted; everything else in the root is left alone.
243
+ */
244
+ export async function applyFindingsRecords(
245
+ input: ApplyFindingsRecordsInput
246
+ ): Promise<ApplyFindingsRecordsResult> {
247
+ const root = await resolveRecordRoot(input.root);
248
+ const nowIso = input.now.toISOString();
249
+ const removePath = input.removePath ?? removePathRequired;
250
+ const existing = new Map(
251
+ (await listFindingsRecords(root)).map((header) => [
252
+ header.findingId,
253
+ header,
254
+ ])
255
+ );
256
+ const result: ApplyFindingsRecordsResult = {
257
+ written: 0,
258
+ reopened: 0,
259
+ resolved: 0,
260
+ deleted: 0,
261
+ unchanged: 0,
262
+ open: 0,
263
+ };
264
+ const currentIds = new Set<string>();
265
+
266
+ for (const finding of input.findings) {
267
+ if (!FINDING_ID_PATTERN.test(finding.id) || currentIds.has(finding.id)) {
268
+ continue;
269
+ }
270
+ currentIds.add(finding.id);
271
+ const header = existing.get(finding.id);
272
+ if (header?.status === "open") {
273
+ result.unchanged += 1;
274
+ continue;
275
+ }
276
+ const path = join(root, findingsRecordFilename(finding.id));
277
+ await writeRecordAtomically(
278
+ path,
279
+ renderFindingsRecord({
280
+ finding,
281
+ status: "open",
282
+ firstSeenAt: header?.firstSeenAt ?? nowIso,
283
+ resolvedAt: null,
284
+ })
285
+ );
286
+ if (header) result.reopened += 1;
287
+ else result.written += 1;
288
+ existing.set(finding.id, {
289
+ path,
290
+ findingId: finding.id,
291
+ ruleId: finding.ruleId,
292
+ status: "open",
293
+ firstSeenAt: header?.firstSeenAt ?? nowIso,
294
+ resolvedAt: null,
295
+ });
296
+ }
297
+
298
+ for (const header of existing.values()) {
299
+ if (header.status !== "open" || currentIds.has(header.findingId)) continue;
300
+ if (!input.allowResolve || !input.settledRuleIds.has(header.ruleId)) {
301
+ continue;
302
+ }
303
+ const content = await Bun.file(header.path).text();
304
+ const resolvedContent = rewriteRecordStatus(content, "resolved", nowIso);
305
+ if (resolvedContent === null) continue;
306
+ await writeRecordAtomically(header.path, resolvedContent);
307
+ header.status = "resolved";
308
+ header.resolvedAt = nowIso;
309
+ result.resolved += 1;
310
+ }
311
+
312
+ const expired = [...existing.values()].filter(
313
+ (header) =>
314
+ header.status === "resolved" &&
315
+ !currentIds.has(header.findingId) &&
316
+ resolvedAge(header, input.now) > RETENTION_MS
317
+ );
318
+ // A record removed by hand between listing and retention is already gone:
319
+ // ENOENT counts as deleted instead of failing the whole pass.
320
+ for (const header of expired) {
321
+ await removePath(header.path);
322
+ existing.delete(header.findingId);
323
+ result.deleted += 1;
324
+ }
325
+ if (existing.size > FINDINGS_MAX_RECORDS) {
326
+ const surplus = [...existing.values()]
327
+ .filter((header) => header.status === "resolved")
328
+ .sort(
329
+ (left, right) =>
330
+ resolvedAge(right, input.now) - resolvedAge(left, input.now)
331
+ )
332
+ .slice(0, existing.size - FINDINGS_MAX_RECORDS);
333
+ for (const header of surplus) {
334
+ await removePath(header.path);
335
+ existing.delete(header.findingId);
336
+ result.deleted += 1;
337
+ }
338
+ }
339
+
340
+ for (const header of existing.values()) {
341
+ if (header.status === "open") result.open += 1;
342
+ }
343
+ return result;
344
+ }
345
+
346
+ /**
347
+ * Flip the status/resolvedAt frontmatter lines and the matching body lines
348
+ * without re-rendering (the original finding payload is not re-read).
349
+ */
350
+ export function rewriteRecordStatus(
351
+ content: string,
352
+ status: FindingsRecordStatus,
353
+ resolvedAt: string | null
354
+ ): string | null {
355
+ if (!content.startsWith("---\n")) return null;
356
+ const end = content.indexOf("\n---\n", 4);
357
+ if (end === -1) return null;
358
+ const frontmatter = content
359
+ .slice(4, end)
360
+ .split("\n")
361
+ .map((line) => {
362
+ if (line.startsWith("status: ")) return `status: ${status}`;
363
+ if (line.startsWith("resolvedAt: ")) {
364
+ return `resolvedAt: ${yamlScalar(resolvedAt)}`;
365
+ }
366
+ return line;
367
+ })
368
+ .join("\n");
369
+ const body = content
370
+ .slice(end + 5)
371
+ .split("\n")
372
+ .map((line) => {
373
+ if (line.startsWith("- Status: ")) return `- Status: ${status}`;
374
+ if (line.startsWith("- Resolved: ")) {
375
+ return `- Resolved: ${resolvedAt ?? "(open)"}`;
376
+ }
377
+ return line;
378
+ })
379
+ .join("\n");
380
+ return `---\n${frontmatter}\n---\n${body}`;
381
+ }