akm-cli 0.9.0-beta.11 → 0.9.0-beta.26

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 (88) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/health.html +25 -27
  16. package/dist/cli.js +2 -2
  17. package/dist/commands/agent/contribute-cli.js +16 -3
  18. package/dist/commands/feedback-cli.js +48 -44
  19. package/dist/commands/health/html-report.js +140 -16
  20. package/dist/commands/health.js +277 -1
  21. package/dist/commands/improve/calibration.js +161 -0
  22. package/dist/commands/improve/consolidate.js +595 -105
  23. package/dist/commands/improve/dedup.js +482 -0
  24. package/dist/commands/improve/distill.js +119 -64
  25. package/dist/commands/improve/encoding-salience.js +205 -0
  26. package/dist/commands/improve/extract-cli.js +115 -1
  27. package/dist/commands/improve/extract-prompt.js +32 -1
  28. package/dist/commands/improve/extract-watch.js +140 -0
  29. package/dist/commands/improve/extract.js +210 -30
  30. package/dist/commands/improve/feedback-valence.js +54 -0
  31. package/dist/commands/improve/homeostatic.js +467 -0
  32. package/dist/commands/improve/improve-auto-accept.js +80 -7
  33. package/dist/commands/improve/improve-profiles.js +8 -0
  34. package/dist/commands/improve/improve.js +991 -61
  35. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  36. package/dist/commands/improve/outcome-loop.js +256 -0
  37. package/dist/commands/improve/proactive-maintenance.js +9 -35
  38. package/dist/commands/improve/procedural.js +409 -0
  39. package/dist/commands/improve/recombine.js +488 -0
  40. package/dist/commands/improve/reflect.js +20 -1
  41. package/dist/commands/improve/related-sessions.js +120 -0
  42. package/dist/commands/improve/salience.js +386 -0
  43. package/dist/commands/improve/triage.js +95 -0
  44. package/dist/commands/lint/agent-linter.js +19 -24
  45. package/dist/commands/lint/base-linter.js +173 -60
  46. package/dist/commands/lint/command-linter.js +19 -24
  47. package/dist/commands/lint/env-key-rules.js +34 -1
  48. package/dist/commands/lint/index.js +30 -13
  49. package/dist/commands/lint/memory-linter.js +1 -1
  50. package/dist/commands/lint/registry.js +5 -2
  51. package/dist/commands/lint/task-linter.js +3 -3
  52. package/dist/commands/lint/workflow-linter.js +26 -1
  53. package/dist/commands/proposal/validators/proposals.js +4 -0
  54. package/dist/commands/read/curate.js +284 -86
  55. package/dist/commands/read/search-cli.js +7 -0
  56. package/dist/commands/read/search.js +1 -0
  57. package/dist/commands/sources/installed-stashes.js +5 -1
  58. package/dist/core/asset/frontmatter.js +166 -167
  59. package/dist/core/asset/markdown.js +8 -0
  60. package/dist/core/config/config-schema.js +211 -3
  61. package/dist/core/config/config.js +2 -2
  62. package/dist/core/logs-db.js +4 -3
  63. package/dist/core/state-db.js +555 -29
  64. package/dist/indexer/db/db.js +250 -27
  65. package/dist/indexer/db/graph-db.js +81 -86
  66. package/dist/indexer/graph/graph-boost.js +51 -41
  67. package/dist/indexer/passes/memory-inference.js +10 -3
  68. package/dist/indexer/passes/staleness-detect.js +2 -5
  69. package/dist/indexer/search/db-search.js +15 -4
  70. package/dist/indexer/search/ranking.js +4 -0
  71. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  72. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  73. package/dist/integrations/session-logs/index.js +16 -0
  74. package/dist/llm/embedder.js +27 -3
  75. package/dist/llm/embedders/local.js +66 -2
  76. package/dist/llm/graph-extract.js +2 -1
  77. package/dist/llm/memory-infer.js +4 -8
  78. package/dist/llm/metadata-enhance.js +9 -1
  79. package/dist/output/shapes/curate.js +14 -2
  80. package/dist/output/text/helpers.js +9 -0
  81. package/dist/runtime.js +25 -1
  82. package/dist/scripts/migrate-storage.js +1025 -567
  83. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +435 -269
  84. package/dist/storage/sqlite-pragmas.js +146 -0
  85. package/dist/workflows/db.js +3 -4
  86. package/dist/workflows/validate-summary.js +2 -7
  87. package/docs/data-and-telemetry.md +1 -0
  88. package/package.json +5 -4
@@ -0,0 +1,409 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * #615 — procedural-compilation pass.
6
+ *
7
+ * An OPT-IN post-loop improve stage (default disabled via
8
+ * `IMPROVE_PROCESS_DEFAULTS.procedural`). It reads assets that carry an
9
+ * `orderedActions` frontmatter list (captured by #619), detects RECURRING
10
+ * successful action sequences across sessions (the SAME normalized ordered step
11
+ * list appearing >= `minRecurrence` times with a non-failure `outcomeData`), and
12
+ * emits ONE normal `type: workflow` proposal per recurring sequence through the
13
+ * existing proposal queue + quality gate.
14
+ *
15
+ * The ordered step list — NOT the LLM — is the source of truth. The bounded LLM
16
+ * call only NAMES the workflow and per-step titles/instructions; the parser
17
+ * rejects any output whose step count / order drifts from the deterministic
18
+ * sequence, and the assembled workflow markdown is re-parsed locally before it
19
+ * is ever queued. A justified null (the LLM determines the sequence is not a
20
+ * coherent procedure) is an acceptable outcome and produces no proposal.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import proceduralSystemPrompt from "../../assets/prompts/procedural-system.md" with { type: "text" };
25
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
26
+ import { resolveStashDir } from "../../core/common.js";
27
+ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
28
+ import { appendEvent } from "../../core/events.js";
29
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
30
+ import { warn } from "../../core/warn.js";
31
+ import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
32
+ import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
33
+ import { chatCompletion } from "../../llm/client.js";
34
+ import { parseWorkflow } from "../../workflows/parser.js";
35
+ import { validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
36
+ import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
37
+ const PROCEDURAL_SYSTEM_PROMPT = proceduralSystemPrompt;
38
+ const DEFAULT_MIN_RECURRENCE = 3;
39
+ const DEFAULT_MAX_PROPOSALS_PER_RUN = 3;
40
+ /** Failure-signal heuristic: an outcome matching this is NOT counted as success. */
41
+ const FAILURE_SIGNAL = /\b(fail|failed|failure|error|errored|abort|aborted|rollback|reverted)\b/i;
42
+ // ── Normalization + recurrence model (deterministic, no LLM) ────────────────────
43
+ /** Normalize a single action string: trim, lowercase, collapse whitespace, strip trailing punctuation. */
44
+ function normalizeStep(s) {
45
+ return s
46
+ .trim()
47
+ .toLowerCase()
48
+ .replace(/\s+/g, " ")
49
+ .replace(/[.;,:]+$/, "")
50
+ .trim();
51
+ }
52
+ /**
53
+ * Normalize an ordered action list: each step token-normalized (case /
54
+ * whitespace / trailing-punctuation insensitive), with empties dropped. The
55
+ * result is ORDER-SENSITIVE — reordered sequences normalize distinctly.
56
+ */
57
+ export function normalizeSequence(actions) {
58
+ return actions.map(normalizeStep).filter((s) => s.length > 0);
59
+ }
60
+ /** A member counts toward recurrence only when its outcome is present, non-empty, and not a failure signal. */
61
+ function isSuccessfulOutcome(outcome) {
62
+ if (!outcome)
63
+ return false;
64
+ const trimmed = outcome.trim();
65
+ if (!trimmed)
66
+ return false;
67
+ return !FAILURE_SIGNAL.test(trimmed);
68
+ }
69
+ /** Recover the ordered-action sequence + outcome from an asset's frontmatter (NOT a DB column). */
70
+ function readOrderedSequence(entry) {
71
+ let data;
72
+ try {
73
+ const raw = fs.readFileSync(entry.filePath, "utf8");
74
+ data = parseFrontmatter(raw).data;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ const rawActions = data.orderedActions;
80
+ if (!Array.isArray(rawActions))
81
+ return null;
82
+ const actions = rawActions.filter((a) => typeof a === "string");
83
+ if (actions.length === 0)
84
+ return null;
85
+ const outcome = typeof data.outcomeData === "string" ? data.outcomeData : undefined;
86
+ const ref = `${entry.entry.type}:${entry.entry.name}`;
87
+ return { actions, ...(outcome !== undefined ? { outcome } : {}), ref, entryKey: entry.entryKey };
88
+ }
89
+ /**
90
+ * Group entries by their normalized ordered sequence. Only successful members
91
+ * (non-failure outcome) count toward recurrence. Keep groups whose successful
92
+ * member count >= `minRecurrence`, sort deterministically (member-count desc
93
+ * then groupKey asc), and cap to `maxProposalsPerRun`. Exported for unit tests.
94
+ */
95
+ export function buildSequenceClusters(entries, opts) {
96
+ const groups = new Map();
97
+ for (const entry of entries) {
98
+ const seq = readOrderedSequence(entry);
99
+ if (!seq)
100
+ continue;
101
+ const normalized = normalizeSequence(seq.actions);
102
+ if (normalized.length === 0)
103
+ continue;
104
+ if (!isSuccessfulOutcome(seq.outcome))
105
+ continue;
106
+ const groupKey = JSON.stringify(normalized);
107
+ const existing = groups.get(groupKey);
108
+ const member = { ref: seq.ref, entryKey: seq.entryKey, outcome: seq.outcome ?? "" };
109
+ if (existing) {
110
+ if (!existing.members.some((m) => m.entryKey === member.entryKey))
111
+ existing.members.push(member);
112
+ }
113
+ else {
114
+ groups.set(groupKey, { groupKey, normalized, members: [member] });
115
+ }
116
+ }
117
+ const clusters = [...groups.values()].filter((c) => c.members.length >= opts.minRecurrence);
118
+ clusters.sort((a, b) => b.members.length - a.members.length || a.groupKey.localeCompare(b.groupKey));
119
+ return clusters.slice(0, Math.max(0, opts.maxProposalsPerRun));
120
+ }
121
+ /**
122
+ * Stable workflow ref for a sequence cluster. The hash of the sorted member keys
123
+ * + the normalized sequence keeps the ref deterministic across runs, so
124
+ * re-detection maps to the same ref and the content-hash dedup in createProposal
125
+ * suppresses queue churn.
126
+ */
127
+ export function deriveProceduralWorkflowRef(cluster) {
128
+ const slug = cluster.normalized
129
+ .slice(0, 3)
130
+ .join("-")
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9-]+/g, "-")
133
+ .replace(/-+/g, "-")
134
+ .replace(/^-|-$/g, "");
135
+ const memberKey = cluster.members
136
+ .map((m) => m.entryKey)
137
+ .sort()
138
+ .join("|");
139
+ const hash = createHash("sha256")
140
+ .update(`${memberKey} ${JSON.stringify(cluster.normalized)}`, "utf8")
141
+ .digest("hex")
142
+ .slice(0, 8);
143
+ return `workflow:compiled/${slug || "sequence"}-${hash}`;
144
+ }
145
+ // ── Prompt + parse ──────────────────────────────────────────────────────────────
146
+ /** Assemble the per-sequence user prompt fed to the procedural LLM. */
147
+ export function buildProceduralPrompt(cluster) {
148
+ const lines = [
149
+ `A recurring successful action sequence observed across ${cluster.members.length} sessions.`,
150
+ "",
151
+ "Ordered actions (turn EACH into exactly one step, in this order):",
152
+ ];
153
+ cluster.normalized.forEach((step, i) => {
154
+ lines.push(`${i + 1}. ${step}`);
155
+ });
156
+ lines.push("", "Sample successful outcomes:");
157
+ for (const m of cluster.members.slice(0, 5)) {
158
+ if (m.outcome)
159
+ lines.push(`- ${m.outcome}`);
160
+ }
161
+ lines.push("", `Return strict JSON with EXACTLY ${cluster.normalized.length} steps in the same order, or an explicit null.`);
162
+ return lines.join("\n");
163
+ }
164
+ /** Parse the raw LLM output into a workflow, or `null` for the justified-null path. */
165
+ function parseProceduralWorkflow(raw, expectedSteps) {
166
+ if (raw === null)
167
+ return null;
168
+ const trimmed = raw.trim();
169
+ if (!trimmed || trimmed.toLowerCase() === "null")
170
+ return null;
171
+ const parsed = parseEmbeddedJsonResponse(trimmed);
172
+ if (parsed === undefined || parsed === null || typeof parsed !== "object")
173
+ return null;
174
+ const obj = parsed;
175
+ const title = typeof obj.title === "string" ? obj.title.trim() : "";
176
+ const description = typeof obj.description === "string" ? obj.description : "";
177
+ if (!title && !description)
178
+ return null;
179
+ if (!Array.isArray(obj.steps) || obj.steps.length !== expectedSteps)
180
+ return null;
181
+ const steps = [];
182
+ for (const rawStep of obj.steps) {
183
+ if (typeof rawStep !== "object" || rawStep === null)
184
+ return null;
185
+ const s = rawStep;
186
+ const stepTitle = typeof s.title === "string" ? s.title.trim() : "";
187
+ const instructions = typeof s.instructions === "string" ? s.instructions.trim() : "";
188
+ if (!stepTitle || !instructions)
189
+ return null;
190
+ const completionCriteria = Array.isArray(s.completionCriteria)
191
+ ? s.completionCriteria.filter((c) => typeof c === "string" && c.trim().length > 0)
192
+ : undefined;
193
+ steps.push({
194
+ title: stepTitle,
195
+ instructions,
196
+ ...(completionCriteria && completionCriteria.length > 0 ? { completionCriteria } : {}),
197
+ });
198
+ }
199
+ return { title: title || "Compiled Workflow", description, steps };
200
+ }
201
+ // ── Workflow markdown assembly ────────────────────────────────────────────────
202
+ /** Convert a step title into a kebab-case step id. */
203
+ function kebab(s) {
204
+ return (s
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9]+/g, "-")
207
+ .replace(/-+/g, "-")
208
+ .replace(/^-|-$/g, "") || "step");
209
+ }
210
+ /** Build the exact workflow markdown the parser accepts. */
211
+ export function assembleWorkflowMarkdown(doc) {
212
+ const lines = [
213
+ "---",
214
+ `description: ${JSON.stringify(doc.description)}`,
215
+ "---",
216
+ "",
217
+ `# Workflow: ${doc.title}`,
218
+ "",
219
+ ];
220
+ const usedIds = new Set();
221
+ doc.steps.forEach((step, idx) => {
222
+ let id = kebab(step.title);
223
+ if (usedIds.has(id))
224
+ id = `${id}-${idx + 1}`;
225
+ usedIds.add(id);
226
+ lines.push(`## Step: ${step.title}`, `Step ID: ${id}`, "", "### Instructions", step.instructions, "");
227
+ if (step.completionCriteria && step.completionCriteria.length > 0) {
228
+ lines.push("### Completion Criteria");
229
+ for (const c of step.completionCriteria)
230
+ lines.push(`- ${c}`);
231
+ lines.push("");
232
+ }
233
+ });
234
+ return lines.join("\n");
235
+ }
236
+ // ── Production LLM seam ───────────────────────────────────────────────────────
237
+ /**
238
+ * Resolve the production LLM seam from the active improve profile. Returns a
239
+ * `ProceduralLlmFn` that issues one bounded chatCompletion per call, or
240
+ * `undefined` when no LLM is configured (the pass then makes no calls).
241
+ */
242
+ function resolveProductionLlmFn(config, signal) {
243
+ const proceduralProcess = config.profiles?.improve?.default?.processes?.procedural;
244
+ const runnerSpec = resolveImproveProcessRunnerFromProfile(proceduralProcess, config);
245
+ const llmConfig = runnerSpec && runnerIsLlm(runnerSpec) ? runnerSpec.connection : getDefaultLlmConfig(config);
246
+ if (!llmConfig)
247
+ return undefined;
248
+ return async (prompt) => {
249
+ const messages = [
250
+ { role: "system", content: PROCEDURAL_SYSTEM_PROMPT },
251
+ { role: "user", content: prompt },
252
+ ];
253
+ try {
254
+ return await chatCompletion(llmConfig, messages, { signal, enableThinking: false });
255
+ }
256
+ catch (e) {
257
+ warn(`[procedural] LLM call failed: ${String(e)}`);
258
+ return null;
259
+ }
260
+ };
261
+ }
262
+ // ── Main entry point ───────────────────────────────────────────────────────────
263
+ export async function akmProcedural(opts) {
264
+ const startMs = Date.now();
265
+ const config = opts.config ?? loadConfig();
266
+ const stashDir = opts.stashDir ?? resolveStashDir();
267
+ const sourceRun = opts.sourceRun ?? `procedural-${startMs}`;
268
+ const eligibilitySource = opts.eligibilitySource ?? "procedural";
269
+ const minRecurrence = opts.minRecurrence ?? DEFAULT_MIN_RECURRENCE;
270
+ const maxProposalsPerRun = opts.maxProposalsPerRun ?? DEFAULT_MAX_PROPOSALS_PER_RUN;
271
+ const warnings = [];
272
+ const finish = (over) => ({
273
+ schemaVersion: 1,
274
+ ok: true,
275
+ sequencesScanned: 0,
276
+ clustersFormed: 0,
277
+ proposalsEmitted: 0,
278
+ nullsReturned: 0,
279
+ durationMs: Date.now() - startMs,
280
+ warnings,
281
+ ...over,
282
+ });
283
+ // Budget guard: an already-aborted signal short-circuits before any LLM call.
284
+ if (opts.signal?.aborted) {
285
+ return finish({ ok: false, warnings: [...warnings, "aborted-before-start"] });
286
+ }
287
+ // Load all entries from the index (orderedActions can ride any asset type).
288
+ let entries = [];
289
+ let db;
290
+ try {
291
+ db = openExistingDatabase();
292
+ entries = getAllEntries(db);
293
+ }
294
+ catch (e) {
295
+ warnings.push(`procedural: failed to open index — ${String(e)}`);
296
+ return finish({ ok: false });
297
+ }
298
+ finally {
299
+ if (db)
300
+ closeDatabase(db);
301
+ }
302
+ const clusters = buildSequenceClusters(entries, { minRecurrence, maxProposalsPerRun });
303
+ const sequencesScanned = entries.length;
304
+ let clustersFormed = 0;
305
+ let proposalsEmitted = 0;
306
+ let nullsReturned = 0;
307
+ if (clusters.length === 0) {
308
+ return finish({ sequencesScanned, clustersFormed: 0 });
309
+ }
310
+ const llmFn = opts.proceduralLlmFn ?? resolveProductionLlmFn(config, opts.signal);
311
+ if (!llmFn) {
312
+ warnings.push("procedural: no LLM configured — skipping");
313
+ return finish({ sequencesScanned, clustersFormed: 0 });
314
+ }
315
+ for (const cluster of clusters) {
316
+ if (opts.signal?.aborted) {
317
+ warnings.push("aborted-mid-run");
318
+ break;
319
+ }
320
+ clustersFormed += 1;
321
+ const workflowRef = deriveProceduralWorkflowRef(cluster);
322
+ const prompt = buildProceduralPrompt(cluster);
323
+ const raw = await llmFn(prompt);
324
+ const doc = parseProceduralWorkflow(raw, cluster.normalized.length);
325
+ if (!doc) {
326
+ nullsReturned += 1;
327
+ appendEvent({
328
+ eventType: "procedural_compiled",
329
+ ref: workflowRef,
330
+ metadata: {
331
+ groupKey: cluster.groupKey,
332
+ memberCount: cluster.members.length,
333
+ outcome: "null_returned",
334
+ sourceRun,
335
+ },
336
+ }, opts.ctx);
337
+ continue;
338
+ }
339
+ // Quality gate (always-run, never bypassed): the description must be present
340
+ // and non-truncated. Runs BEFORE createProposal.
341
+ const fmCheck = validateProposalFrontmatter({ description: doc.description });
342
+ if (!fmCheck.ok) {
343
+ appendEvent({
344
+ eventType: "procedural_compiled",
345
+ ref: workflowRef,
346
+ metadata: {
347
+ groupKey: cluster.groupKey,
348
+ memberCount: cluster.members.length,
349
+ outcome: "quality_rejected",
350
+ reason: fmCheck.reason,
351
+ sourceRun,
352
+ },
353
+ }, opts.ctx);
354
+ continue;
355
+ }
356
+ // Assemble + locally validate the workflow markdown. Never queue an
357
+ // unparseable workflow.
358
+ const content = assembleWorkflowMarkdown(doc);
359
+ const parsed = parseWorkflow(content, { path: workflowRef });
360
+ if (!parsed.ok) {
361
+ appendEvent({
362
+ eventType: "procedural_compiled",
363
+ ref: workflowRef,
364
+ metadata: {
365
+ groupKey: cluster.groupKey,
366
+ memberCount: cluster.members.length,
367
+ outcome: "invalid_workflow",
368
+ reason: parsed.errors[0]?.message,
369
+ sourceRun,
370
+ },
371
+ }, opts.ctx);
372
+ continue;
373
+ }
374
+ const proposalResult = createProposal(stashDir, {
375
+ ref: workflowRef,
376
+ source: "procedural",
377
+ sourceRun,
378
+ payload: { content, frontmatter: { description: doc.description } },
379
+ eligibilitySource,
380
+ }, opts.ctx);
381
+ if (isProposalSkipped(proposalResult)) {
382
+ appendEvent({
383
+ eventType: "procedural_compiled",
384
+ ref: workflowRef,
385
+ metadata: {
386
+ groupKey: cluster.groupKey,
387
+ memberCount: cluster.members.length,
388
+ outcome: "skipped",
389
+ skipReason: proposalResult.reason,
390
+ sourceRun,
391
+ },
392
+ }, opts.ctx);
393
+ continue;
394
+ }
395
+ proposalsEmitted += 1;
396
+ appendEvent({
397
+ eventType: "procedural_compiled",
398
+ ref: workflowRef,
399
+ metadata: {
400
+ groupKey: cluster.groupKey,
401
+ memberCount: cluster.members.length,
402
+ outcome: "queued",
403
+ proposalId: proposalResult.id,
404
+ sourceRun,
405
+ },
406
+ }, opts.ctx);
407
+ }
408
+ return finish({ sequencesScanned, clustersFormed, proposalsEmitted, nullsReturned });
409
+ }