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.
- package/CHANGELOG.md +296 -2025
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +46 -1
- package/dist/commands/health/archive-usage.js +9 -15
- package/dist/commands/health/checks.js +6 -6
- package/dist/commands/health/improve-metrics.js +25 -12
- package/dist/commands/health.js +3 -3
- package/dist/commands/improve/consolidate/pair-pass.js +2 -2
- package/dist/commands/improve/consolidate.js +3 -3
- package/dist/commands/improve/distill.js +1 -1
- package/dist/commands/improve/memory/memory-improve.js +8 -15
- package/dist/commands/improve/preparation.js +2 -0
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/stage.js +5 -3
- package/dist/commands/proposal/repository.js +5 -1
- package/dist/commands/sources/info.js +122 -18
- package/dist/commands/sources/stash-cli.js +21 -1
- package/dist/core/improve-result.js +6 -1
- package/dist/output/text/command-format.js +9 -0
- package/dist/scripts/akm-migrate-node.js +29 -2
- package/dist/scripts/akm-migrate.js +29 -2
- package/dist/sources/providers/git-stash.js +28 -0
- package/dist/storage/repositories/index-connection.js +5 -2
- package/dist/storage/sqlite-read-snapshot.js +46 -2
- package/dist/storage/state-db-integrity.js +12 -9
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +130 -41
- package/docs/migration/release-notes/README.md +7 -0
- package/docs/migration/v0.7-to-v0.8.md +2 -2
- package/docs/reference/cli.md +11 -2
- package/docs/reference/data-and-telemetry.md +5 -2
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
908
|
-
`sqlite3 state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes
|
|
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
|
|
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
|
|
932
|
-
: "state.db passed PRAGMA
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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;
|
package/dist/commands/health.js
CHANGED
|
@@ -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,
|
|
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 —
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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 [];
|