akm-cli 0.9.17-alpha.9 → 0.9.18

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 (32) hide show
  1. package/CHANGELOG.md +296 -2025
  2. package/dist/cli/unknown-flags.js +24 -1
  3. package/dist/cli.js +46 -1
  4. package/dist/commands/health/archive-usage.js +9 -15
  5. package/dist/commands/health/checks.js +6 -6
  6. package/dist/commands/health/improve-metrics.js +25 -12
  7. package/dist/commands/health.js +3 -3
  8. package/dist/commands/improve/consolidate/pair-pass.js +2 -2
  9. package/dist/commands/improve/consolidate.js +3 -3
  10. package/dist/commands/improve/distill.js +1 -1
  11. package/dist/commands/improve/memory/memory-improve.js +8 -15
  12. package/dist/commands/improve/preparation.js +2 -0
  13. package/dist/commands/improve/reflect.js +1 -1
  14. package/dist/commands/improve/stage.js +5 -3
  15. package/dist/commands/proposal/repository.js +5 -1
  16. package/dist/commands/sources/info.js +122 -18
  17. package/dist/commands/sources/stash-cli.js +21 -1
  18. package/dist/core/improve-result.js +6 -1
  19. package/dist/output/text/command-format.js +9 -0
  20. package/dist/scripts/akm-migrate-node.js +29 -2
  21. package/dist/scripts/akm-migrate.js +29 -2
  22. package/dist/sources/providers/git-stash.js +28 -0
  23. package/dist/storage/repositories/index-connection.js +5 -2
  24. package/dist/storage/sqlite-read-snapshot.js +46 -2
  25. package/dist/storage/state-db-integrity.js +12 -9
  26. package/docs/migration/README.md +1 -1
  27. package/docs/migration/release-notes/0.9.17.md +130 -41
  28. package/docs/migration/release-notes/README.md +7 -0
  29. package/docs/migration/v0.7-to-v0.8.md +2 -2
  30. package/docs/reference/cli.md +11 -2
  31. package/docs/reference/data-and-telemetry.md +5 -2
  32. package/package.json +1 -1
@@ -26,8 +26,17 @@
26
26
  * validation) — conservative by design.
27
27
  */
28
28
  import { UsageError } from "../core/errors.js";
29
+ import { warn } from "../core/warn.js";
29
30
  import { cittyComparableName, findCittyTopLevelCommandIndex, toAliasArray, } from "./invocation.js";
30
31
  import { retiredFlagHint } from "./retired-commands.js";
32
+ /**
33
+ * Commands that must never refuse on an unrecognized flag — `akm info` warns
34
+ * and continues instead of exiting 2, the same tolerance a bare `akm help
35
+ * --whatever` already gets for free from its group-command fallback (no
36
+ * resolved subcommand, so this gate stands down entirely). `info` is a leaf
37
+ * command, so it needs an explicit opt-in here instead.
38
+ */
39
+ const UNKNOWN_FLAG_TOLERANT_COMMANDS = new Set(["info"]);
31
40
  /** Flags citty implements itself, which no command declares. */
32
41
  const IMPLICIT_FLAGS = ["help", "h", "version", "v"];
33
42
  /**
@@ -172,6 +181,10 @@ function throwUnknownFlag(shown, attempted, known) {
172
181
  ? `Did you mean \`${suggestion}\`? Run the command with \`--help\` to see its accepted flags.`
173
182
  : undefined);
174
183
  }
184
+ /** The {@link UNKNOWN_FLAG_TOLERANT_COMMANDS} counterpart to {@link throwUnknownFlag}: report, don't refuse. */
185
+ function warnUnknownFlag(shown, known) {
186
+ warn(`[akm ${known.path.join(" ")}] ignoring unknown flag "${shown}" — run with --help to see its accepted flags.`);
187
+ }
175
188
  /**
176
189
  * Throw a {@link UsageError} naming the first flag the resolved command does
177
190
  * not declare. Returns silently when every flag is known.
@@ -195,6 +208,7 @@ export function assertKnownFlags(root, rawArgs) {
195
208
  const dynamicNamedFlagCommands = new Set(["workflow run", "task run", "task explain"]);
196
209
  const dynamicWorkflowParams = dynamicNamedFlagCommands.has(known.path.join(" "));
197
210
  const selfDiagnosed = SELF_DIAGNOSED_FLAGS.get(known.path.join(" "));
211
+ const tolerant = UNKNOWN_FLAG_TOLERANT_COMMANDS.has(known.path.join(" "));
198
212
  for (let i = 0; i < ownArgs.length; i += 1) {
199
213
  const token = ownArgs[i];
200
214
  // Not a flag: positional, a bare `-` (stdin), or a negative number.
@@ -209,8 +223,13 @@ export function assertKnownFlags(root, rawArgs) {
209
223
  for (let offset = 0; offset < shortFlags.length; offset += 1) {
210
224
  const rawName = shortFlags[offset];
211
225
  const candidate = cittyComparableName(rawName);
212
- if (!known.names.has(candidate))
226
+ if (!known.names.has(candidate)) {
227
+ if (tolerant) {
228
+ warnUnknownFlag(token, known);
229
+ break;
230
+ }
213
231
  throwUnknownFlag(token, `-${rawName}`, known);
232
+ }
214
233
  if (known.valueFlags.has(candidate)) {
215
234
  if (offset === shortFlags.length - 1)
216
235
  i += 1;
@@ -239,6 +258,10 @@ export function assertKnownFlags(root, rawArgs) {
239
258
  // frozen plan before a run is inserted. Short flags remain strict.
240
259
  if (dynamicWorkflowParams)
241
260
  continue;
261
+ if (tolerant) {
262
+ warnUnknownFlag(token.split("=")[0], known);
263
+ continue;
264
+ }
242
265
  throwUnknownFlag(token.split("=")[0], `--${rawName}`, known);
243
266
  }
244
267
  // Skip a declared value flag's value so `--reason "--x"` is not scanned.
package/dist/cli.js CHANGED
@@ -645,6 +645,24 @@ export function shouldBypassConfigStartup(argv) {
645
645
  const subcommand = args.slice(configIndex + 1).find((arg) => !arg.startsWith("-"));
646
646
  return subcommand === "path";
647
647
  }
648
+ /**
649
+ * Whether `argv` resolves to the top-level `info` command — used by
650
+ * `runCli` (and mirrored in `tests/_helpers/cli.ts`) to scope the startup
651
+ * config read's best-effort fallback to `info` alone. `info` is NOT on
652
+ * {@link shouldBypassConfigStartup}'s allowlist: unlike a bare bypass, it
653
+ * still reads a valid config's `output.format`/`output.detail` like any
654
+ * other command, it just must not be aborted by one it cannot read (see
655
+ * `assembleInfo`'s doc comment, src/commands/sources/info.ts, for why).
656
+ * Every other command reads config exactly as before — a broken config
657
+ * throws here and the command never runs.
658
+ */
659
+ export function isInfoCommand(argv) {
660
+ const userArgs = argv.slice(2);
661
+ const separator = userArgs.indexOf("--");
662
+ const args = separator === -1 ? userArgs : userArgs.slice(0, separator);
663
+ const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
664
+ return (commandIndex >= 0 ? args[commandIndex] : undefined) === "info";
665
+ }
648
666
  // ── Exit codes ──────────────────────────────────────────────────────────────
649
667
  // Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
650
668
  // the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
@@ -968,7 +986,34 @@ async function runCli() {
968
986
  try {
969
987
  applyEarlyStderrFlags(process.argv);
970
988
  const bypassConfig = shouldBypassConfigStartup(process.argv);
971
- initOutputMode(process.argv, bypassConfig ? (DEFAULT_CONFIG.output ?? {}) : (loadConfig().output ?? {}));
989
+ // Off the bypass allowlist, every command reads config here exactly as
990
+ // it always has: an invalid config.json throws, `emitJsonError` reports
991
+ // it, and the command never runs — no side effect of its own body ever
992
+ // happens (a lock taken, a network call made, a database opened
993
+ // read-write). `akm info` is the ONE exception (see `assembleInfo`'s
994
+ // doc comment, src/commands/sources/info.ts): only ITS read is
995
+ // best-effort, falling back to `DEFAULT_CONFIG.output` instead of
996
+ // throwing. Scoped narrowly on purpose — an earlier version of this fix
997
+ // made the read best-effort for every command, which silently changed
998
+ // outcomes across the CLI (some commands that should refuse at exit 78
999
+ // ran anyway; `health`/`index`/`config set`/`feedback` still failed,
1000
+ // but only after already taking a lock, opening a database read-write,
1001
+ // or making a network call).
1002
+ let outputDefaults = DEFAULT_CONFIG.output ?? {};
1003
+ if (!bypassConfig) {
1004
+ if (isInfoCommand(process.argv)) {
1005
+ try {
1006
+ outputDefaults = loadConfig().output ?? {};
1007
+ }
1008
+ catch {
1009
+ outputDefaults = DEFAULT_CONFIG.output ?? {};
1010
+ }
1011
+ }
1012
+ else {
1013
+ outputDefaults = loadConfig().output ?? {};
1014
+ }
1015
+ }
1016
+ initOutputMode(process.argv, outputDefaults);
972
1017
  }
973
1018
  catch (error) {
974
1019
  emitJsonError(error);
@@ -25,7 +25,7 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { MEMORY_ARCHIVE_REL } from "../../core/asset/memory-archive.js";
27
27
  import { toPosix } from "../../core/common.js";
28
- import { isGitBackedStash, tryListGitChangedPaths, tryListGitTrackedPaths, tryListGitUnverifiablePaths, } from "../../sources/providers/git-stash.js";
28
+ import { checkGitPathSafety, isGitBackedStash } from "../../sources/providers/git-stash.js";
29
29
  import { MAX_WALK_ENTRIES, sizeOfPath } from "./data-dir-usage.js";
30
30
  /**
31
31
  * Build the `memory-cleanup-archive` advisory, or `undefined` when there is
@@ -52,23 +52,17 @@ export function collectArchiveUsageAdvisory(stashDir) {
52
52
  }
53
53
  // Git-backed: the SAME three checks purgeGracedArchive runs (B1, G10) —
54
54
  // computed once here, not per file, and reused via `onFile` below instead
55
- // of a second walk of the same tree.
56
- const dirtyQuery = tryListGitChangedPaths(stashDir);
57
- const trackedQuery = tryListGitTrackedPaths(stashDir, MEMORY_ARCHIVE_REL);
58
- const unverifiableQuery = tryListGitUnverifiablePaths(stashDir, MEMORY_ARCHIVE_REL);
59
- // A failed git check here fails the same way purgeGracedArchive's own
60
- // sweep would: nothing in the archive can be proven purgeable, so every
61
- // byte counts as unpurgeable rather than guessing.
62
- const gitStateKnown = dirtyQuery.ok && trackedQuery.ok && unverifiableQuery.ok;
63
- const dirty = new Set(dirtyQuery.paths);
64
- const tracked = new Set(trackedQuery.paths);
65
- const unverifiable = new Set(unverifiableQuery.paths);
55
+ // of a second walk of the same tree. A failed git check here fails the
56
+ // same way purgeGracedArchive's own sweep would: nothing in the archive
57
+ // can be proven purgeable, so every byte counts as unpurgeable rather
58
+ // than guessing (`checkGitPathSafety`'s `isSafe` is always `false` when
59
+ // `ok` is `false`).
60
+ const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL);
66
61
  let unpurgeableFiles = 0;
67
62
  let unpurgeableBytes = 0;
68
63
  const usage = sizeOfPath(archiveRoot, { remaining: MAX_WALK_ENTRIES }, (filePath, bytes) => {
69
64
  const key = toPosix(path.relative(stashDir, filePath));
70
- const safe = gitStateKnown && tracked.has(key) && !dirty.has(key) && !unverifiable.has(key);
71
- if (!safe) {
65
+ if (!gitSafety.isSafe(key)) {
72
66
  unpurgeableFiles++;
73
67
  unpurgeableBytes += bytes;
74
68
  }
@@ -92,7 +86,7 @@ export function collectArchiveUsageAdvisory(stashDir) {
92
86
  truncated: usage.truncated,
93
87
  unpurgeableFiles,
94
88
  unpurgeableBytes,
95
- gitStateKnown,
89
+ gitStateKnown: gitSafety.ok,
96
90
  },
97
91
  };
98
92
  }
@@ -888,7 +888,7 @@ export const HEALTH_CHECKS = [
888
888
  // R0: nothing looked at state.db's own SQLite-level integrity before
889
889
  // this — the round-trip probe above only proves one row can be appended
890
890
  // and read back, which stays true on a database that fails
891
- // `PRAGMA quick_check` elsewhere (corrupt indexes, out-of-order rowids).
891
+ // `PRAGMA integrity_check` elsewhere (corrupt indexes, out-of-order rowids).
892
892
  // Also reports the freelist ratio (fraction of pages VACUUM could
893
893
  // reclaim) so a bloated-but-uncorrupted file is visible as a warning
894
894
  // rather than silence.
@@ -904,8 +904,8 @@ export const HEALTH_CHECKS = [
904
904
  kind: "deterministic",
905
905
  status: "fail",
906
906
  confidence: "high",
907
- message: `state.db failed PRAGMA quick_check: ${detail}. Repair: back up state.db, then run ` +
908
- `sqlite3 state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes quick_check, stop ` +
907
+ message: `state.db failed PRAGMA integrity_check: ${detail}. Repair: back up state.db, then run ` +
908
+ `sqlite3 -readonly state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes integrity_check, stop ` +
909
909
  "every akm process, then delete state.db-wal and state.db-shm before swapping state.new.db in as " +
910
910
  "state.db — a leftover WAL from the OLD database is replayed onto the new one and corrupts it.",
911
911
  evidence: { path: ctx.stateDbPath, lines, freelistRatio },
@@ -917,7 +917,7 @@ export const HEALTH_CHECKS = [
917
917
  kind: "deterministic",
918
918
  status: "fail",
919
919
  confidence: "high",
920
- message: `state.db passed PRAGMA quick_check, but reading its freelist/page-count failed: ${freelistError}.`,
920
+ message: `state.db passed PRAGMA integrity_check, but reading its freelist/page-count failed: ${freelistError}.`,
921
921
  evidence: { path: ctx.stateDbPath, lines, freelistError },
922
922
  };
923
923
  }
@@ -928,8 +928,8 @@ export const HEALTH_CHECKS = [
928
928
  status: freelistWarn ? "warn" : "pass",
929
929
  confidence: "high",
930
930
  message: freelistWarn
931
- ? `state.db passed PRAGMA quick_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
- : "state.db passed PRAGMA quick_check.",
931
+ ? `state.db passed PRAGMA integrity_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
932
+ : "state.db passed PRAGMA integrity_check.",
933
933
  evidence: {
934
934
  path: ctx.stateDbPath,
935
935
  freelistCount: ctx.stateDbFreelist.freelistCount,
@@ -53,6 +53,25 @@ export function countAgentFailureReasons(agentFailures) {
53
53
  }
54
54
  return counts;
55
55
  }
56
+ /**
57
+ * Decode one `improve_runs.result_json` envelope, warning once per row on
58
+ * failure (mirrors {@link taskFailureDetail}'s handling of the analogous
59
+ * `task_history` case) and returning `undefined` instead of throwing.
60
+ * Callers count the `undefined` case themselves (`resultRows.skipped.invalid`
61
+ * / `resultStatus: "invalid"`) so a decode failure is never silent — the
62
+ * warning names *why* (corrupt data, or a decoder too strict for a shape an
63
+ * older release legitimately wrote), the counters say *how many*.
64
+ */
65
+ function decodeImproveResultRow(row) {
66
+ try {
67
+ return decodeImproveResult(row.result_json).envelope;
68
+ }
69
+ catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ console.warn(`[akm] Skipping unparseable improve_runs row in health metrics (id=${row.id}, started_at=${row.started_at}): ${message}`);
72
+ return undefined;
73
+ }
74
+ }
56
75
  /** A zeroed accumulator — also what health reports when it could not read state.db at all (#791). */
57
76
  export function emptyImproveMetrics() {
58
77
  return {
@@ -273,11 +292,8 @@ export function summarizeImproveRuns(db, since, until) {
273
292
  // newest complete run's snapshot (current state) — not a sum across runs.
274
293
  let latest;
275
294
  for (const row of rows) {
276
- let result;
277
- try {
278
- result = decodeImproveResult(row.result_json).envelope;
279
- }
280
- catch {
295
+ const result = decodeImproveResultRow(row);
296
+ if (!result) {
281
297
  resultRows.skipped.invalid += 1;
282
298
  continue;
283
299
  }
@@ -298,13 +314,10 @@ export function summarizeImproveRuns(db, since, until) {
298
314
  }
299
315
  /** Project an improve_runs row + wall time + task attribution into one {@link ImproveRunSummary}. */
300
316
  export function projectImproveRunSummary(row, wallTimeMs, taskId) {
301
- let result = {};
302
- let resultStatus = "invalid";
303
- try {
304
- result = decodeImproveResult(row.result_json).envelope;
305
- resultStatus = "valid";
306
- }
307
- catch {
317
+ const decoded = decodeImproveResultRow(row);
318
+ const result = decoded ?? {};
319
+ const resultStatus = decoded ? "valid" : "invalid";
320
+ if (!decoded) {
308
321
  // Keep the persisted row visible in per-run output, but do not project its
309
322
  // unknown payload or admit its duration to result-derived denominators.
310
323
  wallTimeMs = 0;
@@ -17,7 +17,7 @@ import { countImproveRunsSince } from "../storage/repositories/improve-runs-repo
17
17
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
18
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
19
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
20
- import { getStateDbFreelistInfo, runStateDbQuickCheck } from "../storage/state-db-integrity.js";
20
+ import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
21
21
  import { pkgVersion } from "../version.js";
22
22
  import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
23
23
  import { HEALTH_CHECKS, probeActiveImproveStrategy, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
@@ -182,10 +182,10 @@ function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
182
182
  const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
183
183
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
184
184
  const probe = probeStateDbRoundTrip(stateDbPath);
185
- // R0: read-only, independent of the round-trip probe above — quick_check
185
+ // R0: read-only, independent of the round-trip probe above — integrity_check
186
186
  // catches corruption a successful append/read cannot (out-of-order rowids,
187
187
  // bad index entry counts), and the freelist reading is purely informational.
188
- const stateDbIntegrity = runStateDbQuickCheck(stateDbPath);
188
+ const stateDbIntegrity = runStateDbIntegrityCheck(stateDbPath);
189
189
  const stateDbFreelist = getStateDbFreelistInfo(stateDbPath);
190
190
  // D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
191
191
  // row is the agent/LLM arm; an unmarked "command" row is the legacy
@@ -635,7 +635,7 @@ seams = {}) {
635
635
  // also be judged as part of another until that decision resolves.
636
636
  const pendingRetireRefs = new Set();
637
637
  try {
638
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
638
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, opts.proposalsCtx)) {
639
639
  if (!isRetireProposal(p))
640
640
  continue;
641
641
  pendingRetireRefs.add(stripBundle(p.ref));
@@ -657,7 +657,7 @@ seams = {}) {
657
657
  const rejectedPairKeys = new Set();
658
658
  try {
659
659
  for (const status of ["rejected", "reverted"]) {
660
- for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true })) {
660
+ for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, opts.proposalsCtx)) {
661
661
  if (!isRetireProposal(p) || !p.retirement)
662
662
  continue;
663
663
  rejectedPairKeys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
@@ -222,10 +222,10 @@ function injectRandomClusterMembers(memories, profile, warnings) {
222
222
  return out;
223
223
  }
224
224
  /** Body hashes of pending consolidate proposals, so the prompt can mark memories already queued. */
225
- function loadPendingConsolidateProposalHashes(stashDir) {
225
+ function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
226
226
  const hashes = new Set();
227
227
  try {
228
- for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
228
+ for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, proposalsCtx)) {
229
229
  if (p.source !== "consolidate")
230
230
  continue;
231
231
  try {
@@ -622,7 +622,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
622
622
  assertRunnerCredentials(llmRunner);
623
623
  const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
624
624
  const chunks = slice(injectRandomClusterMembers(ordered, opts.improveProfile, warnings));
625
- const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
625
+ const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir, opts.proposalsCtx);
626
626
  warn(`[consolidate] ${budgeted.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
627
627
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
628
628
  const planned = await judgeConsolidationChunks({
@@ -799,7 +799,7 @@ function readDistillFeedback(run) {
799
799
  }
800
800
  /** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
801
801
  async function buildDistillMessages(run, feedback, kind, outputRef) {
802
- const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx);
802
+ const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
803
803
  // CLS interleaving (default off): show related lessons so the model does not overwrite them.
804
804
  const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
805
805
  let clsContext = "";
@@ -12,7 +12,7 @@ import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
12
12
  import { warn } from "../../../core/warn.js";
13
13
  import { recordWrittenPath } from "../../../core/write-provenance.js";
14
14
  import { walkMarkdownFiles } from "../../../indexer/walk/walker.js";
15
- import { isGitBackedStash, tryListGitChangedPaths, tryListGitTrackedPaths, tryListGitUnverifiablePaths, } from "../../../sources/providers/git-stash.js";
15
+ import { checkGitPathSafety, isGitBackedStash } from "../../../sources/providers/git-stash.js";
16
16
  import { contentHash } from "../content-hash.js";
17
17
  import { isDerivedMemory, memoryIdentityRef, parseMemoryName, resolveParentRef } from "./derived-ref.js";
18
18
  export function analyzeMemoryCleanup(stashDir, options = {}) {
@@ -665,20 +665,16 @@ export function purgeGracedArchive(stashDir, now = new Date()) {
665
665
  // sweep rather than silently trusting whichever check happened to
666
666
  // succeed — a `dirty`/`unverifiable` set that came back empty ONLY
667
667
  // because the call failed must never read as "nothing to protect".
668
- const dirtyQuery = tryListGitChangedPaths(stashDir);
669
- const trackedQuery = tryListGitTrackedPaths(stashDir, MEMORY_ARCHIVE_REL);
670
- const unverifiableQuery = tryListGitUnverifiablePaths(stashDir, MEMORY_ARCHIVE_REL);
671
- if (!dirtyQuery.ok || !trackedQuery.ok || !unverifiableQuery.ok) {
668
+ // G10: assume-unchanged / skip-worktree files never show up as dirty even
669
+ // when genuinely modified — `checkGitPathSafety` treats them the same as
670
+ // "not tracked" below, so such a file (and its whole retirement) is left
671
+ // for a later sweep.
672
+ const gitSafety = checkGitPathSafety(stashDir, MEMORY_ARCHIVE_REL);
673
+ if (!gitSafety.ok) {
672
674
  warn(`[improve] archive purge: skipped this sweep — could not determine the archive's git state at ${stashDir} ` +
673
675
  "(git status/ls-files failed); nothing was purged.");
674
676
  return EMPTY_ARCHIVE_PURGE_RESULT;
675
677
  }
676
- const dirty = new Set(dirtyQuery.paths);
677
- const tracked = new Set(trackedQuery.paths);
678
- // G10: assume-unchanged / skip-worktree files never show up as dirty even
679
- // when genuinely modified — treated the same as "not tracked" below, so
680
- // such a file (and its whole retirement) is left for a later sweep.
681
- const unverifiable = new Set(unverifiableQuery.paths);
682
678
  let purgedDirs = 0;
683
679
  let purgedFiles = 0;
684
680
  for (const entry of entries) {
@@ -705,10 +701,7 @@ export function purgeGracedArchive(stashDir, now = new Date()) {
705
701
  if (!Number.isFinite(retiredMs) || retiredMs >= cutoffMs)
706
702
  continue; // "more than" the grace period — exactly at it is not enough
707
703
  const allFiles = listFilesRecursive(dir); // tombstone included — the whole entry must be a clean, committed unit
708
- const isSafeToPurge = allFiles.every((filePath) => {
709
- const key = toPosix(path.relative(stashDir, filePath));
710
- return tracked.has(key) && !dirty.has(key) && !unverifiable.has(key);
711
- });
704
+ const isSafeToPurge = allFiles.every((filePath) => gitSafety.isSafe(toPosix(path.relative(stashDir, filePath))));
712
705
  if (!isSafeToPurge)
713
706
  continue; // untracked, modified, or unverifiable entry — skip the whole directory this sweep (B1, G10)
714
707
  let children;
@@ -209,6 +209,8 @@ async function runConsolidationPass(args) {
209
209
  maxChunkSize: processConfig?.maxChunkSize,
210
210
  signal: args.budgetSignal,
211
211
  p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
212
+ // Its read-only proposal lookups go through the run's own state.db handle.
213
+ ...(eventsCtx?.db ? { proposalsCtx: { db: eventsCtx.db } } : {}),
212
214
  }));
213
215
  }
214
216
  return { consolidation, plan: planned.plan };
@@ -761,7 +761,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
761
761
  relatedLessons: options.ref && parsedRef
762
762
  ? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
763
763
  : [],
764
- rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx),
764
+ rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
765
765
  standardsContext: resolveStandardsContext(options.ref, stash),
766
766
  };
767
767
  }
@@ -100,12 +100,14 @@ export const MAX_REJECTED_PROPOSALS = 3;
100
100
  /**
101
101
  * Reflexion context: the newest reviewer rejections for `ref`. Procedural
102
102
  * refusals (expiry, stale target, missing asset) are not judgements on the
103
- * content and are left out. Reads never create state.db.
103
+ * content and are left out. Reads never create state.db, and an improve run's
104
+ * live connection (`eventsCtx.db`) is read through, not copied.
104
105
  */
105
- export function rejectedProposalContext(stash, ref, ctx) {
106
+ export function rejectedProposalContext(stash, ref, ctx, eventsCtx) {
106
107
  if (!ref)
107
108
  return [];
108
- return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, ctx)
109
+ const proposalsCtx = eventsCtx?.db ? { ...ctx, db: eventsCtx.db } : ctx;
110
+ return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
109
111
  .filter((p) => !isProceduralRejection(p))
110
112
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
111
113
  .slice(0, MAX_REJECTED_PROPOSALS)
@@ -373,9 +373,13 @@ export function listProposals(stashDir, options = {}, ctx) {
373
373
  /**
374
374
  * {@link listProposals} on a read snapshot that never creates or migrates
375
375
  * state.db: prompt building runs before the first dispatch has validated its
376
- * credentials, and a missing store is simply empty.
376
+ * credentials, and a missing store is simply empty. A caller that already
377
+ * holds a live connection (`ctx.db`) reads through it: a snapshot would copy
378
+ * the whole database for nothing.
377
379
  */
378
380
  export function listProposalsReadOnly(stashDir, options = {}, ctx) {
381
+ if (ctx?.db)
382
+ return queryProposals(ctx.db, stashDir, options);
379
383
  const dbPath = ctx?.dbPath ?? getStateDbPath();
380
384
  if (!fs.existsSync(dbPath))
381
385
  return [];