akm-cli 0.10.261005-beta → 0.10.261007-beta

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 (55) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/STABILITY.md +9 -0
  3. package/dist/assets/ui/app.js +91 -0
  4. package/dist/assets/ui/asset.js +67 -0
  5. package/dist/assets/ui/bundles.js +80 -0
  6. package/dist/assets/ui/changes.js +32 -0
  7. package/dist/assets/ui/charts.js +93 -0
  8. package/dist/assets/ui/config.js +69 -0
  9. package/dist/assets/ui/diff-view.js +202 -0
  10. package/dist/assets/ui/diff.js +261 -0
  11. package/dist/assets/ui/index.html +30 -0
  12. package/dist/assets/ui/lib.js +238 -0
  13. package/dist/assets/ui/markdown.js +103 -0
  14. package/dist/assets/ui/overview.js +145 -0
  15. package/dist/assets/ui/proposal-actions.js +273 -0
  16. package/dist/assets/ui/proposals.js +219 -0
  17. package/dist/assets/ui/search.js +62 -0
  18. package/dist/assets/ui/styles.css +1415 -0
  19. package/dist/cli.js +3 -0
  20. package/dist/commands/health/checks.js +5 -3
  21. package/dist/commands/health/egress.js +5 -1
  22. package/dist/commands/health/engine-usage.js +2 -2
  23. package/dist/commands/health/llm-usage.js +2 -2
  24. package/dist/commands/health/windows.js +5 -5
  25. package/dist/commands/health.js +40 -8
  26. package/dist/commands/improve/reflect-noise.js +4 -0
  27. package/dist/commands/improve/reflect.js +14 -5
  28. package/dist/commands/metrics/metrics-cli.js +3 -3
  29. package/dist/commands/proposal/propose-cli.js +5 -0
  30. package/dist/commands/proposal/propose.js +6 -2
  31. package/dist/commands/proposal/repository.js +34 -29
  32. package/dist/commands/read/search.js +2 -1
  33. package/dist/commands/read/show.js +18 -12
  34. package/dist/commands/sources/source-clone.js +123 -11
  35. package/dist/commands/ui/api.js +556 -0
  36. package/dist/commands/ui/git-read.js +101 -0
  37. package/dist/commands/ui/open-browser.js +30 -0
  38. package/dist/commands/ui/proposal-actions.js +179 -0
  39. package/dist/commands/ui/redact.js +88 -0
  40. package/dist/commands/ui/server.js +352 -0
  41. package/dist/commands/ui/ui-cli.js +62 -0
  42. package/dist/core/asset/resolve-ref.js +7 -3
  43. package/dist/indexer/indexer.js +14 -2
  44. package/dist/indexer/search/db-search.js +7 -3
  45. package/dist/output/format-exempt.js +3 -0
  46. package/dist/storage/repositories/improve-runs-repository.js +6 -0
  47. package/dist/storage/repositories/index-db.js +10 -2
  48. package/dist/storage/repositories/metrics-repository.js +17 -0
  49. package/docs/architecture/0.10-process-rules.md +3 -4
  50. package/docs/architecture/improvement.md +2 -1
  51. package/docs/architecture/internals/improve-workflow.md +2 -2
  52. package/docs/reference/README.md +1 -0
  53. package/docs/reference/cli.md +68 -0
  54. package/docs/reference/ui-api.md +576 -0
  55. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -85,6 +85,7 @@ import { renderMigrationHelp } from "./commands/sources/migration-help.js";
85
85
  import { cloneCommand, syncCommand, upgradeCommand } from "./commands/sources/sources-cli.js";
86
86
  import { importKnowledgeCommand, indexCommand, infoCommand } from "./commands/sources/stash-cli.js";
87
87
  import { taskCommand } from "./commands/tasks/tasks-cli.js";
88
+ import { uiCommand } from "./commands/ui/ui-cli.js";
88
89
  import { workflowCommand } from "./commands/workflow-cli.js";
89
90
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
90
91
  import { UsageError } from "./core/errors.js";
@@ -448,6 +449,7 @@ const commands = {
448
449
  index: indexCommand,
449
450
  health: healthCommand,
450
451
  metrics: metricsCommand,
452
+ ui: uiCommand,
451
453
  info: infoCommand,
452
454
  bundle: bundleCommand,
453
455
  upgrade: upgradeCommand,
@@ -778,6 +780,7 @@ const HELP_SECTIONS = [
778
780
  "lint",
779
781
  "health",
780
782
  "metrics",
783
+ "ui",
781
784
  "config",
782
785
  "models",
783
786
  "registry",
@@ -902,9 +902,11 @@ export const HEALTH_CHECKS = [
902
902
  kind: "deterministic",
903
903
  status: ctx.probe.ok ? "pass" : "fail",
904
904
  confidence: "high",
905
- message: ctx.probe.ok
906
- ? "state.db append/read round-trip succeeded."
907
- : `state.db round-trip failed: ${ctx.probe.error}`,
905
+ message: ctx.probe.skipped
906
+ ? "state.db append/read round-trip skipped: this run is read-only."
907
+ : ctx.probe.ok
908
+ ? "state.db append/read round-trip succeeded."
909
+ : `state.db round-trip failed: ${ctx.probe.error}`,
908
910
  evidence: { path: ctx.stateDbPath, durationMs: ctx.probe.durationMs },
909
911
  }),
910
912
  },
@@ -8,6 +8,7 @@
8
8
  * configured, matching the `stash-git-exposure` / `binary-config-skew`
9
9
  * advisories' pattern.
10
10
  */
11
+ import { redactCredentialPatterns } from "../../core/redaction.js";
11
12
  import { formatRegistryUrl } from "../../core/registry-url.js";
12
13
  /**
13
14
  * `egress-endpoints` (08 surfaces 3/9): the full list of remote destinations
@@ -42,6 +43,9 @@ export function collectEgressAdvisory(config) {
42
43
  endpoints.push(`embedding: ${config.embedding.endpoint}`);
43
44
  if (endpoints.length === 0)
44
45
  return undefined;
46
+ // A configured URL can carry a credential (`?token=…`, `user:pass@`); the list is for eyeballing destinations, so
47
+ // print the host and path and never the secret.
48
+ const safe = endpoints.map(redactCredentialPatterns);
45
49
  return {
46
50
  name: "egress-endpoints",
47
51
  kind: "deterministic",
@@ -49,6 +53,6 @@ export function collectEgressAdvisory(config) {
49
53
  confidence: "high",
50
54
  message: `${endpoints.length} remote endpoint(s) in the effective config (registries/sources/LLM/embedding) — ` +
51
55
  "review the evidence list for unexpected destinations.",
52
- evidence: { endpoints },
56
+ evidence: { endpoints: safe },
53
57
  };
54
58
  }
@@ -28,9 +28,9 @@ export function engineLastUsedSince(now) {
28
28
  * `engine` field (pre-#576 rows, or a call outside any resolved engine) is
29
29
  * skipped rather than bucketed under a synthetic key.
30
30
  */
31
- export function readLastEngineUsage(stateDbPath, now) {
31
+ export function readLastEngineUsage(stateDbPath, now, readOnly = false) {
32
32
  const since = engineLastUsedSince(now);
33
- const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events;
33
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath, readOnly }).events;
34
34
  const lastUsed = new Map();
35
35
  for (const event of events) {
36
36
  const record = decodeLlmUsageRecord(event.metadata);
@@ -59,8 +59,8 @@ export function summarizeLlmUsage(events) {
59
59
  }
60
60
  return aggregate;
61
61
  }
62
- export function readLlmUsageAggregate(stateDbPath, since, until) {
63
- const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events.filter((event) => {
62
+ export function readLlmUsageAggregate(stateDbPath, since, until, readOnly = false) {
63
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath, readOnly }).events.filter((event) => {
64
64
  if (until === undefined)
65
65
  return true;
66
66
  return new Date(event.ts ?? since).getTime() < new Date(until).getTime();
@@ -151,9 +151,9 @@ export function computeTaskWindowRates(taskRows, now) {
151
151
  * stats from the same per-run rows, and accepted-proposal coverage, all over
152
152
  * one `[since, until)`.
153
153
  */
154
- export function buildImproveWindowSummary(db, stateDbPath, since, until) {
154
+ export function buildImproveWindowSummary(db, stateDbPath, since, until, readOnly = false) {
155
155
  const untilMs = new Date(until).getTime();
156
- const eventsBeforeUntil = (type) => readEvents({ since, type }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < untilMs);
156
+ const eventsBeforeUntil = (type) => readEvents({ since, type }, { dbPath: stateDbPath, readOnly }).events.filter((event) => new Date(event.ts ?? since).getTime() < untilMs);
157
157
  const { metrics: improve, runCount } = summarizeImproveRuns(db, since, until);
158
158
  improve.invoked = eventsBeforeUntil("improve_invoked").length;
159
159
  improve.completed = eventsBeforeUntil(IMPROVE_COMPLETED_EVENT).length;
@@ -167,20 +167,20 @@ export function buildImproveWindowSummary(db, stateDbPath, since, until) {
167
167
  improve.coverage = computeWindowProposalCoverage(db, since, until);
168
168
  return { improve, runCount, perRunSummaries };
169
169
  }
170
- export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now()) {
170
+ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(), readOnly = false) {
171
171
  const untilMs = new Date(until).getTime();
172
172
  const taskRows = queryTaskHistory(db, { since }).filter((row) => {
173
173
  const startMs = new Date(row.started_at).getTime();
174
174
  return !Number.isFinite(untilMs) || startMs < untilMs;
175
175
  });
176
176
  const rates = computeTaskWindowRates(taskRows, now);
177
- const { improve, runCount } = buildImproveWindowSummary(db, stateDbPath, since, until);
177
+ const { improve, runCount } = buildImproveWindowSummary(db, stateDbPath, since, until, readOnly);
178
178
  const metrics = {
179
179
  taskFailRate: roundRate(rates.taskFailRate),
180
180
  agentFailureRate: roundRate(rates.agentFailureRate),
181
181
  agentFailureReasonCounts: rates.agentFailureReasonCounts,
182
182
  stuckActiveRuns: rates.stuckActiveRuns,
183
- llmUsage: readLlmUsageAggregate(stateDbPath, since, until),
183
+ llmUsage: readLlmUsageAggregate(stateDbPath, since, until, readOnly),
184
184
  };
185
185
  return { improve, metrics, runs: runCount };
186
186
  }
@@ -11,12 +11,14 @@ import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from ".
11
11
  import { listExistingTableNames, listPendingStateMigrations, openStateDatabaseWithReport, withStateDb, } from "../core/state-db.js";
12
12
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
13
13
  import { probeLlmEndpoint } from "../llm/client.js";
14
+ import { openDatabase } from "../storage/database.js";
14
15
  import { insertEvent } from "../storage/repositories/events-repository.js";
15
16
  import { getExtractOutcomeCountsSince } from "../storage/repositories/extract-sessions-repository.js";
16
17
  import { countImproveRunsSince } from "../storage/repositories/improve-runs-repository.js";
17
18
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
19
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
20
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
21
+ import { applyReadonlyPragmas } from "../storage/sqlite-pragmas.js";
20
22
  import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
21
23
  import { pkgVersion } from "../version.js";
22
24
  import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
@@ -176,12 +178,12 @@ function computeWorstTaskFailRate(rows) {
176
178
  return worst;
177
179
  }
178
180
  /** Table presence, the state.db round-trip probe, and task_history-derived rates. */
179
- function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
181
+ function gatherTaskHistoryPhase(db, since, stateDbPath, now, readOnly) {
180
182
  const tables = listExistingTableNames(db, ["events", "task_history", "proposals", "schema_migrations"]);
181
183
  const tableNames = tables.map((row) => row.name).sort();
182
184
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
183
185
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
184
- const probe = probeStateDbRoundTrip(stateDbPath);
186
+ const probe = readOnly ? { ok: true, durationMs: null, skipped: true } : probeStateDbRoundTrip(stateDbPath);
185
187
  // R0: read-only, independent of the round-trip probe above — integrity_check
186
188
  // catches corruption a successful append/read cannot (out-of-order rowids,
187
189
  // bad index entry counts), and the freelist reading is purely informational.
@@ -440,7 +442,7 @@ function resolveWindowComparePhase(options, db, stateDbPath, now) {
440
442
  windowResults = windowSpecs.map((spec) => {
441
443
  const winSince = parseHealthSince(spec.since);
442
444
  const winUntil = spec.until ? parseHealthSince(spec.until) : new Date(now()).toISOString();
443
- const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now);
445
+ const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now, options.readOnly === true);
444
446
  return {
445
447
  name: spec.name,
446
448
  since: winSince,
@@ -508,6 +510,25 @@ function unreadableStateDbCheck(detail) {
508
510
  evidence: { detail },
509
511
  };
510
512
  }
513
+ /**
514
+ * Why a read-only health run cannot use `stateDbPath`, as the failing check to
515
+ * report: the file is missing, or it is behind on migrations (a read-only open
516
+ * must not apply them). `undefined` when it can be read as it is.
517
+ */
518
+ function readOnlyStateDbUnavailable(stateDbPath) {
519
+ if (!fs.existsSync(stateDbPath)) {
520
+ return {
521
+ name: "state-db-readable",
522
+ kind: "deterministic",
523
+ status: "fail",
524
+ confidence: "high",
525
+ message: `state.db does not exist at ${stateDbPath}; a read-only health run does not create it. Run any akm command that records state, such as \`akm health\`.`,
526
+ evidence: { path: stateDbPath },
527
+ };
528
+ }
529
+ const pending = runPendingStateMigrationsCheck(stateDbPath, { listPendingStateMigrations });
530
+ return pending.status === "fail" ? pending : undefined;
531
+ }
511
532
  export async function akmHealth(options = {}) {
512
533
  validateAkmHealthOptions(options);
513
534
  const now = options.now ?? (() => Date.now());
@@ -521,9 +542,20 @@ export async function akmHealth(options = {}) {
521
542
  // to find out why everything else is behaving oddly. Report it as a finding
522
543
  // instead of dying before any check runs. The open itself applies every
523
544
  // pending migration; `state-db-migrations` reports what it applied.
545
+ const readOnly = options.readOnly === true;
524
546
  let opened;
525
547
  try {
526
- opened = openStateDatabaseWithReport(stateDbPath);
548
+ if (readOnly) {
549
+ const unavailable = readOnlyStateDbUnavailable(stateDbPath);
550
+ if (unavailable)
551
+ return degradedStateDbReport(unavailable, options);
552
+ const db = openDatabase(stateDbPath, { readonly: true, create: false });
553
+ applyReadonlyPragmas(db);
554
+ opened = { db, applied: [] };
555
+ }
556
+ else {
557
+ opened = openStateDatabaseWithReport(stateDbPath);
558
+ }
527
559
  }
528
560
  catch (error) {
529
561
  const { access, code } = classifyPathAccess(stateDbPath);
@@ -561,14 +593,14 @@ export async function akmHealth(options = {}) {
561
593
  cliVersion: pkgVersion,
562
594
  });
563
595
  schedulerBinaryDriftPromise.catch(() => undefined);
564
- const taskHistory = gatherTaskHistoryPhase(db, since, stateDbPath, now);
596
+ const taskHistory = gatherTaskHistoryPhase(db, since, stateDbPath, now, readOnly);
565
597
  const { tableNames, missingTables, probe } = taskHistory;
566
598
  const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
567
599
  // Same window bundle `--window-compare` builds per-window (windows.ts) —
568
600
  // reused here for the main `--since` window's improve summary and for
569
601
  // `--group-by run`, so neither re-reads `improve_runs`.
570
602
  const until = new Date(now()).toISOString();
571
- const { improve: improveSummary, perRunSummaries } = buildImproveWindowSummary(db, stateDbPath, since, until);
603
+ const { improve: improveSummary, perRunSummaries } = buildImproveWindowSummary(db, stateDbPath, since, until, readOnly);
572
604
  advisories.push(...gatherAncillaryAdvisories(db, options, egressConfigView));
573
605
  const sessionExtractionLedger = gatherSessionExtractionLedgerPhase(db, now);
574
606
  // #950: computed once (no IO beyond config/env, same as gatherEgressConfigPhase)
@@ -578,14 +610,14 @@ export async function akmHealth(options = {}) {
578
610
  // #950: `engine-last-used` reads a fixed lookback window independent of
579
611
  // `--since` (mirrors sessionExtractionLedger's independent window above).
580
612
  const engineLastUsedSinceIso = engineLastUsedSince(now);
581
- const engineLastUsed = readLastEngineUsage(stateDbPath, now);
613
+ const engineLastUsed = readLastEngineUsage(stateDbPath, now, readOnly);
582
614
  const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
583
615
  const engineProbes = await engineProbesPromise;
584
616
  const versionDrift = await versionDriftPromise;
585
617
  const schedulerBinaryDrift = await schedulerBinaryDriftPromise;
586
618
  // Read once, shared by the `thinking-control` check (#949) and the
587
619
  // `metrics.llmUsage` report field below — same window, same aggregate.
588
- const llmUsage = readLlmUsageAggregate(stateDbPath, since);
620
+ const llmUsage = readLlmUsageAggregate(stateDbPath, since, undefined, readOnly);
589
621
  // Run the ordered health-check registry. Each check projects the shared
590
622
  // context computed above into one HealthCheckResult; `channel` routes it to
591
623
  // hardChecks or advisories. Declaration order in HEALTH_CHECKS is the
@@ -158,6 +158,10 @@ export function splitFrontmatter(raw) {
158
158
  const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
159
159
  return m ? { fmText: m[1] ?? "", body: m[2] ?? "" } : { fmText: null, body: raw };
160
160
  }
161
+ /** A level-1 heading line (`# text`) anywhere in a body: what reflect's title adds only when none is there. */
162
+ export function bodyHasH1(body) {
163
+ return /^#[ \t]+\S/m.test(body);
164
+ }
161
165
  /** Frontmatter text, fenced code blocks, and prose lines (an unclosed fence counts as prose). */
162
166
  function parseSections(text) {
163
167
  const { fmText, body } = splitFrontmatter(normalizeTrailingWhitespace(text));
@@ -44,7 +44,7 @@ import { contentHash } from "./content-hash.js";
44
44
  import { findAssetFilePath } from "./eligibility.js";
45
45
  import { resolveImproveExecution } from "./execution.js";
46
46
  import { recordLedgerAttempt } from "./ledger.js";
47
- import { classifyReflectChange, findReflectDefect, splitFrontmatter } from "./reflect-noise.js";
47
+ import { bodyHasH1, classifyReflectChange, findReflectDefect, splitFrontmatter, } from "./reflect-noise.js";
48
48
  import { callStageOnce, errMessage, mintProposal, noticeSet, rejectedProposalContext, resolveQualityGateJudge, runReflectQualityJudge, } from "./stage.js";
49
49
  const MAX_FEEDBACK_LINES = 10;
50
50
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
@@ -151,10 +151,17 @@ function firstProseSentence(body) {
151
151
  }
152
152
  return "";
153
153
  }
154
+ /** The body with `# title` and one blank line inserted at its first non-blank line; the rest is untouched. */
155
+ function withH1(body, title) {
156
+ const eol = body.match(/\r?\n/)?.[0] ?? "\n";
157
+ const leading = body.match(/^(?:\r?\n)*/)?.[0] ?? "";
158
+ return `${leading}# ${title}${eol}${eol}${body.slice(leading.length)}`;
159
+ }
154
160
  /**
155
161
  * The asset with the patch applied, or `undefined` when the patch changes
156
162
  * nothing (or there is no asset to patch). The body is the source's own, byte
157
- * for byte; the one thing akm adds to it is a `# title` heading it lacks. A
163
+ * for byte; the one thing akm adds to it is a `# title` heading it lacks, with
164
+ * one blank line after it (#1183). A
158
165
  * required description that neither the source nor the patch has is derived
159
166
  * from the asset's own text (#636). Only the changed keys' frontmatter lines are
160
167
  * rewritten: every other line is kept as it is, so a source whose YAML the
@@ -170,8 +177,10 @@ export function applyReflectPatch(patch, sourceContent, targetRef) {
170
177
  return undefined;
171
178
  const sourceFm = fmText !== null ? parseFrontmatter(sourceContent).data : {};
172
179
  const { title, ...changes } = patch;
173
- const addTitle = title !== undefined && !/^#[ \t]+\S/m.test(sourceBody);
174
- const body = addTitle ? `# ${title}\n\n${sourceBody.replace(/^(\r?\n)+/, "")}` : sourceBody;
180
+ const addTitle = title !== undefined && !bodyHasH1(sourceBody);
181
+ // The heading and one blank line go in at the body's first non-blank line, in
182
+ // the body's own line ending; every other byte of the body stays where it was.
183
+ const body = addTitle ? withH1(sourceBody, title) : sourceBody;
175
184
  // Only a source that already has frontmatter but no description gets one:
176
185
  // injecting a whole block, or overwriting an authored one, is out of scope.
177
186
  const refType = lenientRefType(targetRef);
@@ -198,7 +207,7 @@ export function applyReflectPatch(patch, sourceContent, targetRef) {
198
207
  const content = `---\n${serializeFrontmatter(changes)}\n---\n\n${body}`;
199
208
  return { content, frontmatter: parseFrontmatter(content).data };
200
209
  }
201
- const content = `---\n${patchFrontmatterLines(fmText, changes)}\n---\n${addTitle ? "\n" : ""}${body}`;
210
+ const content = `---\n${patchFrontmatterLines(fmText, changes)}\n---\n${body}`;
202
211
  return { content, frontmatter: parseFrontmatter(content).data };
203
212
  }
204
213
  /** The frontmatter text with each changed key's lines (the key line and its indented continuation) replaced, or appended. */
@@ -53,7 +53,7 @@ export function metricsIncludeRows(mode) {
53
53
  * `bundle//conceptId`; resolving here keeps one asset from showing as two rows.
54
54
  * A ref that does not resolve (registry results, removed assets) is kept as is.
55
55
  */
56
- async function durableSelects(selects) {
56
+ async function durableSelects(selects, indexSnapshot = true) {
57
57
  const unqualified = [];
58
58
  for (const ref of new Set(selects.map((select) => select.ref))) {
59
59
  try {
@@ -65,7 +65,7 @@ async function durableSelects(selects) {
65
65
  // Not a local bundle ref: keep the ref as recorded.
66
66
  }
67
67
  }
68
- const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed));
68
+ const entries = await lookupBundleRefsReadonly(unqualified.map(({ parsed }) => parsed), { isolatedSnapshot: indexSnapshot });
69
69
  const resolved = new Map(unqualified.map(({ ref }, i) => [ref, entries[i]?.itemRef ?? ref]));
70
70
  return selects.map((select) => ({ ts: select.ts, ref: resolved.get(select.ref) ?? select.ref }));
71
71
  }
@@ -190,7 +190,7 @@ export async function akmMetrics(options = {}) {
190
190
  finally {
191
191
  stateDb.close();
192
192
  }
193
- input.selects = await durableSelects(selects);
193
+ input.selects = await durableSelects(selects, options.indexSnapshot ?? true);
194
194
  return buildMetricsResult(input);
195
195
  }
196
196
  export const metricsCommand = defineJsonCommand({
@@ -47,6 +47,10 @@ export const proposeCommand = defineCommand({
47
47
  },
48
48
  task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
49
49
  file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
50
+ bundle: {
51
+ type: "string",
52
+ description: "Configured writable bundle to queue the proposal for and draw convention facts from (default: the working bundle)",
53
+ },
50
54
  engine: { type: "string", description: "Engine to use (defaults to defaults.engine)" },
51
55
  "timeout-ms": { type: "string", description: "Override the engine timeout in milliseconds" },
52
56
  },
@@ -73,6 +77,7 @@ export const proposeCommand = defineCommand({
73
77
  name: proposedName,
74
78
  task: taskText,
75
79
  engine: getStringArg(args, "engine"),
80
+ bundle: getStringArg(args, "bundle"),
76
81
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
77
82
  });
78
83
  output("proposal-new", result);
@@ -23,6 +23,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
23
23
  import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
24
24
  import { runStructured } from "../../core/structured.js";
25
25
  import { warn } from "../../core/warn.js";
26
+ import { resolveWriteTarget } from "../../core/write-source.js";
26
27
  import { deriveEntryProvenance } from "../../indexer/installations.js";
27
28
  import { fallbackAnnouncement } from "../../integrations/agent/engine-fallback.js";
28
29
  import { buildExecution, resolveExecution } from "../../integrations/agent/execution.js";
@@ -144,10 +145,13 @@ export async function akmPropose(options) {
144
145
  if (!stashDirFor(options.type)) {
145
146
  throw new UsageError(`propose: unknown asset type "${options.type}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_FLAG_VALUE");
146
147
  }
147
- const stash = options.stashDir ?? resolveStashDir();
148
148
  // 1. Resolve the write target. Engine/model/inference resolution happens
149
- // exactly once below through the shared execution cascade.
149
+ // exactly once below through the shared execution cascade. `--bundle` names a
150
+ // configured, writable bundle (same resolver and errors as `akm remember
151
+ // --bundle`); its root becomes the stash the proposal is queued for and the
152
+ // prompt's standards are read from.
150
153
  const config = options.agentConfig ?? (await import("../../core/config/config.js")).loadConfig();
154
+ const stash = options.stashDir ?? (options.bundle ? resolveWriteTarget(config, options.bundle).source.path : resolveStashDir());
151
155
  const target = resolveProposalQueueTarget(stash, config);
152
156
  // 2. Build terminal user content.
153
157
  // Standards "rulebook" for this target — wiki schema (wiki page) or stash
@@ -436,34 +436,36 @@ function requireProposal(db, stashDir, id) {
436
436
  * proposal (then most recent archived); else by a unique pending id prefix.
437
437
  */
438
438
  export function resolveProposalId(stashDir, idOrRef, ctx) {
439
- return withProposalsDb(ctx, (db) => {
440
- const exact = getStateProposal(db, idOrRef, stashDir);
441
- if (exact)
442
- return exact;
443
- if (idOrRef.includes(":") || idOrRef.includes("/")) {
444
- const wantRef = filterRefIdentity(idOrRef);
445
- // Should-fix 7: by-ref resolution never picks a retire proposal — the
446
- // newest pending proposal for a ref could be a `consolidate-pair`
447
- // retirement rather than the reflect/distill edit a person typed the
448
- // ref to accept, and accepting it archives the asset instead. A retire
449
- // is reached by its own proposal id, or by the explicit generator
450
- // `consolidate-pair` (bulk accept/reject).
451
- const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
452
- .filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
453
- .sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
454
- const found = newest("pending") ?? newest();
455
- if (found)
456
- return found;
457
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
458
- }
459
- const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
460
- if (prefixMatches.length === 1)
461
- return requireProposal(db, stashDir, prefixMatches[0]);
462
- if (prefixMatches.length > 1) {
463
- throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
464
- }
465
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "PROPOSAL_NOT_FOUND");
466
- });
439
+ return withProposalsDb(ctx, (db) => resolveProposalIdIn(db, stashDir, idOrRef));
440
+ }
441
+ /** {@link resolveProposalId} over a connection the caller holds (`akm ui` reads state.db read-only). */
442
+ export function resolveProposalIdIn(db, stashDir, idOrRef) {
443
+ const exact = getStateProposal(db, idOrRef, stashDir);
444
+ if (exact)
445
+ return exact;
446
+ if (idOrRef.includes(":") || idOrRef.includes("/")) {
447
+ const wantRef = filterRefIdentity(idOrRef);
448
+ // Should-fix 7: by-ref resolution never picks a retire proposal — the
449
+ // newest pending proposal for a ref could be a `consolidate-pair`
450
+ // retirement rather than the reflect/distill edit a person typed the
451
+ // ref to accept, and accepting it archives the asset instead. A retire
452
+ // is reached by its own proposal id, or by the explicit generator
453
+ // `consolidate-pair` (bulk accept/reject).
454
+ const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
455
+ .filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
456
+ .sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
457
+ const found = newest("pending") ?? newest();
458
+ if (found)
459
+ return found;
460
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
461
+ }
462
+ const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
463
+ if (prefixMatches.length === 1)
464
+ return requireProposal(db, stashDir, prefixMatches[0]);
465
+ if (prefixMatches.length > 1) {
466
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
467
+ }
468
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "PROPOSAL_NOT_FOUND");
467
469
  }
468
470
  /**
469
471
  * The ledger outcome of a decision. A procedural refusal (retention expiry, a
@@ -1869,7 +1871,10 @@ export function reopenProposals(stashDir, config, ids, options = {}, ctx) {
1869
1871
  }
1870
1872
  /** The proposal against the asset its accept would overwrite (same target resolution as accept). */
1871
1873
  export function diffProposal(stashDir, config, id, options = {}, ctx) {
1872
- const proposal = getProposal(stashDir, id, ctx);
1874
+ return diffLoadedProposal(config, getProposal(stashDir, id, ctx), options);
1875
+ }
1876
+ /** {@link diffProposal} for a proposal the caller already read (`akm ui` reads state.db read-only). */
1877
+ export function diffLoadedProposal(config, proposal, options = {}) {
1873
1878
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1874
1879
  const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1875
1880
  const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
@@ -123,6 +123,7 @@ export async function akmSearch(input) {
123
123
  // would leak hits from sources the caller did not request.
124
124
  restrictToSources: namedSourceName !== undefined,
125
125
  includeExcludedTypes: input.includeSessions === true,
126
+ readOnly: input.readOnly,
126
127
  });
127
128
  const registryResult = source === "local"
128
129
  ? undefined
@@ -201,7 +202,7 @@ function usageSearchMode(mode) {
201
202
  return mode === "semantic" ? "semantic" : "keyword";
202
203
  }
203
204
  function maybeLogSearchEvent(input, query, response, mode) {
204
- if (input.skipLogging)
205
+ if (input.skipLogging || input.readOnly)
205
206
  return;
206
207
  logSearchEvent(query, response, mode, input.eventSource, input.attributionProjection);
207
208
  }
@@ -33,7 +33,7 @@ import { appendEvent } from "../../core/events.js";
33
33
  import { SCRIPT_EXTENSIONS } from "../../core/recognition-util.js";
34
34
  import { presentationFor } from "../../core/type-presentation.js";
35
35
  import { warn, warnOnce } from "../../core/warn.js";
36
- import { lookupBundleRef, lookupBundleRefWithResolution } from "../../indexer/indexer.js";
36
+ import { lookupBundleRef, lookupBundleRefWithResolution, lookupBundleRefWithResolutionReadonly, } from "../../indexer/indexer.js";
37
37
  import { projectMarkdownFragmentContent } from "../../indexer/passes/metadata.js";
38
38
  import { ensurePrimaryIndexForRead, resolveReadSources } from "../../indexer/read-preflight.js";
39
39
  import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries, } from "../../indexer/search/search-source.js";
@@ -65,7 +65,7 @@ export async function akmShowUnified(input) {
65
65
  {
66
66
  const metaRef = parseMetaRef(ref);
67
67
  if (metaRef)
68
- return showStashMeta(metaRef);
68
+ return showStashMeta(metaRef, input.readOnly === true);
69
69
  }
70
70
  // Env/secret bodies have no safe fragment surface, and a fragment cannot
71
71
  // widen what the env/secret renderers expose: both always omit the body
@@ -77,7 +77,7 @@ export async function akmShowUnified(input) {
77
77
  // safe substrate below is its source of truth. Friendly heading selectors
78
78
  // intentionally retain the normal source-live read behavior.
79
79
  const parsedRef = parseBundleRef(ref);
80
- if (!parsedRef.fragment?.startsWith("akm-fragment-")) {
80
+ if (!input.readOnly && !parsedRef.fragment?.startsWith("akm-fragment-")) {
81
81
  const { primarySource } = resolveReadSources();
82
82
  await ensurePrimaryIndexForRead(primarySource);
83
83
  }
@@ -91,7 +91,7 @@ export async function akmShowUnified(input) {
91
91
  enforceScopeOrThrow(result.path, ref, input.scope);
92
92
  }
93
93
  // Count prior shows of this ref before logging the current one.
94
- if (!input.skipLogging) {
94
+ if (!input.skipLogging && !input.readOnly) {
95
95
  const consumedRef = result.ref ?? makeBundleRef(undefined, parseBundleRef(ref).conceptId);
96
96
  const priorShowCount = recentShowCount(consumedRef);
97
97
  recordShowUsage(consumedRef, result.type, result.name, input.eventSource, result.path);
@@ -110,7 +110,7 @@ export async function akmShowUnified(input) {
110
110
  * to that stash; an uninstalled origin yields an actionable "not installed"
111
111
  * error. The file is read directly from disk — `.meta/` is never indexed.
112
112
  */
113
- async function showStashMeta(metaRef) {
113
+ async function showStashMeta(metaRef, readOnly = false) {
114
114
  const allSources = resolveSourceEntries();
115
115
  const sources = resolveSourcesForOrigin(metaRef.origin, allSources);
116
116
  if (metaRef.origin && sources.length === 0) {
@@ -123,7 +123,9 @@ async function showStashMeta(metaRef) {
123
123
  if (!metaFile)
124
124
  continue;
125
125
  const editable = isEditable(metaFile.path, config, allSources);
126
- appendEvent({ eventType: "show", ref: `meta:${metaRef.name}`, metadata: { type: "meta", name: metaRef.name } });
126
+ if (!readOnly) {
127
+ appendEvent({ eventType: "show", ref: `meta:${metaRef.name}`, metadata: { type: "meta", name: metaRef.name } });
128
+ }
127
129
  return {
128
130
  type: "meta",
129
131
  name: metaRef.name,
@@ -211,7 +213,9 @@ export async function showLocal(input) {
211
213
  const allSources = resolveSourceEntries(input.stashDir);
212
214
  const searchSources = resolveSourcesForOrigin(parsed.bundle, allSources);
213
215
  const allSourceDirs = searchSources.map((s) => s.path);
214
- const resolution = await lookupBundleRefWithResolution(parsed);
216
+ const resolution = input.readOnly
217
+ ? await lookupBundleRefWithResolutionReadonly(parsed)
218
+ : await lookupBundleRefWithResolution(parsed);
215
219
  if (resolution.indexError !== undefined)
216
220
  throw resolution.indexError;
217
221
  const indexedEntry = resolution.entry;
@@ -260,7 +264,9 @@ export async function showLocal(input) {
260
264
  // Only resolve the indexed-safe revision when the selector itself is opaque,
261
265
  // or when the caller explicitly opts into indexed-safe contextual output.
262
266
  const indexedFragment = parsed.fragment && (opaqueFragmentSelector || input.contextMode === "lead")
263
- ? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment))
267
+ ? withIndexDb((db) => getIndexedMarkdownFragment(db, indexedEntry.itemRef, parsed.fragment), {
268
+ readOnly: input.readOnly === true,
269
+ })
264
270
  : undefined;
265
271
  const indexedFragmentContent = opaqueFragmentSelector ? indexedFragment?.content : undefined;
266
272
  const indexedRenderer = rendererForIndexedEntry(indexedEntry, fileCtx);
@@ -364,9 +370,9 @@ export async function showLocal(input) {
364
370
  origin: source?.registryId ?? null,
365
371
  editable,
366
372
  ...(!editable ? { editHint: buildEditHint(canonicalRef) } : {}),
367
- ...showLinks(indexedEntry.itemRef, displayDefaultBundle),
373
+ ...showLinks(indexedEntry.itemRef, displayDefaultBundle, input.readOnly === true),
368
374
  };
369
- const activeRun = await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
375
+ const activeRun = input.readOnly ? null : await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
370
376
  if (activeRun) {
371
377
  fullResponse.activeRun = activeRun;
372
378
  }
@@ -385,10 +391,10 @@ const LINKS_PER_KIND = 10;
385
391
  * authored order, incoming by ref, and unresolved tokens as authored. Empty
386
392
  * parts are omitted, and the field when nothing links either way.
387
393
  */
388
- function showLinks(itemRef, defaultBundle) {
394
+ function showLinks(itemRef, defaultBundle, readOnly = false) {
389
395
  let rows;
390
396
  try {
391
- rows = withIndexDb((db) => readEntryLinks(db, itemRef));
397
+ rows = withIndexDb((db) => readEntryLinks(db, itemRef), { readOnly });
392
398
  }
393
399
  catch (err) {
394
400
  rethrowIfTestIsolationError(err);