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

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
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0-beta.11] - 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - **`extract.maxSessionsPerRun`** (default 25) — caps the NEW sessions the
14
+ extract pass LLM-processes in a single run so a backlog (e.g. after downtime)
15
+ can't push one run past its scheduled-task timeout. Overflow sessions stay
16
+ unseen and are picked up by later runs, so coverage is preserved. `0` disables.
17
+
18
+ ### Fixed
19
+
20
+ - **Auto-accept validation failures are no longer a blind leak.** When a
21
+ confidence-passing proposal fails promotion validation, the gate now captures
22
+ the reason (the `validateProposal` finding kind, e.g. `validation:description-quality`),
23
+ records it on the proposal (`akm proposal show` explains the rejection), logs
24
+ it, and exposes `failedByReason` on the gate result — so the ~5% leak is
25
+ diagnosable instead of silently warned-and-dropped.
26
+ - **Inflated skip-reason aggregates in `akm health`.** `no_new_signal` /
27
+ `profile_filtered_all_passes` are per-run snapshots of a stable set; the
28
+ window aggregator summed their per-run counts (≈2.7M / 3M). It now uses the
29
+ most recent run's count for these aggregated-snapshot reasons while still
30
+ summing genuine per-occurrence skips.
31
+
9
32
  ## [0.9.0-beta.10] - 2026-06-15
10
33
 
11
34
  ### Added
@@ -833,19 +833,33 @@ function computeWallTimeStats(durationsMs, byPhase) {
833
833
  };
834
834
  }
835
835
  function buildImproveSkipSummary(events) {
836
- const skipReasons = {};
837
- let skipped = 0;
836
+ // Two kinds of skip events:
837
+ // - Per-occurrence (no `count`): one event per skipped ref → SUM is correct.
838
+ // - Aggregated snapshot (carries `count`): a single per-run event whose count
839
+ // is the number of refs that hit a STABLE, whole-stash condition that run
840
+ // (`no_new_signal`, `profile_filtered_all_passes`). Each run re-counts the
841
+ // same stable set, so summing across the window re-counts it N times (the
842
+ // 2.7M / 3M inflation). For these we keep the MOST RECENT run's count — the
843
+ // current snapshot — matching how memorySummary/profileFilteredRefs are
844
+ // handled. Events arrive in chronological (offset) order, so the last
845
+ // count-bearing event per reason is the latest run's value.
846
+ const summed = {};
847
+ const latestSnapshot = {};
838
848
  for (const event of events) {
839
849
  const reason = typeof event.metadata?.reason === "string" && event.metadata.reason.trim() ? event.metadata.reason : "unknown";
840
- // Aggregated skip events (e.g. `no_new_signal`, `profile_filtered_all_passes`)
841
- // carry a `count` of the refs they represent in a single row instead of one
842
- // event per ref. Honor that count so the skip histogram reflects the true
843
- // number of skipped refs; per-ref events without a count contribute 1.
844
850
  const rawCount = event.metadata?.count;
845
- const count = typeof rawCount === "number" && Number.isFinite(rawCount) && rawCount > 0 ? rawCount : 1;
851
+ if (typeof rawCount === "number" && Number.isFinite(rawCount) && rawCount > 0) {
852
+ latestSnapshot[reason] = rawCount; // overwrite → keeps the latest run's snapshot
853
+ }
854
+ else {
855
+ summed[reason] = (summed[reason] ?? 0) + 1;
856
+ }
857
+ }
858
+ const skipReasons = { ...summed };
859
+ for (const [reason, count] of Object.entries(latestSnapshot)) {
846
860
  skipReasons[reason] = (skipReasons[reason] ?? 0) + count;
847
- skipped += count;
848
861
  }
862
+ const skipped = Object.values(skipReasons).reduce((a, b) => a + b, 0);
849
863
  return { skipped, skipReasons };
850
864
  }
851
865
  function probeStateDbRoundTrip(stateDbPath) {
@@ -51,6 +51,14 @@ const DEFAULT_MIN_SESSION_DURATION_MINUTES = 5;
51
51
  * (0 chars, journal files) are safe to skip.
52
52
  */
53
53
  const DEFAULT_MIN_CONTENT_CHARS = 10;
54
+ /**
55
+ * Default cap on NEW sessions the extract pass will LLM-process in a single run
56
+ * (`processes.extract.maxSessionsPerRun` overrides; `0` disables). Bounds per-run
57
+ * wall time + token spend so a backlog of accumulated sessions can't run a single
58
+ * pass past its scheduled-task timeout. Overflow sessions stay unseen and are
59
+ * processed by subsequent runs, so coverage is preserved — just spread out.
60
+ */
61
+ const DEFAULT_MAX_SESSIONS_PER_RUN = 25;
54
62
  // ── Helpers ──────────────────────────────────────────────────────────────────
55
63
  /**
56
64
  * Parse a since-string into an absolute ms-epoch cutoff. Accepts:
@@ -383,6 +391,12 @@ export async function akmExtract(options) {
383
391
  // #595/#596 — minimum raw session size; sessions below it skip the LLM call
384
392
  // entirely. Set `processes.extract.minContentChars: 0` to disable the gate.
385
393
  const minContentChars = typeof extractProcess?.minContentChars === "number" ? extractProcess.minContentChars : DEFAULT_MIN_CONTENT_CHARS;
394
+ // Cap on NEW sessions LLM-processed per run; 0 disables. Absent = default.
395
+ // Bounds per-run wall time / LLM cost so a backlog can't push a run past its
396
+ // task timeout — the overflow stays unseen and is picked up by later runs.
397
+ const maxSessionsPerRun = typeof extractProcess?.maxSessionsPerRun === "number"
398
+ ? extractProcess.maxSessionsPerRun
399
+ : DEFAULT_MAX_SESSIONS_PER_RUN;
386
400
  // Default discovery window — process config can override the built-in 24h.
387
401
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
388
402
  // #561 — resolve session-indexing config. Default ON: we only reach this code
@@ -526,6 +540,13 @@ export async function akmExtract(options) {
526
540
  skippedCount += 1;
527
541
  continue;
528
542
  }
543
+ // Per-run cap on LLM-processed sessions (skip-tracked seen sessions above
544
+ // don't count). Single-session / --force modes bypass the cap (explicit
545
+ // intent). Overflow sessions are left unseen for the next run.
546
+ if (!options.sessionId && !options.force && maxSessionsPerRun > 0 && processedCount >= maxSessionsPerRun) {
547
+ topLevelWarnings.push(`Reached maxSessionsPerRun=${maxSessionsPerRun}; ${candidates.length - processedCount - skippedCount} session(s) deferred to a later run.`);
548
+ break;
549
+ }
529
550
  try {
530
551
  const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, sessionIndexing);
531
552
  sessions.push(result);
@@ -5,6 +5,23 @@ import { loadConfig } from "../../core/config/config.js";
5
5
  import { appendEvent } from "../../core/events.js";
6
6
  import { info, warn } from "../../core/warn.js";
7
7
  import { promoteProposal, recordGateDecision } from "../proposal/validators/proposals.js";
8
+ /**
9
+ * Derive a stable, low-cardinality reason bucket from an auto-accept promotion
10
+ * error. `promoteProposal` throws a `validateProposal` report formatted as
11
+ * `[kind] message` lines; we extract the first finding kind. Non-validation
12
+ * throws collapse to `promote-error`.
13
+ */
14
+ function classifyPromoteFailure(err) {
15
+ const message = err instanceof Error ? err.message : String(err);
16
+ const finding = /\[([a-z][a-z0-9-]*)\]/i.exec(message);
17
+ if (finding)
18
+ return `validation:${finding[1]}`;
19
+ if (/not pending/i.test(message))
20
+ return "not-pending";
21
+ if (/unknown asset type/i.test(message))
22
+ return "unknown-type";
23
+ return "promote-error";
24
+ }
8
25
  // ---------------------------------------------------------------------------
9
26
  // Gate implementation
10
27
  // ---------------------------------------------------------------------------
@@ -18,7 +35,7 @@ import { promoteProposal, recordGateDecision } from "../proposal/validators/prop
18
35
  * @param promoteFn Injectable override for `promoteProposal` (test seam).
19
36
  */
20
37
  export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProposal) {
21
- const result = { promoted: [], skipped: [], failed: [] };
38
+ const result = { promoted: [], skipped: [], failed: [], failedByReason: {} };
22
39
  // --- Guard: gate is disabled or context is incomplete ---
23
40
  if (cfg.dryRun || cfg.globalThreshold === undefined || !cfg.stashDir) {
24
41
  result.skipped = candidates.map((c) => c.proposalId);
@@ -84,8 +101,19 @@ export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProp
84
101
  result.promoted.push(proposalId);
85
102
  }
86
103
  catch (err) {
87
- warn(`[improve] ${cfg.phase} auto-accept failed for ${proposalId}: ${err instanceof Error ? err.message : String(err)}`);
104
+ const reason = classifyPromoteFailure(err);
105
+ warn(`[improve] ${cfg.phase} auto-accept failed for ${proposalId} (${reason}): ${err instanceof Error ? err.message : String(err)}`);
88
106
  result.failed.push(proposalId);
107
+ result.failedByReason[reason] = (result.failedByReason[reason] ?? 0) + 1;
108
+ // Record WHY on the proposal so `akm proposal show` explains the rejection
109
+ // and the leak is no longer blind. Best-effort.
110
+ stamp(proposalId, {
111
+ outcome: "auto-rejected",
112
+ reason,
113
+ confidence,
114
+ thresholds: { autoAccept: effectiveThreshold },
115
+ gate: gateLabel,
116
+ });
89
117
  }
90
118
  }
91
119
  return result;
@@ -173,6 +173,9 @@ export const ImproveProcessConfigSchema = z
173
173
  // disables the guard. Only meaningful on the `extract` process. Default 0
174
174
  // (disabled) so existing behaviour is preserved; only opted-in profiles set it.
175
175
  minNewSessions: z.number().int().min(0).optional(),
176
+ // Extract process: cap on NEW sessions processed (LLM-called) per run; the
177
+ // rest roll to the next run (still unseen). 0 disables. Absent = default 25.
178
+ maxSessionsPerRun: z.number().int().min(0).optional(),
176
179
  // #561 — index agent sessions as a searchable `session` asset (extract
177
180
  // process). Absent = on-when-an-LLM-is-available (fail-open when offline).
178
181
  indexSessions: z.boolean().optional(),
@@ -15543,6 +15543,7 @@ var init_config_schema = __esm(() => {
15543
15543
  importanceWeights: exports_external.record(exports_external.string().min(1), exports_external.number()).optional(),
15544
15544
  minPendingCount: exports_external.number().int().min(0).optional(),
15545
15545
  minNewSessions: exports_external.number().int().min(0).optional(),
15546
+ maxSessionsPerRun: exports_external.number().int().min(0).optional(),
15546
15547
  indexSessions: exports_external.boolean().optional(),
15547
15548
  minSessionDuration: exports_external.number().min(0).optional(),
15548
15549
  applyMode: exports_external.enum(["queue", "promote"]).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.10",
3
+ "version": "0.9.0-beta.11",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [