akm-cli 0.8.1 → 0.8.3

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 CHANGED
@@ -4,6 +4,97 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.8.3] - 2026-06-08
8
+
9
+ ### Fixed
10
+
11
+ - **`improve.lock` leaked on signal death (cron timeout).** The improve
12
+ SIGTERM/SIGINT/SIGHUP handler calls `process.exit()`, which skips `finally`
13
+ blocks — so the `finally` that releases `improve.lock` never ran, and every
14
+ timed-out cron run leaked the lock sentinel. (It wasn't a permanent deadlock
15
+ only because the next run reclaims a dead-PID lock, a path that PID reuse can
16
+ defeat.) The lock is now released from a `process.on("exit", …)` handler
17
+ registered at acquire time (exit handlers DO run on `process.exit()`), via a
18
+ new ownership-checked `releaseLockIfOwned(path, pid)` so a backstop release can
19
+ never delete a different run's lock. This generalizes to the budget watchdog
20
+ and any future exit path.
21
+ - **`quick` profile was not quick.** It was documented "Reflect-only" but did
22
+ not disable the session-`extract` process (which is default-ON), so a `quick`
23
+ run processed the entire unindexed-session backlog (~40 min) — guaranteeing a
24
+ 5-minute cron timeout → SIGTERM → the lock leak above, every run. `quick` now
25
+ explicitly sets `processes.extract.enabled: false`.
26
+
27
+ ## [0.8.2] - 2026-06-05
28
+
29
+ ### Added
30
+
31
+ - **LM Studio auto-detection in setup wizard** — `akm setup` now probes
32
+ `localhost:1234/v1/models` at startup and, when the server is running, pre-fills
33
+ the LLM backend with the active model list, mirroring the existing Ollama detection
34
+ flow (#522).
35
+ - **Agent harness config import** — `akm setup` detects installed AI coding harnesses
36
+ (currently Claude Code and OpenCode) and pre-populates LLM provider, model, and
37
+ base-URL fields from the harness configuration. The importer registry
38
+ (`HARNESS_CONFIG_IMPORTERS`) makes adding future harnesses a single append (#523).
39
+ API key *values* are never read or stored — only the environment variable name is
40
+ imported.
41
+ - **Registry-driven stash selection** — the "Add Sources" step now fetches available
42
+ stashes from the official AKM registry at startup. `DEFAULT_SELECTED_STASH_IDS`
43
+ in `src/setup/registry-stash-loader.ts` is the single edit point for changing
44
+ which stashes are pre-checked. Falls back to a hardcoded list on network error (#520).
45
+ - **`improve.autoAccept.{promoted,validationFailed}` health metrics** — auto-accepted
46
+ proposals that pass the confidence threshold but fail validation (truncated
47
+ description, invalid frontmatter) are now counted as `gateAutoAcceptFailedCount`
48
+ in the improve result envelope and surfaced as `improve.autoAccept.validationFailed`
49
+ in `akm health` reports.
50
+ - **`auto-accept-validation` health advisory** — heuristic advisory that warns when
51
+ `validationFailed > 0` so malformed proposals are visible before they pile up in
52
+ the queue.
53
+
54
+ ### Fixed
55
+
56
+ - **`akm-improve` tasks recorded as failed on budget exhaustion** — the budget
57
+ exhaustion timer called `process.exit(1)`, causing every budget-limited run to be
58
+ recorded as a task failure. Changed to `process.exit(0)`; budget exhaustion is a
59
+ normal exit condition.
60
+ - **`improve_runs.started_at` always equal to `completed_at`** — `writeImproveResultFile`
61
+ was called at end-of-run, so `new Date()` captured the completion time and both
62
+ columns held the same value (649/661 real runs affected, regressed ~May 26).
63
+ `started_at` now uses the timestamp captured at process launch, passed in from the
64
+ CLI entry point. A regex-based fallback decodes the timestamp embedded in the run ID
65
+ for any call site that does not supply an explicit value (#524).
66
+ - **`akm-health-report` task fails on transient DNS errors** — the Discord webhook
67
+ script caught `HTTPError` but not the parent `URLError`, so DNS blips caused the
68
+ task runner to record the health report as failed. `URLError` is now caught and
69
+ logged as a warning with a clean exit.
70
+
71
+ ### Added
72
+
73
+ - **Stash `.meta/` convention** — a stash may carry an optional, human-authored
74
+ `.meta/` directory at its root for orientation: purpose, key assets, conventions,
75
+ and maintainer info. Surface it on demand with `akm show meta` (the working
76
+ stash's `.meta/index.md`), `akm show meta:<name>` (e.g. `.meta/about.md`), or
77
+ scope it to a specific stash with `akm show <origin>//meta[:<name>]`. Because
78
+ `.meta/` is a dot-directory, the indexer already skips it, so these docs never
79
+ pollute search results — they are direct-read on demand. Owners extend the
80
+ convention by dropping new files (`.meta/about.md`, `.meta/conventions.md`,
81
+ `.meta/license`) with no code changes. `akm init` scaffolds a `.meta/index.md`
82
+ template into newly created stashes.
83
+ - **Default stash skeleton** — `akm init` (and `akm setup`) now copies
84
+ `src/assets/stash-skeleton/` into every newly created stash. Currently ships
85
+ a `README.md` covering what the stash contains and how agents use `akm` to
86
+ access assets. Existing files are never overwritten. Add files to
87
+ `src/assets/stash-skeleton/` to extend what ships with a fresh install.
88
+
89
+ ### Improved
90
+
91
+ - **Setup wizard pre-populates from existing config** — on re-run, `akm setup`
92
+ initialises every prompt default from the current saved configuration so users
93
+ only need to change what has actually changed (#519).
94
+ - **Config backup before every setup write** — `backupExistingConfig()` is now called
95
+ before each `saveConfig` in the setup wizard, ensuring the previous config is always
96
+ recoverable if a wizard run is interrupted (#521).
97
+
7
98
  ## [0.8.1] - 2026-06-05
8
99
 
9
100
  ### Added
@@ -1,10 +1,11 @@
1
1
  {
2
- "description": "Reflect-only pass — no distill, consolidate, memoryInference, or graphExtraction.",
2
+ "description": "Reflect-only pass — no extract, distill, consolidate, memoryInference, or graphExtraction.",
3
3
  "processes": {
4
4
  "reflect": {
5
5
  "enabled": true,
6
6
  "allowedTypes": ["agent", "command", "knowledge", "lesson", "memory", "skill", "wiki", "workflow"]
7
7
  },
8
+ "extract": { "enabled": false },
8
9
  "distill": { "enabled": false },
9
10
  "consolidate": { "enabled": false },
10
11
  "memoryInference": { "enabled": false },
@@ -0,0 +1,76 @@
1
+ # AKM Stash
2
+
3
+ This is an **AKM stash** — a structured knowledge repository that stores reusable
4
+ assets for you and your AI agents. AKM (Agent Knowledge Management) indexes, ranks,
5
+ and surfaces these assets at the right moment during coding sessions, improving
6
+ consistency and reducing repeated context-setting.
7
+
8
+ ## What this stash contains
9
+
10
+ | Directory | Asset type | Purpose |
11
+ |-----------|-----------|---------|
12
+ | `skills/` | Skills | Step-by-step instructions agents follow for specific tasks |
13
+ | `knowledge/` | Knowledge | Reference documents, guides, architecture notes |
14
+ | `memories/` | Memories | Persistent facts and preferences learned over time |
15
+ | `commands/` | Commands | Parameterised prompt templates for common workflows |
16
+ | `agents/` | Agents | Agent definitions with system prompts and tool policies |
17
+ | `workflows/` | Workflows | Multi-step orchestration sequences |
18
+ | `tasks/` | Tasks | Scheduled or on-demand automation tasks |
19
+ | `lessons/` | Lessons | Durable lessons extracted from past sessions |
20
+
21
+ Add your own assets to any of these directories. AKM will index them automatically
22
+ on the next `akm index` run (or when the background improve pipeline picks them up).
23
+
24
+ ## For agents: how to access this stash
25
+
26
+ All assets in this stash are searchable via the `akm` CLI. Use these commands to
27
+ find and read assets during a session:
28
+
29
+ ```sh
30
+ # Find assets relevant to your current task (recommended first step)
31
+ akm curate "<task description including project name>"
32
+
33
+ # Full-text + semantic search
34
+ akm search "<query>"
35
+ akm search "<query>" --type skill
36
+ akm search "<query>" --type knowledge
37
+
38
+ # Show a specific asset by ref
39
+ akm show skill:<name>
40
+ akm show knowledge:<name>
41
+ akm show memory:<name>
42
+ akm show command:<name>
43
+
44
+ # List available assets by type
45
+ akm list --type skill
46
+ akm list --type knowledge
47
+ ```
48
+
49
+ ### Recording feedback and new knowledge
50
+
51
+ ```sh
52
+ # Mark an asset as helpful (improves future rankings)
53
+ akm feedback <ref> --positive
54
+
55
+ # Capture a durable lesson or memory from the current session
56
+ akm remember "<fact or lesson>"
57
+ ```
58
+
59
+ ### Improving and maintaining the stash
60
+
61
+ ```sh
62
+ # Run the self-improvement pipeline (extract, reflect, consolidate)
63
+ akm improve
64
+
65
+ # Check stash health and pipeline metrics
66
+ akm health
67
+
68
+ # Review pending improvement proposals
69
+ akm proposal list
70
+ akm proposal show <id>
71
+ akm proposal accept <id>
72
+ ```
73
+
74
+ ---
75
+
76
+ *Created by `akm init`. See `akm --help` for full command reference.*
package/dist/cli.js CHANGED
@@ -92,8 +92,8 @@ function resolveEventSource() {
92
92
  }
93
93
  import { resolveImproveProfile } from "./commands/improve-profiles";
94
94
  import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, } from "./commands/proposal";
95
- import { drainProposals } from "./commands/proposal-drain";
96
- import { resolveDrainPolicy } from "./commands/proposal-drain-policies";
95
+ import { drainProposals } from "./commands/proposal/drain";
96
+ import { resolveDrainPolicy } from "./commands/proposal/drain-policies";
97
97
  import { akmPropose } from "./commands/propose";
98
98
  import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
99
99
  import { checkForUpdate, performUpgrade } from "./commands/self-update";
@@ -107,6 +107,7 @@ import { DEFAULT_CONFIG, loadConfig, loadUserConfig, resolveConfiguredSources, s
107
107
  import { ConfigError, NotFoundError, UsageError } from "./core/errors";
108
108
  import { appendEvent } from "./core/events";
109
109
  import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
110
+ import { parseMetaRef } from "./core/stash-meta";
110
111
  import { plainize } from "./core/tty";
111
112
  import { clearLogFile, info, isQuiet, isVerbose, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
112
113
  import { closeDatabase, openExistingDatabase } from "./indexer/db";
@@ -872,7 +873,11 @@ const showCommand = defineCommand({
872
873
  output("proposal-show", result);
873
874
  return;
874
875
  }
875
- parseAssetRef(args.ref);
876
+ // `[origin//]meta[:name]` targets the stash `.meta/` convention, which is
877
+ // not a typed asset ref — skip ref validation and let akmShowUnified
878
+ // direct-read it. (`parseAssetRef` would reject the non-type `meta`.)
879
+ if (!parseMetaRef(args.ref))
880
+ parseAssetRef(args.ref);
876
881
  // The knowledge-view positional syntax (`akm show knowledge:foo section "Auth"`)
877
882
  // is rewritten to `--akmView` / `--akmHeading` / `--akmStart` / `--akmEnd`
878
883
  // by `normalizeShowArgv` before citty parses argv. We read those values
@@ -170,7 +170,7 @@ export function isHotCapturedMemory(filePath) {
170
170
  return false;
171
171
  }
172
172
  }
173
- export function consolidateGuardStatus(filePath) {
173
+ function consolidateGuardStatus(filePath) {
174
174
  if (!fs.existsSync(filePath))
175
175
  return "missing";
176
176
  let content;
@@ -395,7 +395,7 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
395
395
  * trimmed). Empty set on any read/parse error — fail-safe to "annotate
396
396
  * nothing" so the LLM still proposes, just slightly more wastefully.
397
397
  */
398
- export function loadPendingConsolidateProposalHashes(stashDir) {
398
+ function loadPendingConsolidateProposalHashes(stashDir) {
399
399
  const hashes = new Set();
400
400
  try {
401
401
  const pending = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
@@ -1924,7 +1924,7 @@ export function normalizeUpdatedField(fm) {
1924
1924
  * Two slugs that normalise to the same string are considered the same asset
1925
1925
  * for dedup purposes even if they don't share an exact ref.
1926
1926
  */
1927
- export function normalizeSlugForDedup(ref) {
1927
+ function normalizeSlugForDedup(ref) {
1928
1928
  const slug = ref.replace(/^[^:]+:/, "");
1929
1929
  const monthRe = /(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i;
1930
1930
  const tokens = slug
@@ -1962,7 +1962,7 @@ export function normalizeSlugForDedup(ref) {
1962
1962
  * improve invocation — a different concern from the cross-run content-hash
1963
1963
  * dedup, and cheap (no embeddings, no DB query).
1964
1964
  */
1965
- export async function checkPreEmitDedup(opts) {
1965
+ async function checkPreEmitDedup(opts) {
1966
1966
  const normCandidate = normalizeSlugForDedup(opts.candidateRef);
1967
1967
  // Pending consolidate proposals (slug match) — within the same improve run.
1968
1968
  const pendingConsolidate = listProposals(opts.stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
@@ -71,6 +71,7 @@ function createUnknownImproveMetrics() {
71
71
  graphExtraction: 0,
72
72
  error: 0,
73
73
  },
74
+ autoAccept: { promoted: 0, validationFailed: 0 },
74
75
  reflectsWithErrorContext: 0,
75
76
  coverageGapCount: 0,
76
77
  evalCasesWritten: 0,
@@ -293,6 +294,8 @@ function projectRunMetrics(result) {
293
294
  }
294
295
  }
295
296
  }
297
+ metrics.autoAccept.promoted += toFiniteNumber(result.gateAutoAcceptedCount);
298
+ metrics.autoAccept.validationFailed += toFiniteNumber(result.gateAutoAcceptFailedCount);
296
299
  metrics.reflectsWithErrorContext += toFiniteNumber(result.reflectsWithErrorContext);
297
300
  if (Array.isArray(result.coverageGaps))
298
301
  metrics.coverageGapCount += result.coverageGaps.length;
@@ -481,6 +484,8 @@ function mergeImproveMetrics(dst, src) {
481
484
  dst.actions.memoryInference += src.actions.memoryInference;
482
485
  dst.actions.graphExtraction += src.actions.graphExtraction;
483
486
  dst.actions.error += src.actions.error;
487
+ dst.autoAccept.promoted += src.autoAccept.promoted;
488
+ dst.autoAccept.validationFailed += src.autoAccept.validationFailed;
484
489
  dst.reflectsWithErrorContext += src.reflectsWithErrorContext;
485
490
  dst.coverageGapCount += src.coverageGapCount;
486
491
  dst.evalCasesWritten += src.evalCasesWritten;
@@ -923,6 +928,8 @@ const INTERESTING_DELTA_PATHS = [
923
928
  "improve.graphExtraction.failures",
924
929
  "improve.sessionExtraction.sessionsScanned",
925
930
  "improve.sessionExtraction.proposalsCreated",
931
+ "improve.autoAccept.promoted",
932
+ "improve.autoAccept.validationFailed",
926
933
  "improve.wallTime.medianMs",
927
934
  "improve.wallTime.p95Ms",
928
935
  ];
@@ -1183,6 +1190,19 @@ export function akmHealth(options = {}) {
1183
1190
  durationMs: sx.durationMs,
1184
1191
  },
1185
1192
  });
1193
+ const aa = improveSummary.autoAccept;
1194
+ advisories.push({
1195
+ name: "auto-accept-validation",
1196
+ kind: "heuristic",
1197
+ status: aa.validationFailed > 0 ? "warn" : "pass",
1198
+ confidence: aa.promoted + aa.validationFailed > 0 ? "high" : "low",
1199
+ message: aa.validationFailed > 0
1200
+ ? `${aa.validationFailed} proposal(s) passed confidence threshold but failed auto-accept validation (truncated description, invalid frontmatter, etc.) — they remain in the queue for manual review.`
1201
+ : aa.promoted > 0
1202
+ ? `Auto-accept healthy: ${aa.promoted} proposal(s) promoted, 0 validation failures.`
1203
+ : "Auto-accept gate did not run (disabled or no proposals above threshold).",
1204
+ evidence: { promoted: aa.promoted, validationFailed: aa.validationFailed },
1205
+ });
1186
1206
  const metrics = {
1187
1207
  taskFailRate: roundRate(taskFailRate),
1188
1208
  agentFailureRate: roundRate(agentFailureRate),
@@ -217,7 +217,7 @@ export const improveCommand = defineCommand({
217
217
  runRecorded = true; // Suppress any late signal-handler write — the success path owns the row now.
218
218
  if (primaryStashDir) {
219
219
  try {
220
- writeImproveResultFile(primaryStashDir, runId, improveResult);
220
+ writeImproveResultFile(primaryStashDir, runId, improveResult, startedAtIso);
221
221
  }
222
222
  catch (err) {
223
223
  // Stderr warning on the failure path is preferable to crashing
@@ -73,14 +73,19 @@ export function relativeImproveResultPath(runId) {
73
73
  * (closes the dry-run/real-run artifact-trap recorded in MEMORY.md
74
74
  * `feedback_akm_dryrun_artifact_trap`).
75
75
  */
76
- export function writeImproveResultFile(stashDir, runId, result) {
76
+ export function writeImproveResultFile(stashDir, runId, result, startedAt) {
77
77
  const db = openStateDatabase();
78
78
  try {
79
- const startedAt = new Date().toISOString();
79
+ const completedAt = new Date().toISOString();
80
+ // startedAt is the ISO timestamp captured at process launch (passed from the
81
+ // CLI entry point). If omitted, fall back to the run-id's embedded timestamp
82
+ // so started_at != completed_at even on older call sites.
83
+ const resolvedStartedAt = startedAt ??
84
+ runId.slice(0, 24).replace(/^(\d{4}-\d{2}-\d{2}T)(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "$1$2:$3:$4.$5Z");
80
85
  recordImproveRun(db, {
81
86
  id: runId,
82
- startedAt,
83
- completedAt: startedAt,
87
+ startedAt: resolvedStartedAt,
88
+ completedAt,
84
89
  stashDir,
85
90
  dryRun: Boolean(result.dryRun),
86
91
  profile: null,
@@ -8,7 +8,7 @@ import { daysToMs, isAssetType } from "../core/common";
8
8
  import { getDefaultLlmConfig, loadConfig } from "../core/config";
9
9
  import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../core/errors";
10
10
  import { appendEvent, readEvents } from "../core/events";
11
- import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock";
11
+ import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../core/file-lock";
12
12
  import { parseFrontmatter } from "../core/frontmatter";
13
13
  import { detectAndWriteContradictions } from "../core/memory-contradiction-detect";
14
14
  import { analyzeMemoryCleanup, applyMemoryCleanup, } from "../core/memory-improve";
@@ -36,8 +36,8 @@ import { akmExtract } from "./extract";
36
36
  import { makeGateConfig, resolveExtractConfidence, runAutoAcceptGate } from "./improve-auto-accept";
37
37
  import { isProfileFilteredForAllPasses, resolveImproveProfile, resolveProcessEnabled, shouldSkipRef, } from "./improve-profiles";
38
38
  import { akmLint } from "./lint/index";
39
- import { drainProposals } from "./proposal-drain";
40
- import { resolveDrainPolicy } from "./proposal-drain-policies";
39
+ import { drainProposals } from "./proposal/drain";
40
+ import { resolveDrainPolicy } from "./proposal/drain-policies";
41
41
  import { akmReflect } from "./reflect";
42
42
  import { runSchemaRepairPass } from "./schema-repair";
43
43
  import { checkDeadUrls } from "./url-checker";
@@ -497,6 +497,17 @@ export async function akmImprove(options = {}) {
497
497
  }
498
498
  lockAcquired = false;
499
499
  };
500
+ // Signal-safe lock release (0.8.3 hotfix). The SIGTERM/SIGINT/SIGHUP handler
501
+ // in improve-cli.ts calls `process.exit()`, which does NOT run the `finally`
502
+ // below that owns lock release — so a cron-timeout SIGTERM leaked
503
+ // `improve.lock` every run. `process.exit()` DOES fire `'exit'` listeners,
504
+ // so we release the lock from one. `releaseLockIfOwned` only unlinks a lock
505
+ // still owned by this PID, so it is safe even if a later run re-acquired it.
506
+ // The listener is removed in the `finally` so the normal path stays single-release
507
+ // and repeated in-process `akmImprove` calls (tests) do not accumulate listeners.
508
+ const releaseLockOnExit = () => {
509
+ releaseLockIfOwned(resolvedLockPath, process.pid);
510
+ };
500
511
  const preEnsureCleanupWarnings = [];
501
512
  let plannedRefs;
502
513
  let memorySummary;
@@ -510,6 +521,9 @@ export async function akmImprove(options = {}) {
510
521
  if (!options.dryRun) {
511
522
  acquireLock();
512
523
  lockAcquired = true;
524
+ // Backstop release on process.exit() (signal handler / budget watchdog),
525
+ // which skips the finally below. Removed in that finally on the normal path.
526
+ process.on("exit", releaseLockOnExit);
513
527
  // Phase 4 triage pre-pass (§7, §13): drain the standing pending backlog
514
528
  // BEFORE ensureIndex so improve generates fresh proposals against a cleared
515
529
  // queue (no `duplicate_pending` collisions) and ensureIndex absorbs triage's
@@ -678,7 +692,8 @@ export async function akmImprove(options = {}) {
678
692
  budgetAbortController.abort("improve budget exhausted");
679
693
  // Grace period: let finally run to release improve.lock, then hard-exit
680
694
  // to prevent the process outliving the task timeout window (lock-cascade fix).
681
- setTimeout(() => process.exit(1), 5_000);
695
+ // Exit 0: budget exhaustion is a normal scheduled-task condition, not an error.
696
+ setTimeout(() => process.exit(0), 5_000);
682
697
  }, budgetMs);
683
698
  // Clear the timer when the run ends to avoid keeping the event loop alive.
684
699
  clearBudgetTimer = () => clearTimeout(budgetTimer);
@@ -729,7 +744,7 @@ export async function akmImprove(options = {}) {
729
744
  rejectedProposalsByRef.set(e.ref, e);
730
745
  }
731
746
  }
732
- const { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount: loopGateCount, } = await runImproveLoopStage({
747
+ const { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount: loopGateCount, gateAutoAcceptFailedCount: loopGateFailedCount, } = await runImproveLoopStage({
733
748
  scope,
734
749
  options,
735
750
  primaryStashDir,
@@ -748,7 +763,7 @@ export async function akmImprove(options = {}) {
748
763
  eventsCtx,
749
764
  improveProfile,
750
765
  });
751
- const { allWarnings, consolidation, deadUrls, memoryInference, graphExtraction, stalenessDetection, maintenanceActions, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, gateAutoAcceptedCount: postLoopGateCount, } = await runImprovePostLoopStage({
766
+ const { allWarnings, consolidation, deadUrls, memoryInference, graphExtraction, stalenessDetection, maintenanceActions, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, gateAutoAcceptedCount: postLoopGateCount, gateAutoAcceptFailedCount: postLoopGateFailedCount, } = await runImprovePostLoopStage({
752
767
  scope,
753
768
  options,
754
769
  primaryStashDir,
@@ -833,6 +848,10 @@ export async function akmImprove(options = {}) {
833
848
  const t = preparation.gateAutoAcceptedCount + loopGateCount + postLoopGateCount;
834
849
  return t > 0 ? { gateAutoAcceptedCount: t } : {};
835
850
  })(),
851
+ ...(() => {
852
+ const f = preparation.gateAutoAcceptFailedCount + loopGateFailedCount + postLoopGateFailedCount;
853
+ return f > 0 ? { gateAutoAcceptFailedCount: f } : {};
854
+ })(),
836
855
  };
837
856
  if (!result.dryRun)
838
857
  emitImproveCompletedEvent(result, {
@@ -917,6 +936,9 @@ export async function akmImprove(options = {}) {
917
936
  catch {
918
937
  // ignore
919
938
  }
939
+ // The normal path released the lock above; drop the process.exit backstop so
940
+ // it does not fire later (or accumulate across repeated in-process calls).
941
+ process.removeListener("exit", releaseLockOnExit);
920
942
  // I1: close the long-lived state.db connection opened at the top of the run.
921
943
  try {
922
944
  eventsDb?.close();
@@ -1066,6 +1088,7 @@ async function runImprovePreparationStage(args) {
1066
1088
  // The extract envelope's own `warnings` field surfaces what went wrong.
1067
1089
  let extractResults;
1068
1090
  let gateAutoAcceptedCount = 0;
1091
+ let gateAutoAcceptFailedCount = 0;
1069
1092
  const extractConfig = options.config ?? loadConfig();
1070
1093
  const extractGateCfg = makeGateConfig("extract", {
1071
1094
  globalThreshold: options.autoAccept,
@@ -1087,12 +1110,16 @@ async function runImprovePreparationStage(args) {
1087
1110
  dryRun: options.dryRun ?? false,
1088
1111
  });
1089
1112
  extractResults.push(result);
1090
- gateAutoAcceptedCount += (await runAutoAcceptGate(primaryStashDir
1091
- ? result.proposals.map((proposalId) => {
1092
- const proposal = getProposal(primaryStashDir, proposalId);
1093
- return { proposalId, confidence: resolveExtractConfidence(proposal) };
1094
- })
1095
- : [], extractGateCfg)).promoted.length;
1113
+ {
1114
+ const gr = await runAutoAcceptGate(primaryStashDir
1115
+ ? result.proposals.map((proposalId) => {
1116
+ const proposal = getProposal(primaryStashDir, proposalId);
1117
+ return { proposalId, confidence: resolveExtractConfidence(proposal) };
1118
+ })
1119
+ : [], extractGateCfg);
1120
+ gateAutoAcceptedCount += gr.promoted.length;
1121
+ gateAutoAcceptFailedCount += gr.failed.length;
1122
+ }
1096
1123
  }
1097
1124
  catch (err) {
1098
1125
  const msg = err instanceof Error ? err.message : String(err);
@@ -1118,7 +1145,9 @@ async function runImprovePreparationStage(args) {
1118
1145
  proposalId: p.id,
1119
1146
  confidence: resolveExtractConfidence(p),
1120
1147
  }));
1121
- gateAutoAcceptedCount += (await runAutoAcceptGate(backlogCandidates, extractGateCfg)).promoted.length;
1148
+ const backlogGr = await runAutoAcceptGate(backlogCandidates, extractGateCfg);
1149
+ gateAutoAcceptedCount += backlogGr.promoted.length;
1150
+ gateAutoAcceptFailedCount += backlogGr.failed.length;
1122
1151
  }
1123
1152
  }
1124
1153
  // eligibleCount = raw pre-filter count (before cooldown/signal/cleanup filters).
@@ -1544,6 +1573,7 @@ async function runImprovePreparationStage(args) {
1544
1573
  recentErrors,
1545
1574
  utilityMap,
1546
1575
  gateAutoAcceptedCount,
1576
+ gateAutoAcceptFailedCount,
1547
1577
  };
1548
1578
  }
1549
1579
  // TODO(refactor): 13 args including `actions`/`recentErrors` mutation channels. Restructure into immutable plan + mutable context objects — deferred to dedicated refactor with isolated testing.
@@ -1627,6 +1657,7 @@ async function runImproveLoopStage(args) {
1627
1657
  ? listProposals(dedupeStashDirForProposals, { status: "pending" }).map((p) => p.ref)
1628
1658
  : []);
1629
1659
  let gateAutoAcceptedCount = 0;
1660
+ let gateAutoAcceptFailedCount = 0;
1630
1661
  const reflectGateCfg = makeGateConfig("reflect", {
1631
1662
  globalThreshold: options.autoAccept,
1632
1663
  dryRun: options.dryRun ?? false,
@@ -1803,7 +1834,9 @@ async function runImproveLoopStage(args) {
1803
1834
  },
1804
1835
  }, eventsCtx);
1805
1836
  if (reflectResult.ok) {
1806
- gateAutoAcceptedCount += (await runAutoAcceptGate([{ proposalId: reflectResult.proposal.id, confidence: reflectResult.proposal.confidence }], reflectGateCfg)).promoted.length;
1837
+ const reflectGr = await runAutoAcceptGate([{ proposalId: reflectResult.proposal.id, confidence: reflectResult.proposal.confidence }], reflectGateCfg);
1838
+ gateAutoAcceptedCount += reflectGr.promoted.length;
1839
+ gateAutoAcceptFailedCount += reflectGr.failed.length;
1807
1840
  }
1808
1841
  } // end else (reflect type/profile check)
1809
1842
  }
@@ -1914,7 +1947,9 @@ async function runImproveLoopStage(args) {
1914
1947
  });
1915
1948
  actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
1916
1949
  if (distillResult.outcome === "queued" && distillResult.proposal) {
1917
- gateAutoAcceptedCount += (await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg)).promoted.length;
1950
+ const distillGr = await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg);
1951
+ gateAutoAcceptedCount += distillGr.promoted.length;
1952
+ gateAutoAcceptFailedCount += distillGr.failed.length;
1918
1953
  }
1919
1954
  if (parsedPlannedRef.type === "memory") {
1920
1955
  const promotedToKnowledge = distillResult.outcome === "queued" && distillResult.proposalKind === "knowledge";
@@ -1987,7 +2022,7 @@ async function runImproveLoopStage(args) {
1987
2022
  completedCount++;
1988
2023
  info(`[improve] ${completedCount}/${loopRefs.length} ${planned.ref}`);
1989
2024
  }
1990
- return { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount };
2025
+ return { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount, gateAutoAcceptFailedCount };
1991
2026
  }
1992
2027
  async function runImprovePostLoopStage(args) {
1993
2028
  const { scope, options, primaryStashDir, actionableRefs, appliedCleanup, cleanupWarnings, memorySummary, memoryRefsForInference, reindexFn, eventsCtx, budgetSignal, improveProfile, } = args;
@@ -2083,6 +2118,7 @@ async function runImprovePostLoopStage(args) {
2083
2118
  durationMs: 0,
2084
2119
  };
2085
2120
  let gateAutoAcceptedCount = 0;
2121
+ let gateAutoAcceptFailedCount = 0;
2086
2122
  const consolidateGateCfg = makeGateConfig("consolidate", {
2087
2123
  globalThreshold: options.autoAccept,
2088
2124
  dryRun: options.dryRun ?? false,
@@ -2121,17 +2157,21 @@ async function runImprovePostLoopStage(args) {
2121
2157
  // still wins because the spread above runs first.
2122
2158
  autoAccept: options.consolidateOptions?.autoAccept ?? options.autoAccept,
2123
2159
  });
2124
- gateAutoAcceptedCount += (await runAutoAcceptGate(consolidation.promoted.map((proposalId) => {
2125
- try {
2126
- if (!primaryStashDir)
2160
+ {
2161
+ const consolidateGr = await runAutoAcceptGate(consolidation.promoted.map((proposalId) => {
2162
+ try {
2163
+ if (!primaryStashDir)
2164
+ return { proposalId, confidence: undefined };
2165
+ const proposal = getProposal(primaryStashDir, proposalId);
2166
+ return { proposalId, confidence: proposal.confidence };
2167
+ }
2168
+ catch {
2127
2169
  return { proposalId, confidence: undefined };
2128
- const proposal = getProposal(primaryStashDir, proposalId);
2129
- return { proposalId, confidence: proposal.confidence };
2130
- }
2131
- catch {
2132
- return { proposalId, confidence: undefined };
2133
- }
2134
- }), consolidateGateCfg)).promoted.length;
2170
+ }
2171
+ }), consolidateGateCfg);
2172
+ gateAutoAcceptedCount += consolidateGr.promoted.length;
2173
+ gateAutoAcceptFailedCount += consolidateGr.failed.length;
2174
+ }
2135
2175
  if (consolidation.processed > 0) {
2136
2176
  appendEvent({
2137
2177
  eventType: "consolidate_completed",
@@ -2206,6 +2246,7 @@ async function runImprovePostLoopStage(args) {
2206
2246
  orphansPurged: maintenanceResult.orphansPurged,
2207
2247
  proposalsExpired: maintenanceResult.proposalsExpired,
2208
2248
  gateAutoAcceptedCount,
2249
+ gateAutoAcceptFailedCount,
2209
2250
  };
2210
2251
  }
2211
2252
  // TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
@@ -14,7 +14,8 @@ import { TYPE_DIRS } from "../core/asset-spec";
14
14
  import { loadUserConfig, saveConfig } from "../core/config";
15
15
  import { ConfigError } from "../core/errors";
16
16
  import { assertSafeStashDir, getBinDir, getConfigPath, getDefaultStashDir } from "../core/paths";
17
- import { ensureRg } from "../setup/ripgrep-install";
17
+ import { ensureRg } from "../core/ripgrep/install";
18
+ import { copyStashSkeleton, scaffoldStashMeta } from "./stash-skeleton";
18
19
  /**
19
20
  * Refuse to persist a temporary-directory stashDir to the user's config when
20
21
  * running under a test runner AND `--dir <tempdir>` was passed explicitly.
@@ -74,6 +75,10 @@ export async function akmInit(options) {
74
75
  }
75
76
  // Ensure the default stash is a local git repo (no remote required)
76
77
  ensureGitRepo(stashDir);
78
+ if (created) {
79
+ copyStashSkeleton(stashDir);
80
+ scaffoldStashMeta(stashDir);
81
+ }
77
82
  // Persist stashDir in config.json
78
83
  const configPath = getConfigPath();
79
84
  const existing = loadUserConfig();
@@ -16,8 +16,8 @@
16
16
  */
17
17
  import fs from "node:fs";
18
18
  import { z } from "zod";
19
- import { UsageError } from "../core/errors";
20
- import { PROPOSAL_SOURCES } from "../core/proposals";
19
+ import { UsageError } from "../../core/errors";
20
+ import { PROPOSAL_SOURCES } from "../../core/proposals";
21
21
  // Valid `generator` values for a drain rule are exactly the canonical proposal
22
22
  // `source` values (see {@link PROPOSAL_SOURCES} in src/core/proposals.ts). The
23
23
  // engine matches rules via `policy.accept.find(r => r.generator === proposal.source)`,
@@ -36,16 +36,16 @@
36
36
  */
37
37
  import fs from "node:fs";
38
38
  import path from "node:path";
39
- import { parseAssetRef } from "../core/asset-ref";
40
- import { resolveAssetPathFromName, TYPE_DIRS } from "../core/asset-spec";
41
- import { appendEvent } from "../core/events";
42
- import { parseFrontmatter } from "../core/frontmatter";
43
- import { listProposals } from "../core/proposals";
44
- import { info, warn } from "../core/warn";
45
- import { runAgent } from "../integrations/agent";
46
- import { runOpencodeSdk } from "../integrations/agent/sdk-runner";
47
- import { chatCompletion, stripJsonFences } from "../llm/client";
48
- import { akmProposalAccept, akmProposalReject } from "./proposal";
39
+ import { parseAssetRef } from "../../core/asset-ref";
40
+ import { resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset-spec";
41
+ import { appendEvent } from "../../core/events";
42
+ import { parseFrontmatter } from "../../core/frontmatter";
43
+ import { listProposals } from "../../core/proposals";
44
+ import { info, warn } from "../../core/warn";
45
+ import { runAgent } from "../../integrations/agent";
46
+ import { runOpencodeSdk } from "../../integrations/agent/sdk-runner";
47
+ import { chatCompletion, stripJsonFences } from "../../llm/client";
48
+ import { akmProposalAccept, akmProposalReject } from "../proposal";
49
49
  // ---------------------------------------------------------------------------
50
50
  // Content helpers
51
51
  // ---------------------------------------------------------------------------