akm-cli 0.9.0-beta.31 → 0.9.0-beta.32

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,32 @@ 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.32] — 2026-06-21
10
+
11
+ ### Added
12
+
13
+ - **Recombine acceptance path — confirmed lessons now auto-accept.** Recombine
14
+ hypotheses that reach the confirmation threshold (promoted to `type: lesson`,
15
+ #625/#633) now flow to ACCEPTED by reusing the existing drain mechanism instead
16
+ of piling up pending forever: the `personal-stash` drain policy gains a
17
+ `{ generator: "recombine", requireType: "lesson", maxDiffLines: 200 }` rule, via
18
+ a new optional `requireType` frontmatter filter on `DrainAcceptRule`. Only
19
+ confirmed `type: lesson` proposals auto-accept; unconfirmed `type: hypothesis`
20
+ proposals stay pending; the existing proposal quality gate still applies.
21
+ - **`processes.reflect.lowValueFilter` (opt-in, default OFF)** — deterministic
22
+ semantic value-floor that defers trivial reflect rewrites (#639A).
23
+ - **`processes.extract.triage.proceduralAwareFloor` (opt-in, default OFF)** —
24
+ triage floor requiring markers/edits so real lessons always pass (#641).
25
+
26
+ ### Fixed
27
+
28
+ - **Select-time proactive cooldown leak.** `selectProactiveMaintenanceRefs` plans
29
+ the due set BEFORE acquiring `reflect-distill.lock`, so overlapping/back-to-back
30
+ improve runs reused stale due-state and re-reflected the same asset repeatedly
31
+ (observed up to ~16× in a day). The orchestrator now re-applies the dueDays gate
32
+ with freshly-read timestamp maps INSIDE the lock (`filterProactiveDue`), dropping
33
+ refs a concurrent run already reflected.
34
+
9
35
  ## [0.9.0-beta.31] — 2026-06-20
10
36
 
11
37
  ### Changed
@@ -173,6 +173,7 @@ export function hashSessionContent(data) {
173
173
  async function processSession(harness, sessionRef, stashDir, config, llmConfig, chat, ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars,
174
174
  // #626 — pre-LLM heuristic triage gate. Default-off (enabled:false) takes the
175
175
  // exact pre-change path (no scorer call, no new skipReason).
176
+ // #641 — proceduralAwareFloor is opt-in, DEFAULT OFF.
176
177
  triage, sessionIndexing, schemaSimilarityCtx,
177
178
  // #602 — already-extracted skip moved INSIDE processSession: the content hash
178
179
  // can only be computed after readSession, so the skip decision lives here. The
@@ -251,7 +252,9 @@ prior, force, singleSession) {
251
252
  // the configured threshold we triage it out: no chat() call, no session asset,
252
253
  // no proposals. Pure-heuristic — zero added LLM cost. Default-off → skipped.
253
254
  if (triage.enabled) {
254
- const t = scoreSessionTriage(data, triage.minScore);
255
+ const t = scoreSessionTriage(data, triage.minScore, {
256
+ proceduralAwareFloor: triage.proceduralAwareFloor,
257
+ });
255
258
  if (!t.pass) {
256
259
  return {
257
260
  sessionId: sessionRef.sessionId,
@@ -50,7 +50,7 @@ import { isProfileFilteredForAllPasses, resolveImproveProfile, resolveProcessEna
50
50
  import { detectAndWriteContradictions } from "./memory/memory-contradiction-detect.js";
51
51
  import { analyzeMemoryCleanup, applyMemoryCleanup } from "./memory/memory-improve.js";
52
52
  import { computeProxyAdequacy, getAllAssetOutcomes, getOutcomeScoresByRef, outcomeScoreToSalience, updateAssetOutcome, } from "./outcome-loop.js";
53
- import { DEFAULT_DUE_DAYS, DEFAULT_MAX_PER_RUN, selectProactiveMaintenanceRefs } from "./proactive-maintenance.js";
53
+ import { DEFAULT_DUE_DAYS, DEFAULT_MAX_PER_RUN, filterProactiveDue, selectProactiveMaintenanceRefs, } from "./proactive-maintenance.js";
54
54
  import { akmProcedural } from "./procedural.js";
55
55
  import { akmRecombine } from "./recombine.js";
56
56
  import { akmReflect } from "./reflect.js";
@@ -1105,13 +1105,32 @@ export async function akmImprove(options = {}) {
1105
1105
  // both write proposals to state.db). Released immediately after.
1106
1106
  const reflectDistillLPath = processLockPath(lockBaseDir, "reflectDistill");
1107
1107
  const reflectDistillAcquired = tryAcquireProcessLock(reflectDistillLPath, PROCESS_LOCK_DEFS.reflectDistill.staleAfterMs, options.skipIfLocked, "reflect-distill") === "acquired";
1108
+ // Post-lock cooldown re-filter for proactive refs (#SELECT-TIME-LEAK).
1109
+ // Planning built `lastReflectProposalTs` BEFORE acquiring this lock, so a
1110
+ // concurrent run's `reflect_invoked` writes are invisible to it. Now that
1111
+ // we hold the lock, re-read fresh timestamp maps for the proactive subset
1112
+ // and drop any ref whose cooldown has been consumed by the concurrent run.
1113
+ const proactiveLoopRefs = preparation.loopRefs.filter((r) => r.eligibilitySource === "proactive");
1114
+ let postLockLoopRefs = preparation.loopRefs;
1115
+ if (proactiveLoopRefs.length > 0) {
1116
+ const proactiveRefStrs = proactiveLoopRefs.map((r) => r.ref);
1117
+ const freshReflectTs = buildLatestProposalTsMap(proactiveRefStrs, "reflect");
1118
+ const freshDistillTs = buildLatestProposalTsMap(proactiveRefStrs, "distill");
1119
+ const pmDueDays = improveProfile.processes?.proactiveMaintenance?.dueDays ?? DEFAULT_DUE_DAYS;
1120
+ const stillDue = new Set(filterProactiveDue(proactiveLoopRefs, freshReflectTs, freshDistillTs, pmDueDays, Date.now()).map((r) => r.ref));
1121
+ const dropped = proactiveLoopRefs.filter((r) => !stillDue.has(r.ref));
1122
+ if (dropped.length > 0) {
1123
+ info(`[improve] post-lock cooldown re-filter: dropped ${dropped.length} proactive ref(s) claimed by concurrent run (${dropped.map((r) => r.ref).join(", ")})`);
1124
+ postLockLoopRefs = preparation.loopRefs.filter((r) => r.eligibilitySource !== "proactive" || stillDue.has(r.ref));
1125
+ }
1126
+ }
1108
1127
  const loopResult = await runImproveLoopStageImpl({
1109
1128
  scope,
1110
1129
  options,
1111
1130
  primaryStashDir,
1112
1131
  reflectFn,
1113
1132
  distillFn,
1114
- loopRefs: preparation.loopRefs,
1133
+ loopRefs: postLockLoopRefs,
1115
1134
  actions: preparation.actions,
1116
1135
  signalBearingSet: preparation.signalBearingSet,
1117
1136
  distillCooledRefs: preparation.distillCooledRefs,
@@ -85,3 +85,31 @@ export function selectProactiveMaintenanceRefs(params) {
85
85
  const selected = ranked.slice(0, Math.max(0, maxPerRun)).map((s) => s.ref);
86
86
  return { selected, dueTotal, neverReflected, scored };
87
87
  }
88
+ /**
89
+ * Post-lock re-filter for proactive refs.
90
+ *
91
+ * Called INSIDE the reflect-distill.lock window with freshly-read timestamp
92
+ * maps so that any `reflect_invoked` events committed by a concurrent run
93
+ * while this run was waiting for the lock are visible. Refs that became
94
+ * non-due (staleDays ≤ dueDays) since planning time are dropped before
95
+ * execution, closing the SELECT-time cooldown race.
96
+ *
97
+ * The logic mirrors the DUE gate in `selectProactiveMaintenanceRefs` — an
98
+ * asset is due only when never touched OR last touched more than `dueDays`
99
+ * ago. The selector and this filter must always agree on the gate predicate.
100
+ */
101
+ export function filterProactiveDue(selected, lastReflectTs, lastDistillTs, dueDays, now) {
102
+ return selected.filter((candidate) => {
103
+ const ref = candidate.ref;
104
+ const reflectIso = lastReflectTs.get(ref);
105
+ const distillIso = lastDistillTs.get(ref);
106
+ let lastTouchMs = 0;
107
+ if (reflectIso)
108
+ lastTouchMs = Math.max(lastTouchMs, Date.parse(reflectIso) || 0);
109
+ if (distillIso)
110
+ lastTouchMs = Math.max(lastTouchMs, Date.parse(distillIso) || 0);
111
+ const neverReflected = lastTouchMs === 0;
112
+ const staleDays = neverReflected ? Number.POSITIVE_INFINITY : (now - lastTouchMs) / DAY_MS;
113
+ return neverReflected || staleDays > dueDays;
114
+ });
115
+ }
@@ -1170,8 +1170,18 @@ export async function akmReflect(options = {}) {
1170
1170
  // (new-asset proposals have nothing to diff against).
1171
1171
  if (assetContent !== undefined) {
1172
1172
  const changeKind = classifyReflectChange(assetContent, payload.content);
1173
- if (changeKind !== "substantive") {
1174
- const subreason = changeKind === "noop" ? "reflect_skipped_noop" : "reflect_skipped_cosmetic";
1173
+ // 'low-value' is config-gated (#639): only defer when
1174
+ // profiles.improve.default.processes.reflect.lowValueFilter.enabled is
1175
+ // explicitly true. DEFAULT OFF — absent flag = byte-identical pre-#639
1176
+ // behaviour (low-value treated the same as substantive).
1177
+ const lowValueFilterEnabled = runtimeConfig?.profiles?.improve?.default?.processes?.reflect?.lowValueFilter?.enabled === true;
1178
+ const isDeferred = changeKind === "noop" || changeKind === "cosmetic" || (changeKind === "low-value" && lowValueFilterEnabled);
1179
+ if (isDeferred) {
1180
+ const subreason = changeKind === "noop"
1181
+ ? "reflect_skipped_noop"
1182
+ : changeKind === "low-value"
1183
+ ? "reflect_skipped_low_value"
1184
+ : "reflect_skipped_cosmetic";
1175
1185
  emitReflectFailed("no_change", subreason, options.ref, { changeKind });
1176
1186
  return {
1177
1187
  schemaVersion: 1,
@@ -1179,7 +1189,9 @@ export async function akmReflect(options = {}) {
1179
1189
  reason: "no_change",
1180
1190
  error: changeKind === "noop"
1181
1191
  ? `Reflect skipped: proposed content for ${payload.ref} is identical to the current asset (empty diff); no proposal created.`
1182
- : `Reflect skipped: proposed content for ${payload.ref} is a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes); no proposal created.`,
1192
+ : changeKind === "low-value"
1193
+ ? `Reflect skipped: proposed content for ${payload.ref} is a low-value prose micro-rewrite (few changed tokens, no structural changes); no proposal created.`
1194
+ : `Reflect skipped: proposed content for ${payload.ref} is a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes); no proposal created.`,
1183
1195
  ...(options.ref ? { ref: options.ref } : {}),
1184
1196
  exitCode: result.exitCode,
1185
1197
  };
@@ -14,23 +14,7 @@ const MARKER_RE = /\b(error|failed|fix(?:ed)?|root cause|turns out|because|decid
14
14
  const EDIT_COMMIT_RE = /\b(Edit|Write|MultiEdit|git commit|diff)\b/i;
15
15
  // A substantive turn is an assistant or tool turn whose text is non-trivial.
16
16
  const SUBSTANTIVE_MIN_CHARS = 40;
17
- /**
18
- * Pure heuristic scorer. Operates only on `data.events`. Each sub-signal is
19
- * normalized to a small bounded contribution; `score` is their sum and
20
- * `pass = score >= minScore`.
21
- *
22
- * Sub-signals:
23
- * - markers: presence of decision/outcome/error keywords across event text
24
- * (capped, narrative-lesson signal).
25
- * - toolDensity: bounded contribution from tool-use events (procedural).
26
- * - editCommit: bounded contribution from edit/write/commit events (procedural).
27
- * - substantiveRatio: scaled fraction of non-trivial assistant+tool turns,
28
- * filtering pure short Q&A.
29
- *
30
- * The procedural sub-signals (toolDensity + editCommit) alone can clear
31
- * DEFAULT_TRIAGE_MIN_SCORE so high-action / no-narrative sessions are KEPT (#615).
32
- */
33
- export function scoreSessionTriage(data, minScore) {
17
+ export function scoreSessionTriage(data, minScore, options) {
34
18
  const events = data.events;
35
19
  const total = events.length;
36
20
  // (a) markers — count word-bounded marker hits across all event text, capped.
@@ -72,7 +56,19 @@ export function scoreSessionTriage(data, minScore) {
72
56
  const substantiveRatio = Math.min(ratio, 1);
73
57
  const subscores = { markers, toolDensity, editCommit, substantiveRatio };
74
58
  const score = markers + toolDensity + editCommit + substantiveRatio;
75
- const pass = score >= minScore;
59
+ // Base gate: score must clear the floor.
60
+ let pass = score >= minScore;
61
+ // #641 procedural-aware floor (opt-in, DEFAULT OFF).
62
+ // When enabled, a session that clears the score gate must ALSO have at least
63
+ // one narrative marker (markers >= 1) OR meaningful edit/commit signal
64
+ // (editCommit >= 0.5). Pure read-only Q&A sessions that pass only via
65
+ // toolDensity + substantiveRatio are rejected as low-signal.
66
+ if (pass && options?.proceduralAwareFloor === true) {
67
+ const hasProceduralSignal = markers >= 1 || editCommit >= 0.5;
68
+ if (!hasProceduralSignal) {
69
+ pass = false;
70
+ }
71
+ }
76
72
  return {
77
73
  pass,
78
74
  score,
@@ -91,5 +87,7 @@ export function resolveTriageConfig(extractProcess) {
91
87
  const triage = extractProcess?.triage;
92
88
  const enabled = triage?.enabled === true;
93
89
  const minScore = typeof triage?.minScore === "number" ? triage.minScore : DEFAULT_TRIAGE_MIN_SCORE;
94
- return { enabled, minScore };
90
+ // #641: default-off only true when explicitly set to true.
91
+ const proceduralAwareFloor = triage?.proceduralAwareFloor === true;
92
+ return { enabled, minScore, proceduralAwareFloor };
95
93
  }
@@ -45,6 +45,10 @@ export const PERSONAL_STASH = {
45
45
  { generator: "reflect", maxDiffLines: 80 },
46
46
  // Consolidate within the diff band; mid-band lands in `defer` below.
47
47
  { generator: "consolidate", maxDiffLines: 200 },
48
+ // Recombine: accept only confirmed type:lesson proposals (promoted by the
49
+ // recombine confidence gate) within the diff band. type:hypothesis proposals
50
+ // have no matching rule here and stay pending for manual review.
51
+ { generator: "recombine", requireType: "lesson", maxDiffLines: 200 },
48
52
  ],
49
53
  rejectEmpty: true,
50
54
  // Mid-band consolidate, distill duplicates, and contradiction escalations are
@@ -83,6 +87,7 @@ const DrainAcceptRuleSchema = z
83
87
  generator: GeneratorSchema,
84
88
  maxDiffLines: z.number().int().positive().optional(),
85
89
  minContentLines: z.number().int().nonnegative().optional(),
90
+ requireType: z.string().optional(),
86
91
  })
87
92
  .strict();
88
93
  const DrainPolicySchema = z
@@ -80,7 +80,16 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
80
80
  if (policy.rejectEmpty && isEmptyDiff(proposal)) {
81
81
  return { verdict: "reject", reason: "empty diff", gate: { reason: "empty-diff" } };
82
82
  }
83
- const rule = policy.accept.find((r) => r.generator === proposal.source);
83
+ const rule = policy.accept.find((r) => {
84
+ if (r.generator !== proposal.source)
85
+ return false;
86
+ if (r.requireType !== undefined) {
87
+ const fm = parseFrontmatter(proposal.payload.content ?? "").data;
88
+ if (typeof fm.type !== "string" || fm.type !== r.requireType)
89
+ return false;
90
+ }
91
+ return true;
92
+ });
84
93
  if (rule) {
85
94
  const lines = contentLineCount(content);
86
95
  const body = contentBodyLineCount(content);
@@ -329,6 +329,16 @@ export const ImproveProcessConfigSchema = z
329
329
  // #615 — procedural process: asset type a compiled sequence is emitted as.
330
330
  // Reserved; v1 always emits "workflow". Only meaningful on `procedural`.
331
331
  emitAs: z.enum(["workflow", "skill"]).optional(),
332
+ // #639 — semantic value-floor filter for the `reflect` process. When
333
+ // enabled, proposals classified as "low-value" by the deterministic noise
334
+ // gate are deferred. DEFAULT OFF (absent / { enabled: false } = pre-#639
335
+ // byte-identical behaviour). Only meaningful on the `reflect` process.
336
+ lowValueFilter: z.object({ enabled: z.boolean().optional() }).strict().optional(),
337
+ // #641 — procedural-aware floor for the `extract` process triage gate.
338
+ // When true, a session must have markers>=1 OR editCommit>=0.5 to pass, even
339
+ // if score>=minScore. DEFAULT OFF (absent/false = pre-#641 byte-identical).
340
+ // Only meaningful on the `extract` process when triage is also enabled.
341
+ proceduralAwareFloor: z.boolean().optional(),
332
342
  // Triage process config (only meaningful for the `triage` process)
333
343
  applyMode: z.enum(["queue", "promote"]).optional(),
334
344
  policy: z.string().min(1).optional(),
@@ -15920,6 +15920,8 @@ var init_config_schema = __esm(() => {
15920
15920
  minRecurrence: exports_external.number().int().min(2).optional(),
15921
15921
  maxProposalsPerRun: positiveInt.optional(),
15922
15922
  emitAs: exports_external.enum(["workflow", "skill"]).optional(),
15923
+ lowValueFilter: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
15924
+ proceduralAwareFloor: exports_external.boolean().optional(),
15923
15925
  applyMode: exports_external.enum(["queue", "promote"]).optional(),
15924
15926
  policy: exports_external.string().min(1).optional(),
15925
15927
  maxAcceptsPerRun: positiveInt.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.31",
3
+ "version": "0.9.0-beta.32",
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": [