akm-cli 0.9.0-rc.4 → 0.9.0-rc.5

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
@@ -350,6 +350,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
350
350
 
351
351
  ### Fixed
352
352
 
353
+ - **Improve RC stabilization.** Restored one ownership-safe whole-run lock from
354
+ triage through final sync; `--skip-if-locked` is a true no-op; the run deadline
355
+ now starts before indexing and reaches index waits, generation, reindexing, and
356
+ quality judges; reflect judges the sanitized final candidate with bounded
357
+ changed-region context; write-target selectors no longer replace durable source
358
+ identity; and vLLM thinking controls cannot be overridden through `extraParams`.
353
359
  - **Proposal promotion, reversion, and rejection are durable and recoverable.**
354
360
  Acceptance and reversion persist target ownership and content fingerprints,
355
361
  publish atomically across filesystem layouts, index immediately, commit exact
@@ -67,6 +67,9 @@ Options:
67
67
  Only process refs with recent feedback signal events
68
68
  --min-retrieval-count <n>
69
69
  Retrieval fallback threshold when no recent feedback exists (default: 5)
70
+ --timeout-ms <n> Wall-clock budget for the entire live run (default: 7200000)
71
+ --skip-if-locked Exit 0 without doing work when another improve run owns
72
+ the whole-run lock; no triage, indexing, events, or sync
70
73
  --json-to-stdout Emit the full JSON result on stdout (legacy behaviour).
71
74
  (0.8.0+: full result is recorded in the improve_runs table of
72
75
  state.db and stdout is empty; use --json-to-stdout for the prior
@@ -83,7 +83,7 @@ export async function promoteMemoryToKnowledge(ctx) {
83
83
  const mergeResponse = await chat(ctx.llmConfig, [
84
84
  { role: "system", content: "Return only valid JSON. No prose." },
85
85
  { role: "user", content: mergePrompt },
86
- ]);
86
+ ], ctx.signal ? { signal: ctx.signal } : undefined);
87
87
  const mergeResult = parseEmbeddedJsonResponse(mergeResponse);
88
88
  if (mergeResult?.action === "NOOP") {
89
89
  // Existing content is authoritative — no update needed.
@@ -139,7 +139,11 @@ export async function promoteMemoryToKnowledge(ctx) {
139
139
  if (ctx.strategy?.processes?.distill?.qualityGate?.enabled ?? true) {
140
140
  // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
141
141
  const similarLessons = await fetchSimilarLessonsFn(resolvedPromotionContent.slice(0, 500), 3);
142
- const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined, ctx.llmConfig);
142
+ const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, {
143
+ ...(similarLessons.length > 0 ? { similarLessons } : {}),
144
+ ...(ctx.llmConfig ? { llmConfig: ctx.llmConfig } : {}),
145
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
146
+ });
143
147
  if (!judgeResult.pass) {
144
148
  if (judgeResult.reviewNeeded) {
145
149
  // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
@@ -98,37 +98,84 @@ export function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
98
98
  lines.push('Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}');
99
99
  return lines.join("\n");
100
100
  }
101
- /**
102
- * Run the LLM-as-judge quality gate on a proposal's content.
103
- *
104
- * Exported so reflect.ts can apply the same gate to reflect proposals (R-5 / #374).
105
- * The selected strategy's distill/reflect quality-gate setting is resolved by
106
- * the caller before this function runs.
107
- *
108
- * Fail-CLOSED (07 P0-2): returns `pass: false` (score -1) on timeout, parse
109
- * failure, or missing LLM. Minted content that cannot be judged is rejected,
110
- * not passed through — an unverifiable judge must never wave content into the
111
- * stash. The rejection is `quality_rejected`, not `review_needed`.
112
- */
113
- export async function runLessonQualityJudge(config, lessonContent, sourceContent, chat,
114
- /** D-4 / #390: top-3 similar existing lessons for dedup check. */
115
- similarLessons, llmConfigOverride) {
116
- const llmConfig = llmConfigOverride ?? getDefaultLlmConfig(config);
101
+ function boundedDocument(content, maxChars = 6000) {
102
+ if (content.length <= maxChars)
103
+ return content;
104
+ const half = Math.floor((maxChars - 80) / 2);
105
+ return `${content.slice(0, half)}\n\n[... middle omitted for bounded judge context ...]\n\n${content.slice(-half)}`;
106
+ }
107
+ function buildChangedRegion(sourceContent, candidateContent) {
108
+ const source = sourceContent.split("\n");
109
+ const candidate = candidateContent.split("\n");
110
+ let prefix = 0;
111
+ while (prefix < source.length && prefix < candidate.length && source[prefix] === candidate[prefix])
112
+ prefix++;
113
+ let suffix = 0;
114
+ while (suffix < source.length - prefix &&
115
+ suffix < candidate.length - prefix &&
116
+ source[source.length - 1 - suffix] === candidate[candidate.length - 1 - suffix]) {
117
+ suffix++;
118
+ }
119
+ const removed = source.slice(prefix, source.length - suffix).join("\n");
120
+ const added = candidate.slice(prefix, candidate.length - suffix).join("\n");
121
+ return boundedDocument(`Removed or replaced:\n${removed || "(none)"}\n\nAdded or replacement:\n${added || "(none)"}`);
122
+ }
123
+ /** Build quality criteria for revising an existing asset in place. */
124
+ export function buildReflectJudgePrompt(candidateContent, sourceContent, feedback) {
125
+ return [
126
+ "You are evaluating a proposed revision to an existing akm asset.",
127
+ "",
128
+ "Score this revision on each criterion from 1 (poor) to 5 (excellent):",
129
+ "1. FEEDBACK ALIGNMENT: Does the revision address the supplied feedback or improve retrieval and clarity?",
130
+ "2. PRESERVATION: Does it retain the source's concrete facts, code, commands, examples, and structure without truncation?",
131
+ "3. QUALITY: Is the revision coherent, actionable, complete, and free of unsupported claims?",
132
+ "",
133
+ "Overlap with the source is expected and must not lower the score by itself; this is an in-place revision, not a new lesson.",
134
+ "",
135
+ "Feedback:",
136
+ "```",
137
+ (feedback.length > 0 ? feedback.join("\n") : "No explicit feedback supplied.").slice(0, 1000),
138
+ "```",
139
+ "",
140
+ "Source asset content:",
141
+ "```",
142
+ boundedDocument(sourceContent),
143
+ "```",
144
+ "",
145
+ "Proposed revision:",
146
+ "```",
147
+ boundedDocument(candidateContent),
148
+ "```",
149
+ "",
150
+ "Changed region:",
151
+ "```",
152
+ buildChangedRegion(sourceContent, candidateContent),
153
+ "```",
154
+ "",
155
+ 'Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}',
156
+ ].join("\n");
157
+ }
158
+ async function runQualityJudge(config, prompt, chat, options = {}) {
159
+ const llmConfig = options.llmConfig ?? getDefaultLlmConfig(config);
117
160
  if (!llmConfig) {
118
161
  return { pass: false, score: -1, reason: "no LLM configured — cannot judge, failing closed" };
119
162
  }
120
- const judgeLlmConfig = llmConfig;
121
- const JUDGE_TIMEOUT_MS = 8_000;
122
163
  try {
123
- const raw = await Promise.race([
124
- chat(judgeLlmConfig, [
125
- { role: "system", content: "Return only valid JSON. No prose." },
126
- { role: "user", content: buildJudgePrompt(lessonContent, sourceContent, similarLessons) },
127
- ]),
128
- new Promise((_, reject) => setTimeout(() => reject(new Error("judge timeout")), JUDGE_TIMEOUT_MS)),
129
- ]);
164
+ const raw = await chat(llmConfig, [
165
+ { role: "system", content: "Return only valid JSON. No prose." },
166
+ { role: "user", content: prompt },
167
+ ], {
168
+ enableThinking: false,
169
+ ...(Object.hasOwn(options, "timeoutMs") ? { timeoutMs: options.timeoutMs } : {}),
170
+ ...(options.signal ? { signal: options.signal } : {}),
171
+ });
130
172
  const parsed = parseEmbeddedJsonResponse(raw);
131
- if (!parsed || typeof parsed.score !== "number") {
173
+ if (!parsed ||
174
+ typeof parsed.score !== "number" ||
175
+ !Number.isFinite(parsed.score) ||
176
+ parsed.score < 1 ||
177
+ parsed.score > 5 ||
178
+ typeof parsed.reason !== "string") {
132
179
  return { pass: false, score: -1, reason: "judge parse failed — cannot judge, failing closed" };
133
180
  }
134
181
  // D-5 / #388: Three-band system (MT-Bench arXiv:2306.05685 — ~±0.5 judge variance).
@@ -137,19 +184,35 @@ similarLessons, llmConfigOverride) {
137
184
  // < 2.5: auto-reject (pass: false)
138
185
  const score = parsed.score;
139
186
  const reason = parsed.reason ?? "";
140
- if (score >= 3.5) {
187
+ if (score >= 3.5)
141
188
  return { pass: true, score, reason };
142
- }
143
- if (score >= 2.5) {
144
- // Uncertainty band: treat as failed for auto-queuing but flag for review.
189
+ if (score >= 2.5)
145
190
  return { pass: false, score, reason, reviewNeeded: true };
146
- }
147
191
  return { pass: false, score, reason };
148
192
  }
149
193
  catch {
150
194
  return { pass: false, score: -1, reason: "judge timeout/error — cannot judge, failing closed" };
151
195
  }
152
196
  }
197
+ /**
198
+ * Run the LLM-as-judge quality gate on a proposal's content.
199
+ *
200
+ * Exported so reflect.ts can apply the same gate to reflect proposals (R-5 / #374).
201
+ * The selected strategy's distill/reflect quality-gate setting is resolved by
202
+ * the caller before this function runs.
203
+ *
204
+ * Fail-CLOSED (07 P0-2): returns `pass: false` (score -1) on timeout, parse
205
+ * failure, or missing LLM. Minted content that cannot be judged is rejected,
206
+ * not passed through — an unverifiable judge must never wave content into the
207
+ * stash. The rejection is `quality_rejected`, not `review_needed`.
208
+ */
209
+ export async function runLessonQualityJudge(config, lessonContent, sourceContent, chat, options = {}) {
210
+ return runQualityJudge(config, buildJudgePrompt(lessonContent, sourceContent, options.similarLessons), chat, options);
211
+ }
212
+ /** Judge an in-place reflect revision without applying new-lesson novelty criteria. */
213
+ export async function runReflectQualityJudge(config, candidateContent, sourceContent, feedback, chat, options = {}) {
214
+ return runQualityJudge(config, buildReflectJudgePrompt(candidateContent, sourceContent, feedback), chat, options);
215
+ }
153
216
  // ── Quality-rejection helper ─────────────────────────────────────────────────
154
217
  /**
155
218
  * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
@@ -623,6 +623,7 @@ export async function akmDistill(options) {
623
623
  config,
624
624
  strategy: options.improveProfile,
625
625
  llmConfig: distillLlm,
626
+ signal: options.signal,
626
627
  chat,
627
628
  stash,
628
629
  lookup,
@@ -719,7 +720,7 @@ export async function akmDistill(options) {
719
720
  }
720
721
  // Test seam: preserve the two-arg signature so existing fake `chat`
721
722
  // functions (which return markdown strings) continue to work.
722
- return chat(distillLlm, messages);
723
+ return chat(distillLlm, messages, options.signal ? { signal: options.signal } : undefined);
723
724
  }, null, {
724
725
  enabled: resolveProcessEnabled("distill", options.improveProfile ?? resolveImproveStrategy(undefined, config).config),
725
726
  onFallback: (evt) => {
@@ -846,7 +847,11 @@ export async function akmDistill(options) {
846
847
  if (options.improveProfile?.processes?.distill?.qualityGate?.enabled ?? true) {
847
848
  // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
848
849
  const similarLessons = await fetchSimilarLessonsFn(content.slice(0, 500), 3);
849
- const judgeResult = await runLessonQualityJudge(config, content, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined, distillLlm);
850
+ const judgeResult = await runLessonQualityJudge(config, content, assetContent ?? "", chat, {
851
+ ...(similarLessons.length > 0 ? { similarLessons } : {}),
852
+ ...(distillLlm ? { llmConfig: distillLlm } : {}),
853
+ ...(options.signal ? { signal: options.signal } : {}),
854
+ });
850
855
  if (!judgeResult.pass) {
851
856
  if (judgeResult.reviewNeeded) {
852
857
  return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, {
@@ -343,7 +343,7 @@ triage, sessionIndexing, schemaSimilarityCtx, hotProbationEnabled,
343
343
  // prior row + bypass flags are threaded in from the caller. Skipping here still
344
344
  // costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
345
345
  // file read is incurred.
346
- prior, force,
346
+ prior, force, signal,
347
347
  // Stash authoring standards (convention/meta fact bodies) for non-wiki
348
348
  // output. Resolved ONCE per run by the caller and threaded in so facts are
349
349
  // not re-read per session. Empty string when none exist.
@@ -481,6 +481,7 @@ standardsContext) {
481
481
  llmRaw = await chat(getLlmConfig(), [{ role: "user", content: prompt }], {
482
482
  timeoutMs,
483
483
  responseSchema: EXTRACT_JSON_SCHEMA,
484
+ ...(signal ? { signal } : {}),
484
485
  });
485
486
  return llmRaw;
486
487
  }, "", { timeoutMs });
@@ -861,6 +862,8 @@ export async function akmExtract(options) {
861
862
  // prompt so facts are not re-read per session.
862
863
  const extractStandardsContext = resolveExtractStandards(stashDir);
863
864
  for (const summary of candidates) {
865
+ if (options.signal?.aborted)
866
+ break;
864
867
  // #602 — the already-extracted skip moved INTO processSession (the content
865
868
  // hash needs the session body, only available after readSession). The prior
866
869
  // row + bypass flags are threaded through; an unchanged session returns
@@ -901,7 +904,7 @@ export async function akmExtract(options) {
901
904
  sessionLockOwnership = sessionLock.ownership;
902
905
  }
903
906
  try {
904
- const result = await processSession(harness, summary, stashDir, config, getLlmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, hotProbationEnabled, prior, options.force === true, extractStandardsContext);
907
+ const result = await processSession(harness, summary, stashDir, config, getLlmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, hotProbationEnabled, prior, options.force === true, options.signal, extractStandardsContext);
905
908
  sessions.push(result);
906
909
  // #626 — triage aggregation. A session reached the triage gate only when it
907
910
  // was NOT already preempted by an earlier skip (read_failed / too_short /
@@ -118,12 +118,11 @@ export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProp
118
118
  continue;
119
119
  }
120
120
  try {
121
- const target = resolvedConfig.defaultWriteTarget;
122
121
  const resolvedEligibilitySource = isExploration
123
122
  ? "exploration"
124
123
  : currentProposal?.eligibilitySource;
125
124
  const promotion = await promoteFn(cfg.stashDir, resolvedConfig, proposalId, {
126
- ...(target ? { target } : {}),
125
+ ...(cfg.targetSelector ? { target: cfg.targetSelector } : {}),
127
126
  eventMetadata: {
128
127
  autoAccept: true,
129
128
  confidence,
@@ -255,6 +254,7 @@ export function makeGateConfig(phase, shared, overrides = {}) {
255
254
  stashDir: shared.stashDir,
256
255
  config: shared.config,
257
256
  eventsCtx: shared.eventsCtx,
257
+ ...(shared.targetSelector ? { targetSelector: shared.targetSelector } : {}),
258
258
  ...(explorationBudgetCount !== undefined && explorationBudgetCount > 0 ? { explorationBudgetCount } : {}),
259
259
  ...overrides,
260
260
  };