@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,335 @@
1
+ /**
2
+ * Daemon-only scheduled findings pass: read-only audit -> findings records.
3
+ *
4
+ * Report-only by construction: the audit never repairs, the writer only
5
+ * touches records it owns under the findings collection root, and every
6
+ * attempt persists its outcome so a starved or failing scheduler is visible
7
+ * through `gno daemon --status` and `gno doctor` without debug logs.
8
+ *
9
+ * The audit runs without the shared write lease; only the record write takes
10
+ * it, so a long audit never blocks capture or CLI writers.
11
+ */
12
+
13
+ import type { Config } from "../config/types";
14
+ import type { AuditRunResult } from "../core/audit";
15
+ import type {
16
+ FindingsRunCounts,
17
+ FindingsRunOutcome,
18
+ FindingsRunStateRecord,
19
+ FindingsSchedule,
20
+ } from "../core/findings-run-state";
21
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
22
+
23
+ import { AUDIT_CATEGORIES } from "../core/audit";
24
+ import { runWorkspaceAudit } from "../core/audit-workspace";
25
+ import { applyFindingsRecords } from "../core/findings-records";
26
+ import {
27
+ createPendingFindingsRunState,
28
+ EMPTY_FINDINGS_COUNTS,
29
+ writeFindingsRunState,
30
+ } from "../core/findings-run-state";
31
+ import { acquireCliWriteLease } from "../core/write-lease";
32
+
33
+ /** Audit report cap; matches the audit schema ceiling. */
34
+ const FINDINGS_AUDIT_MAX_FINDINGS = 1000;
35
+ const LEASE_HOLDER_COMMAND = "gno daemon (findings pass)";
36
+ const CONTROL_CHARS = /\p{Cc}/gu;
37
+
38
+ /** Error text lands in a JSON state file and status lines: keep it printable and bounded. */
39
+ const boundErrorText = (text: string): string =>
40
+ text.replace(CONTROL_CHARS, "").slice(0, 512);
41
+
42
+ export interface FindingsPassResult {
43
+ outcome: FindingsRunOutcome;
44
+ counts: FindingsRunCounts;
45
+ durationMs: number;
46
+ error: string | null;
47
+ /** The state record persisted for this attempt. */
48
+ record: FindingsRunStateRecord;
49
+ }
50
+
51
+ type FindingsPassAttempt = Omit<FindingsPassResult, "record">;
52
+
53
+ export interface FindingsPassDeps {
54
+ store: SqliteAdapter;
55
+ getConfig: () => Config;
56
+ schedule: FindingsSchedule;
57
+ dbPath: string;
58
+ indexName: string;
59
+ statePath: string;
60
+ now?: () => Date;
61
+ /** Overridable for tests; defaults to the shared `.mcp-write.lock` lease. */
62
+ acquireLease?: (dbPath: string) => Promise<{
63
+ ok: boolean;
64
+ release?: () => Promise<void>;
65
+ holder?: string | null;
66
+ }>;
67
+ runAudit?: typeof runWorkspaceAudit;
68
+ /** Overridable for tests; defaults to the atomic state-file writer. */
69
+ writeState?: typeof writeFindingsRunState;
70
+ }
71
+
72
+ const defaultAcquireLease: NonNullable<
73
+ FindingsPassDeps["acquireLease"]
74
+ > = async (dbPath) => {
75
+ const lease = await acquireCliWriteLease({
76
+ dbPath,
77
+ waitMs: 0,
78
+ noWait: true,
79
+ command: LEASE_HOLDER_COMMAND,
80
+ });
81
+ return lease.ok
82
+ ? { ok: true, release: lease.release }
83
+ : { ok: false, holder: lease.holder };
84
+ };
85
+
86
+ const settledRuleIds = (result: AuditRunResult): Set<string> => {
87
+ if (!result.ok) return new Set();
88
+ return new Set(
89
+ result.report.rules
90
+ .filter((rule) => rule.status === "pass" || rule.status === "fail")
91
+ .map((rule) => rule.ruleId)
92
+ );
93
+ };
94
+
95
+ /**
96
+ * One pass. Never throws: every failure lands in the persisted state, and a
97
+ * failure to persist lands as `failed` in the returned record instead.
98
+ */
99
+ export async function runFindingsPass(
100
+ deps: FindingsPassDeps,
101
+ previous: FindingsRunStateRecord,
102
+ signal?: AbortSignal
103
+ ): Promise<FindingsPassResult> {
104
+ const now = deps.now ?? (() => new Date());
105
+ const startedAt = now();
106
+ const persist = async (
107
+ result: FindingsPassAttempt
108
+ ): Promise<FindingsPassResult> => {
109
+ const finishedAt = now();
110
+ const record: FindingsRunStateRecord = {
111
+ ...previous,
112
+ collection: deps.schedule.collection.name,
113
+ cadence: deps.schedule.cadence,
114
+ lastOutcome: result.outcome,
115
+ lastRunAt: startedAt.toISOString(),
116
+ lastSuccessAt:
117
+ result.outcome === "success"
118
+ ? finishedAt.toISOString()
119
+ : previous.lastSuccessAt,
120
+ nextDueAt: new Date(
121
+ finishedAt.getTime() + deps.schedule.cadenceMs
122
+ ).toISOString(),
123
+ durationMs: result.durationMs,
124
+ counts: result.outcome === "success" ? result.counts : previous.counts,
125
+ error: result.error,
126
+ };
127
+ try {
128
+ await (deps.writeState ?? writeFindingsRunState)(deps.statePath, record);
129
+ } catch (error) {
130
+ // An unwritable state file must not kill the daemon loop, but it must
131
+ // not vanish either: the in-memory record (next pass's `previous`, the
132
+ // daemon log via onResult) carries the failure until a write lands.
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ const failed: FindingsRunStateRecord = {
135
+ ...record,
136
+ lastOutcome: "failed",
137
+ error: boundErrorText(
138
+ `state write failed: ${message}${record.error ? ` (after: ${record.error})` : ""}`
139
+ ),
140
+ };
141
+ return {
142
+ outcome: "failed",
143
+ counts: failed.counts ?? result.counts,
144
+ durationMs: result.durationMs,
145
+ error: failed.error,
146
+ record: failed,
147
+ };
148
+ }
149
+ return { ...result, record };
150
+ };
151
+ const fail = (error: string): Promise<FindingsPassResult> =>
152
+ persist({
153
+ outcome: "failed",
154
+ counts: previous.counts ?? EMPTY_FINDINGS_COUNTS,
155
+ durationMs: now().getTime() - startedAt.getTime(),
156
+ error: boundErrorText(error),
157
+ });
158
+
159
+ // The audit is read-only: run it without the write lease so MCP/REST
160
+ // capture and CLI writers are never blocked behind a long audit. Only the
161
+ // findings-record write below needs the lease.
162
+ let audit: AuditRunResult;
163
+ let allowResolve: boolean;
164
+ try {
165
+ const config = deps.getConfig();
166
+ const findingsCollection = deps.schedule.collection.name;
167
+ const audited = config.collections
168
+ .map((collection) => collection.name)
169
+ .filter((name) => name !== findingsCollection);
170
+ if (audited.length === 0) {
171
+ return persist({
172
+ outcome: "success",
173
+ counts: { ...EMPTY_FINDINGS_COUNTS },
174
+ durationMs: now().getTime() - startedAt.getTime(),
175
+ error: null,
176
+ });
177
+ }
178
+ audit = await (deps.runAudit ?? runWorkspaceAudit)({
179
+ store: deps.store,
180
+ config,
181
+ collections: config.collections,
182
+ indexName: deps.indexName,
183
+ categories: [...AUDIT_CATEGORIES],
184
+ collectionFilters: audited,
185
+ maxFindings: FINDINGS_AUDIT_MAX_FINDINGS,
186
+ signal,
187
+ now: startedAt,
188
+ });
189
+ if (!audit.ok) return fail(`audit failed: ${audit.error}`);
190
+ if (audit.report.status === "failed") {
191
+ return fail("audit reported status failed");
192
+ }
193
+ allowResolve =
194
+ audit.report.status === "complete" &&
195
+ !audit.report.counts.findings.truncated;
196
+ } catch (error) {
197
+ return fail(error instanceof Error ? error.message : String(error));
198
+ }
199
+
200
+ // Write phase: take the shared lease only for as long as the records are
201
+ // being applied. A busy lease at this point still lands as skipped_lease.
202
+ const lease = await (deps.acquireLease ?? defaultAcquireLease)(deps.dbPath);
203
+ if (!lease.ok) {
204
+ return persist({
205
+ outcome: "skipped_lease",
206
+ counts: previous.counts ?? EMPTY_FINDINGS_COUNTS,
207
+ durationMs: now().getTime() - startedAt.getTime(),
208
+ error: lease.holder
209
+ ? boundErrorText(`lease held by ${lease.holder}`)
210
+ : "lease held",
211
+ });
212
+ }
213
+ try {
214
+ const applied = await applyFindingsRecords({
215
+ root: deps.schedule.collection.path,
216
+ findings: audit.report.findings,
217
+ settledRuleIds: settledRuleIds(audit),
218
+ allowResolve,
219
+ now: startedAt,
220
+ });
221
+ return persist({
222
+ outcome: "success",
223
+ counts: {
224
+ findings: audit.report.counts.findings.total,
225
+ written: applied.written,
226
+ reopened: applied.reopened,
227
+ resolved: applied.resolved,
228
+ deleted: applied.deleted,
229
+ open: applied.open,
230
+ },
231
+ durationMs: now().getTime() - startedAt.getTime(),
232
+ error: null,
233
+ });
234
+ } catch (error) {
235
+ return fail(error instanceof Error ? error.message : String(error));
236
+ } finally {
237
+ await lease.release?.().catch(() => undefined);
238
+ }
239
+ }
240
+
241
+ export interface FindingsSchedulerOptions {
242
+ deps: FindingsPassDeps;
243
+ startBackgroundWork: (
244
+ operation: (signal: AbortSignal) => Promise<void>
245
+ ) => boolean;
246
+ /** Called after every attempt; the daemon logs failures only. */
247
+ onResult?: (result: FindingsPassResult) => void;
248
+ }
249
+
250
+ /**
251
+ * Fixed-cadence timer over the daemon's background-work tracker. One pass at
252
+ * a time; a pass still running when the next tick fires is simply skipped
253
+ * (the next tick is armed after the pass completes), so cadence is a floor.
254
+ */
255
+ export class FindingsScheduler {
256
+ readonly #options: FindingsSchedulerOptions;
257
+ #state: FindingsRunStateRecord;
258
+ #timer: ReturnType<typeof setTimeout> | null = null;
259
+ #running: Promise<FindingsPassResult> | null = null;
260
+ #disposed = false;
261
+
262
+ constructor(options: FindingsSchedulerOptions) {
263
+ this.#options = options;
264
+ this.#state = createPendingFindingsRunState(
265
+ options.deps.schedule,
266
+ (options.deps.now ?? (() => new Date()))()
267
+ );
268
+ }
269
+
270
+ get state(): FindingsRunStateRecord {
271
+ return this.#state;
272
+ }
273
+
274
+ /** Persist the pending state and arm the first tick. */
275
+ async start(): Promise<void> {
276
+ if (this.#disposed) return;
277
+ const { deps } = this.#options;
278
+ try {
279
+ await (deps.writeState ?? writeFindingsRunState)(
280
+ deps.statePath,
281
+ this.#state
282
+ );
283
+ } catch (error) {
284
+ // An unwritable state file must not stop the loop: keep the pending
285
+ // record in memory with the write error attached and still arm the
286
+ // first tick; the next successful pass write carries a fresh record.
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ this.#state = {
289
+ ...this.#state,
290
+ error: `state write failed: ${message}`,
291
+ };
292
+ }
293
+ this.#arm();
294
+ }
295
+
296
+ /** Run a pass now (tests, live verification). Coalesces with an in-flight pass. */
297
+ triggerNow(signal?: AbortSignal): Promise<FindingsPassResult> {
298
+ if (this.#running) return this.#running;
299
+ const run = runFindingsPass(this.#options.deps, this.#state, signal)
300
+ .then((result) => {
301
+ this.#state = result.record;
302
+ this.#options.onResult?.(result);
303
+ return result;
304
+ })
305
+ .finally(() => {
306
+ this.#running = null;
307
+ this.#arm();
308
+ });
309
+ this.#running = run;
310
+ return run;
311
+ }
312
+
313
+ dispose(): void {
314
+ this.#disposed = true;
315
+ if (this.#timer) clearTimeout(this.#timer);
316
+ this.#timer = null;
317
+ }
318
+
319
+ #arm(): void {
320
+ if (this.#disposed) return;
321
+ if (this.#timer) clearTimeout(this.#timer);
322
+ this.#timer = setTimeout(
323
+ () => {
324
+ this.#timer = null;
325
+ if (this.#disposed || this.#running) return;
326
+ const started = this.#options.startBackgroundWork(async (signal) => {
327
+ await this.triggerNow(signal);
328
+ });
329
+ if (!started) this.#arm();
330
+ },
331
+ this.#options.deps.schedule.cadenceMs
332
+ );
333
+ this.#timer.unref?.();
334
+ }
335
+ }
@@ -35,6 +35,11 @@ import { SavedCapsuleReverificationScheduler } from "../core/capsule-reverificat
35
35
  import { collectionEgressPolicyEpoch } from "../core/collection-egress-policy-service";
36
36
  import { authorizeCurrentEgress } from "../core/egress-authorization";
37
37
  import { acquireWriteLock } from "../core/file-lock";
38
+ import {
39
+ deleteFindingsRunState,
40
+ findingsRunStatePath,
41
+ resolveFindingsSchedule,
42
+ } from "../core/findings-run-state";
38
43
  import { JobManager } from "../core/job-manager";
39
44
  import { recordContentMutation } from "../core/mutation-generations";
40
45
  import { defaultSyncService, withContentTypeRules } from "../ingestion";
@@ -50,6 +55,7 @@ import {
50
55
  type ServerContext,
51
56
  } from "./context";
52
57
  import { createEmbedScheduler } from "./embed-scheduler";
58
+ import { FindingsScheduler, type FindingsPassResult } from "./findings-pass";
53
59
  import { AdmissionController, ReaderGate } from "./resident-admission";
54
60
  import { ResidentBackgroundWork } from "./resident-background-work";
55
61
  import { buildResidentStatusSnapshot } from "./resident-status";
@@ -68,6 +74,8 @@ export interface ResidentRuntimeOptions {
68
74
  offline?: boolean;
69
75
  eventBus?: DocumentEventBus | null;
70
76
  watchCallbacks?: CollectionWatchCallbacks;
77
+ /** Daemon mode only: observes every scheduled findings-pass attempt. */
78
+ onFindingsResult?: (result: FindingsPassResult) => void;
71
79
  readerLimit?: number;
72
80
  readerQueueLimit?: number;
73
81
  shutdownDeadlineMs?: number;
@@ -100,6 +108,8 @@ export interface ResidentRuntime {
100
108
  readonly readerGate: ReaderGate;
101
109
  readonly jobManager: JobManager;
102
110
  readonly capsuleReverificationScheduler: SavedCapsuleReverificationScheduler;
111
+ /** Present only when daemon mode runs with `findings.enabled`. */
112
+ readonly findingsScheduler: FindingsScheduler | null;
103
113
  readonly modelManager: ModelManager;
104
114
  readonly mcpContext: ToolContext;
105
115
  readonly generations: ResidentGeneration;
@@ -194,6 +204,15 @@ export async function startResidentRuntime(
194
204
  };
195
205
  }
196
206
 
207
+ const mode: ResidentMode = options.mode ?? "serve";
208
+ const findingsResolution =
209
+ mode === "daemon"
210
+ ? resolveFindingsSchedule(initialConfig)
211
+ : ({ ok: true, enabled: false } as const);
212
+ if (!findingsResolution.ok) {
213
+ return { success: false, error: findingsResolution.error };
214
+ }
215
+
197
216
  await (deps.ensureDirectories ?? ensureDirectories)();
198
217
  const dbPath = getIndexDbPath(options.index);
199
218
  const ownerLockPath = join(dirname(dbPath), ".resident-owner.lock");
@@ -334,6 +353,27 @@ export async function startResidentRuntime(
334
353
  const backgroundWork = new ResidentBackgroundWork(
335
354
  () => !disposed && admission.accepting
336
355
  );
356
+ const findingsStatePath = findingsRunStatePath(dbPath);
357
+ let findingsScheduler: FindingsScheduler | null = null;
358
+ if (findingsResolution.enabled) {
359
+ findingsScheduler = new FindingsScheduler({
360
+ deps: {
361
+ store,
362
+ getConfig: () => ctxHolder.config,
363
+ schedule: findingsResolution.schedule,
364
+ dbPath,
365
+ indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
366
+ statePath: findingsStatePath,
367
+ },
368
+ startBackgroundWork: (operation) => backgroundWork.start(operation),
369
+ onResult: options.onFindingsResult,
370
+ });
371
+ await findingsScheduler.start();
372
+ } else if (mode === "daemon") {
373
+ // Findings are off: a state file from an earlier configuration would
374
+ // otherwise keep reporting a schedule that no longer exists.
375
+ await deleteFindingsRunState(findingsStatePath);
376
+ }
337
377
  capsuleReverificationScheduler = new SavedCapsuleReverificationScheduler({
338
378
  deps: {
339
379
  store,
@@ -414,6 +454,7 @@ export async function startResidentRuntime(
414
454
  readerGate,
415
455
  jobManager,
416
456
  capsuleReverificationScheduler,
457
+ findingsScheduler,
417
458
  modelManager,
418
459
  mcpContext,
419
460
  generations,
@@ -540,6 +581,7 @@ export async function startResidentRuntime(
540
581
  options.shutdownAbortSettleMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS
541
582
  );
542
583
  if (deadlineReached) shutdownState = "deadline";
584
+ findingsScheduler?.dispose();
543
585
  await backgroundWork.cancelAndDrain();
544
586
  await capsuleReverificationScheduler.dispose();
545
587
  await jobManager.shutdown().catch(() => undefined);
@@ -157,8 +157,10 @@ import {
157
157
  import { exportPublishArtifact } from "../../publish/export-service";
158
158
  import { buildBrowseTree, normalizeBrowsePath } from "../browse-tree";
159
159
  import {
160
+ classifyResidentCaptureError,
160
161
  executeResidentCapturePlan,
161
162
  planResidentCapture,
163
+ type ResidentCaptureDependencies,
162
164
  } from "../capture-service";
163
165
  import { parseClosedJson } from "../closed-json";
164
166
  import { applyConfigChange, applyConfigChangeTyped } from "../config-sync";
@@ -3629,9 +3631,7 @@ export async function handleCreateCapture(
3629
3631
  ctxHolder: ContextHolder,
3630
3632
  store: SqliteAdapter,
3631
3633
  req: Request,
3632
- deps?: {
3633
- syncCollection?: typeof defaultSyncService.syncCollection;
3634
- }
3634
+ deps: Omit<ResidentCaptureDependencies, "mode" | "syncCollection"> = {}
3635
3635
  ): Promise<Response> {
3636
3636
  let body: CreateCaptureRequestBody;
3637
3637
  try {
@@ -3669,21 +3669,21 @@ export async function handleCreateCapture(
3669
3669
  if (!planned.ok) {
3670
3670
  return errorResponse(planned.code, planned.message, planned.status);
3671
3671
  }
3672
+ // Write + lexical sync complete under the shared write lease before the
3673
+ // response: 201 only once the capture is retrievable (fn-132 R1).
3672
3674
  try {
3673
- const result = await executeResidentCapturePlan(
3674
- ctxHolder,
3675
- store,
3676
- planned,
3677
- deps
3678
- );
3675
+ const result = await executeResidentCapturePlan(ctxHolder, store, planned, {
3676
+ ...deps,
3677
+ mode: "await-sync",
3678
+ });
3679
3679
  return jsonResponse(result.body, result.status);
3680
3680
  } catch (error) {
3681
+ const shape = classifyResidentCaptureError(error, planned);
3681
3682
  return errorResponse(
3682
- "RUNTIME",
3683
- `Failed to capture document: ${
3684
- error instanceof Error ? error.message : String(error)
3685
- }`,
3686
- 500
3683
+ shape.code,
3684
+ shape.message,
3685
+ shape.status,
3686
+ shape.details
3687
3687
  );
3688
3688
  }
3689
3689
  }
@@ -1 +0,0 @@
1
- c0ebec1aed09df105e0b8bacec38818b39b4173c25a0bdcaa6bd3a4774a2e548 gno-browser-clipper-v1.43.0.zip