akm-cli 0.9.16 → 0.9.17-alpha.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/dist/assets/prompts/consolidate-system.md +4 -11
  3. package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
  4. package/dist/commands/health/accept-rate.js +6 -0
  5. package/dist/commands/health/checks.js +54 -0
  6. package/dist/commands/health/improve-metrics.js +1 -5
  7. package/dist/commands/health/report-view-model.js +0 -1
  8. package/dist/commands/health.js +10 -0
  9. package/dist/commands/improve/consolidate/chunking.js +19 -35
  10. package/dist/commands/improve/consolidate/merge.js +6 -9
  11. package/dist/commands/improve/consolidate.js +104 -91
  12. package/dist/commands/improve/distill/promote-memory.js +40 -2
  13. package/dist/commands/improve/distill/quality-gate.js +186 -23
  14. package/dist/commands/improve/distill.js +42 -8
  15. package/dist/commands/improve/eligibility.js +13 -3
  16. package/dist/commands/improve/improve-cli.js +32 -9
  17. package/dist/commands/improve/improve-strategies.js +23 -1
  18. package/dist/commands/improve/improve.js +121 -84
  19. package/dist/commands/improve/loop-stages.js +241 -108
  20. package/dist/commands/improve/preparation.js +50 -17
  21. package/dist/commands/improve/reflect.js +16 -5
  22. package/dist/commands/improve/shared.js +0 -10
  23. package/dist/commands/proposal/drain.js +79 -10
  24. package/dist/commands/proposal/proposal-types.js +21 -0
  25. package/dist/commands/proposal/repository.js +108 -29
  26. package/dist/commands/tasks/tasks.js +19 -2
  27. package/dist/core/asset/frontmatter.js +106 -1
  28. package/dist/core/config/config.js +5 -2
  29. package/dist/core/config/retired-experimental-keys-shim.js +62 -0
  30. package/dist/core/config/schema/improve-processes.js +29 -2
  31. package/dist/core/improve-result.js +9 -0
  32. package/dist/core/paths.js +7 -0
  33. package/dist/core/write-source.js +10 -2
  34. package/dist/indexer/ensure-index.js +52 -7
  35. package/dist/indexer/graph/graph-extraction.js +82 -8
  36. package/dist/indexer/passes/memory-inference.js +16 -1
  37. package/dist/llm/client.js +16 -2
  38. package/dist/llm/graph-extract.js +162 -18
  39. package/dist/scripts/akm-migrate-node.js +97 -36
  40. package/dist/scripts/akm-migrate.js +97 -36
  41. package/dist/storage/repositories/index-entries-repository.js +43 -0
  42. package/dist/storage/repositories/proposals-repository.js +4 -1
  43. package/dist/storage/state-db-integrity.js +123 -0
  44. package/dist/workflows/program/schema.js +1 -0
  45. package/docs/reference/cli.md +17 -7
  46. package/docs/reference/data-and-telemetry.md +1 -0
  47. package/package.json +1 -1
  48. package/schemas/akm-config.json +44 -0
  49. package/schemas/akm-workflow.json +1 -0
  50. package/dist/commands/improve/eval-cases.js +0 -52
@@ -22,7 +22,7 @@ import { listStateProposals } from "../../storage/repositories/proposals-reposit
22
22
  import { akmLint } from "../lint/index.js";
23
23
  import { runSchemaRepairPass } from "../sources/schema-repair.js";
24
24
  import { isAutonomyLaneAllowed } from "./autonomy-gate.js";
25
- import { akmConsolidate, inspectConsolidationPool } from "./consolidate.js";
25
+ import { akmConsolidate, inspectConsolidationPool, loadExistingKnowledgeBodyHashes } from "./consolidate.js";
26
26
  import { computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
27
27
  // Eligibility / candidate-selection predicates live in ./eligibility.
28
28
  import { buildLatestFeedbackTsMap, buildLatestProposalTsMap, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, isSignalDeltaEligible, resolveImproveScope, } from "./eligibility.js";
@@ -133,11 +133,15 @@ function evaluateConsolidationEligibility(args) {
133
133
  // Bootstrap: when no successful consolidate_completed event has ever been
134
134
  // recorded, we cannot evaluate the pool-delta — treat as eligible so a
135
135
  // fresh stash runs consolidate once before the steady-state gate kicks in.
136
+ //
137
+ // R4: the volume override is bootstrap-only — it exists to force that same
138
+ // "fresh stash, consolidate once" run when the pool is already large enough
139
+ // that waiting for the steady-state gate would be wasteful. Once a
140
+ // consolidate_completed event exists, the pool-delta gate below governs on
141
+ // its own; a large eligible pool no longer bypasses it.
136
142
  const memoryUpdatedAfterLastConsolidate = (() => {
137
- if (volumeTriggered)
138
- return true; // volume override forces the run regardless.
139
143
  if (!lastConsolidateTs)
140
- return true; // bootstrap path: never consolidated.
144
+ return true; // bootstrap path: never consolidated (volume override included).
141
145
  if (!primaryStashDir)
142
146
  return false;
143
147
  const memoriesDir = path.join(primaryStashDir, "memories");
@@ -172,7 +176,10 @@ function evaluateConsolidationEligibility(args) {
172
176
  return false;
173
177
  }
174
178
  })();
175
- const consolidationOnCooldown = !volumeTriggered && !memoryUpdatedAfterLastConsolidate;
179
+ // R4: no longer `!volumeTriggered && ...` — the volume override only ever
180
+ // applies at bootstrap (see `memoryUpdatedAfterLastConsolidate` above), so
181
+ // the pool-delta result alone determines cooldown post-bootstrap.
182
+ const consolidationOnCooldown = !memoryUpdatedAfterLastConsolidate;
176
183
  // Profile gate: if profile explicitly disables consolidate, skip the entire pass.
177
184
  const consolidateDisabledByProfile = improveProfile?.processes?.consolidate?.enabled === false;
178
185
  // #553 minPoolSize guard: skip consolidation when the eligible memory pool is
@@ -225,11 +232,13 @@ function planConsolidationPass(args) {
225
232
  maxChunkSize: processConfig?.maxChunkSize,
226
233
  };
227
234
  const poolWarnings = [];
235
+ // Same hash set the live run's pre-filter uses (R2-1), so the preview's
236
+ // candidate pool and eligibility gate agree with what the run will act on.
237
+ // Reuse the caller's set when given one (runConsolidationPass) instead of
238
+ // walking knowledge/ again here.
228
239
  const pool = primaryStashDir
229
- ? inspectConsolidationPool(effectiveOptions, primaryStashDir, poolWarnings, {
230
- readOnly: eventsCtx?.readOnly === true,
231
- })
232
- : { poolSize: 0, candidatePoolSize: 0, dedupPoolSize: 0, memories: [] };
240
+ ? inspectConsolidationPool(effectiveOptions, primaryStashDir, poolWarnings, args.existingKnowledgeBodyHashes ?? loadExistingKnowledgeBodyHashes(primaryStashDir), { readOnly: eventsCtx?.readOnly === true })
241
+ : { poolSize: 0, candidatePoolSize: 0, dedupPoolSize: 0, memories: [], prefilteredAlreadyPromoted: 0 };
233
242
  // #800/#957 round 3 — a credential-unavailable consolidate engine still
234
243
  // resolved a context length structurally; read it off the `engineUnavailable`
235
244
  // entry instead of falling back to the generic default, so a dry-run
@@ -284,7 +293,11 @@ function planConsolidationPass(args) {
284
293
  },
285
294
  delta: {
286
295
  passed: deltaPassed,
287
- reason: deltaPassed ? "memory pool has work" : "no updates since the last completed consolidation",
296
+ reason: !deltaPassed
297
+ ? "no updates since the last completed consolidation"
298
+ : eligibility.lastConsolidationTs
299
+ ? "memory pool has work"
300
+ : "no completed consolidation recorded yet (bootstrap)",
288
301
  },
289
302
  },
290
303
  wouldRun,
@@ -297,6 +310,10 @@ export async function runConsolidationPass(args) {
297
310
  const { options, primaryStashDir, memorySummary, improveProfile, resolvedPlan, eventsCtx, budgetSignal, runBudgetMs, } = args;
298
311
  const baseConfig = options.config ?? loadConfig();
299
312
  const consolidationConfig = baseConfig;
313
+ // Computed once here and reused by both the pool preview below and the
314
+ // akmConsolidate call further down (R2-1/R3-1) — knowledge/ can hold
315
+ // thousands of files, so walking it twice per run would double that cost.
316
+ const existingKnowledgeBodyHashes = primaryStashDir ? loadExistingKnowledgeBodyHashes(primaryStashDir) : undefined;
300
317
  const planned = planConsolidationPass({
301
318
  options,
302
319
  primaryStashDir,
@@ -304,6 +321,7 @@ export async function runConsolidationPass(args) {
304
321
  improveProfile,
305
322
  resolvedPlan,
306
323
  eventsCtx,
324
+ existingKnowledgeBodyHashes,
307
325
  });
308
326
  const { volumeTriggered, consolidationOnCooldown, consolidateDisabledByProfile, poolBelowMinSize, eligiblePoolSize, minPoolSize, lastConsolidationTs, } = planned.eligibility;
309
327
  let consolidation = {
@@ -351,6 +369,9 @@ export async function runConsolidationPass(args) {
351
369
  improveProfile,
352
370
  llmRunner: resolvedPlan.processes.consolidate.runner,
353
371
  autoTriggered: volumeTriggered,
372
+ // Reuse the hash set computed above instead of a second knowledge/
373
+ // walk inside akmConsolidateInner (R2-1/R3-1).
374
+ existingKnowledgeBodyHashes,
354
375
  // Tie consolidate proposals back to this improve invocation so
355
376
  // accept-rate-per-run aggregation works. Mirrors reflect/propose/extract.
356
377
  sourceRun: `consolidate-${Date.now()}`,
@@ -375,12 +396,14 @@ export async function runConsolidationPass(args) {
375
396
  (consolidation.failedChunkMemories ?? 0) === 0 &&
376
397
  (consolidation.failedPromotions ?? 0) === 0 &&
377
398
  (consolidation.deferredMemories ?? 0) === 0;
378
- const hasUnappliedAdvisoryOperations = consolidation.planned?.some((op) => op.op !== "promote") ?? false;
379
- if (consolidation.ok &&
380
- !consolidation.dryRun &&
381
- complete &&
382
- !hasUnappliedAdvisoryOperations &&
383
- consolidation.processed > 0) {
399
+ // R4: advisory ops (merge/delete/contradict) are never auto-applied — see
400
+ // consolidate.ts — so a run that plans some is still a completed pass over
401
+ // the pool, not an incomplete one. Gating the event on zero advisory ops
402
+ // meant it was never emitted in practice, which kept the pool-delta gate
403
+ // permanently bootstrapped. Record the unapplied count for reporting
404
+ // instead of withholding the event.
405
+ const advisoryOpsUnapplied = consolidation.planned?.filter((op) => op.op !== "promote").length ?? 0;
406
+ if (consolidation.ok && !consolidation.dryRun && complete && consolidation.processed > 0) {
384
407
  appendEvent({
385
408
  eventType: "consolidate_completed",
386
409
  ref: makeBundleRef(sourceName, "memories/_consolidation"),
@@ -393,6 +416,7 @@ export async function runConsolidationPass(args) {
393
416
  contradicted: consolidation.contradicted,
394
417
  failedChunks: consolidation.failedChunks ?? 0,
395
418
  durationMs: consolidation.durationMs,
419
+ advisoryOpsUnapplied,
396
420
  },
397
421
  }, eventsCtx);
398
422
  }
@@ -408,7 +432,16 @@ export async function runConsolidationPass(args) {
408
432
  }, eventsCtx);
409
433
  info("[improve] consolidation skipped (no memory updates since last run)");
410
434
  }
411
- // D9: track whether consolidation wrote any data so graph extraction can reindex if needed
435
+ // D9: track whether this was a qualifying consolidation cycle for R5's longitudinal collapse
436
+ // detector (loop-stages.ts, gated on this flag). `processed` counts memories the LLM JUDGED,
437
+ // not files consolidation WROTE — R4's advisory gate (consolidate.ts) means merge/delete/
438
+ // contradict ops are never auto-applied, and the only op that does execute, promote, calls
439
+ // emitProposal → createProposal, which persists to the `proposals` table in state.db, not to
440
+ // any file under the stash (src/commands/proposal/repository.ts). So `processed > 0` is the
441
+ // right gate here: the detector needs one snapshot per cycle where consolidate did work,
442
+ // regardless of whether that work produced a write. It would be the wrong gate for anything
443
+ // that needs to know whether a stash file changed, since promote/merge/delete/contradict never
444
+ // write one.
412
445
  const consolidationRan = !consolidateDisabledByProfile &&
413
446
  !poolBelowMinSize &&
414
447
  !consolidationOnCooldown &&
@@ -48,7 +48,8 @@ import { collectDispatchSensitiveValues } from "../../integrations/agent/runner-
48
48
  import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js";
49
49
  import { callStructured } from "../../llm/structured-call.js";
50
50
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
51
- import { isProposalSkipped, listProposalsReadOnly, proposalContent, recordGateDecision, } from "../proposal/repository.js";
51
+ import { isStaleTargetRejection } from "../proposal/proposal-types.js";
52
+ import { isProposalSkipped, listProposalsReadOnly, recordGateDecision, } from "../proposal/repository.js";
52
53
  import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
53
54
  import { CHARS_PER_TOKEN, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
54
55
  import { deriveLessonRef } from "./distill.js";
@@ -75,7 +76,7 @@ const MAX_GLOBAL_FEEDBACK_LINES = 20;
75
76
  * all assets so `akm reflect` can operate in a general "review recent
76
77
  * signals" mode. Best-effort — a missing or empty events stream returns `[]`.
77
78
  */
78
- function readOnlyEventsContext(ctx) {
79
+ export function readOnlyEventsContext(ctx) {
79
80
  return ctx?.db ? ctx : { ...(ctx ?? {}), readOnly: true };
80
81
  }
81
82
  function readRecentFeedback(ref, eventsCtx) {
@@ -148,13 +149,22 @@ const PROTECTED_FRONTMATTER_FIELDS = new Set(["name", "ref", "id", "slug", "type
148
149
  function readRejectedProposals(stash, ref, proposalsCtx) {
149
150
  if (!ref)
150
151
  return [];
152
+ // Exclude the drain's stale-target auto-rejects (STALE, R20): those are a
153
+ // procedural refusal (the target changed after mint), not a judgement on
154
+ // the content, and would mislead this Reflexion-style "don't repeat this"
155
+ // context.
151
156
  return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
157
+ .filter((p) => !isStaleTargetRejection(p))
152
158
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
153
159
  .slice(0, MAX_REJECTED_PROPOSALS)
154
160
  .map((p) => ({
155
161
  ref: p.ref,
156
162
  reason: p.review?.reason ?? "no reason given",
157
- contentPreview: proposalContent(p).slice(0, 500),
163
+ // #legacy: `changes` is empty for pre-existing rows (storedToChanges),
164
+ // which makes `proposalContent` throw before reflect dispatch even
165
+ // runs. `payload.content` is populated for every row regardless, so
166
+ // read the preview from there instead.
167
+ contentPreview: p.payload.content.slice(0, 500),
158
168
  }));
159
169
  }
160
170
  /**
@@ -1001,13 +1011,14 @@ async function finalizeReflectProposal(args) {
1001
1011
  qualityRejected: true,
1002
1012
  qualityScore: judgeResult.score,
1003
1013
  qualityReason: judgeResult.reason,
1014
+ ...(judgeResult.criteria ? { qualityCriteria: judgeResult.criteria } : {}),
1004
1015
  ...(outputTelemetry ?? {}),
1005
1016
  },
1006
1017
  }, options.eventsCtx);
1007
1018
  return {
1008
1019
  schemaVersion: 2,
1009
1020
  ok: false,
1010
- reason: "parse_error",
1021
+ reason: "quality_rejected",
1011
1022
  error: `Reflect proposal quality gate rejected: score=${judgeResult.score}, reason="${judgeResult.reason}"`,
1012
1023
  ...(options.ref ? { ref: options.ref } : {}),
1013
1024
  engine: engineName,
@@ -1786,7 +1797,7 @@ export async function akmReflect(options = {}) {
1786
1797
  let qualityJudgeSelection = resolveReflectQualityJudgeRunner(config, runnerSpec, isReflectQualityGateEnabled(activeStrategy), collectExecutionNotices);
1787
1798
  const qualityGateSkippedNoJudge = qualityJudgeSelection.enabled && !qualityJudgeSelection.runner;
1788
1799
  if (qualityGateSkippedNoJudge) {
1789
- warnOnce("reflect-quality-gate-no-judge", "Reflect proposal quality gate has no LLM configured to judge proposals (set defaults.llmEngine, or improve.strategies.<name>.processes.reflect.qualityGate.engine). Skipping the gate for this run; the proposal is queued for human review instead.");
1800
+ warnOnce("reflect-quality-gate-no-judge", "Reflect proposal quality gate has no LLM configured to judge proposals (set defaults.llmEngine). Skipping the gate for this run; the proposal is queued for human review instead.");
1790
1801
  qualityJudgeSelection = Object.freeze({ enabled: false, runner: undefined });
1791
1802
  }
1792
1803
  const qualityJudgeRunner = qualityJudgeSelection.runner;
@@ -19,13 +19,3 @@ export const MAX_REJECTED_PROPOSALS = 3;
19
19
  export function errMessage(e) {
20
20
  return e instanceof Error ? e.message : String(e);
21
21
  }
22
- /**
23
- * Slugify an asset ref for use in eval-case / rejection filenames: lowercase,
24
- * non-alphanumerics collapsed to `-`, capped at 60 characters.
25
- */
26
- export function refSlug(ref) {
27
- return ref
28
- .replace(/[^a-z0-9]/gi, "-")
29
- .toLowerCase()
30
- .slice(0, 60);
31
- }
@@ -38,7 +38,7 @@ import { createHash } from "node:crypto";
38
38
  import fs from "node:fs";
39
39
  import path from "node:path";
40
40
  import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
41
- import { parseFrontmatter } from "../../core/asset/frontmatter.js";
41
+ import { computeNormalizedContentHash, parseFrontmatter } from "../../core/asset/frontmatter.js";
42
42
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
43
43
  import { ConfigError } from "../../core/errors.js";
44
44
  import { appendEvent } from "../../core/events.js";
@@ -47,6 +47,7 @@ import { info, warn } from "../../core/warn.js";
47
47
  import { acquireLoweredExecutionDispatchLease, dispatchLoweredExecutionRequest, disposeLoweredExecutionDispatchLease, lowerResolvedExecutionRequestWithRunner, } from "../../integrations/agent/execution-lowering.js";
48
48
  import { prepareInlineExecutionWithRunner } from "../../integrations/agent/inline-execution.js";
49
49
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
50
+ import { STALE_TARGET_GATE_REASON } from "./proposal-types.js";
50
51
  import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, recordGateDecision, } from "./repository.js";
51
52
  // ---------------------------------------------------------------------------
52
53
  // Content helpers
@@ -131,7 +132,7 @@ function deferReasonForSource(source) {
131
132
  */
132
133
  function categorizeDrainFailure(message, fallback) {
133
134
  if (/target (?:changed after|was created after) proposal/.test(message))
134
- return "stale-target";
135
+ return STALE_TARGET_GATE_REASON;
135
136
  if (/failed validation:/.test(message))
136
137
  return "validation";
137
138
  return fallback;
@@ -141,6 +142,33 @@ function pushDrainFailure(result, id, err, fallbackReason) {
141
142
  result.failed.push({ id, reason: categorizeDrainFailure(message, fallbackReason), detail: message });
142
143
  return message;
143
144
  }
145
+ /**
146
+ * A `stale-target` promote failure (STALE, R20) is not a merit rejection —
147
+ * the guard tripped because the target changed after mint (often akm's own
148
+ * bookkeeping), not because of anything wrong with the proposed content. So
149
+ * instead of leaving the row pending to retry and fail identically every run,
150
+ * the drain auto-rejects it once with a structured marker.
151
+ * `checkFingerprintAndBackoff` (repository.ts) excludes this reason from
152
+ * rejection-backoff, so the ref stays re-proposable against its current
153
+ * content. Returns `true` when the reject succeeded (the caller should treat
154
+ * the item as resolved, not failed); `false` leaves it to the caller's
155
+ * existing failure handling.
156
+ */
157
+ async function autoRejectStaleTarget(stashDir, gateLabel, id, message, rejectFn) {
158
+ try {
159
+ await rejectFn({
160
+ stashDir,
161
+ id,
162
+ reason: `stale-target: ${message}`,
163
+ gateDecision: { outcome: "auto-rejected", reason: STALE_TARGET_GATE_REASON, gate: gateLabel },
164
+ });
165
+ return true;
166
+ }
167
+ catch (err) {
168
+ warn(`[triage] stale-target auto-reject failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
169
+ return false;
170
+ }
171
+ }
144
172
  /**
145
173
  * Mirror repository.ts's `promoteProposalWithLease` stale-target guard so a
146
174
  * dry-run preflight predicts the same refusal a real promote would hit,
@@ -149,9 +177,17 @@ function pushDrainFailure(result, id, err, fallbackReason) {
149
177
  */
150
178
  function assertProposalTargetFresh(proposal, assetPath) {
151
179
  const backup = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
152
- const currentHash = backup ? createHash("sha256").update(backup).digest("hex") : undefined;
153
- if (proposal.beforeHash !== undefined && (!backup || currentHash !== proposal.beforeHash)) {
154
- throw new Error(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
180
+ if (proposal.beforeHash !== undefined) {
181
+ // STALE (R20): mirrors repository.ts's promote guard — a normalized
182
+ // before-hash is insensitive to a same-run bookkeeping rewrite of the
183
+ // target; a legacy proposal without one keeps the raw-hash check.
184
+ const fresh = proposal.beforeHashNormalized !== undefined
185
+ ? backup !== undefined &&
186
+ computeNormalizedContentHash(backup.toString("utf8")) === proposal.beforeHashNormalized
187
+ : backup !== undefined && createHash("sha256").update(backup).digest("hex") === proposal.beforeHash;
188
+ if (!fresh) {
189
+ throw new Error(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
190
+ }
155
191
  }
156
192
  if (proposal.beforeHash === undefined &&
157
193
  backup !== undefined &&
@@ -404,14 +440,20 @@ async function runJudgmentTier(input) {
404
440
  if (input.dryRun) {
405
441
  try {
406
442
  if (input.config) {
407
- preflightProposalPromotion(input.config, proposal, {
443
+ const preflight = preflightProposalPromotion(input.config, proposal, {
408
444
  ...(input.target ? { target: input.target } : {}),
409
445
  gateDecision: { outcome: "auto-accepted", reason: "judgment-accept", gate: input.gateLabel },
410
446
  });
447
+ assertProposalTargetFresh(proposal, preflight.assetPath);
411
448
  }
412
449
  }
413
450
  catch (err) {
414
- warn(`[triage] judgment preflight failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
451
+ const message = err instanceof Error ? err.message : String(err);
452
+ if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON) {
453
+ rejected.push(item.id);
454
+ continue;
455
+ }
456
+ warn(`[triage] judgment preflight failed for ${item.id}: ${message}`);
415
457
  stillDeferred.push(item);
416
458
  continue;
417
459
  }
@@ -431,7 +473,13 @@ async function runJudgmentTier(input) {
431
473
  acceptBudget -= 1;
432
474
  }
433
475
  catch (err) {
434
- warn(`[triage] judgment promote failed for ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
476
+ const message = err instanceof Error ? err.message : String(err);
477
+ if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON &&
478
+ (await autoRejectStaleTarget(input.stashDir, input.gateLabel, item.id, message, input.rejectFn))) {
479
+ rejected.push(item.id);
480
+ continue;
481
+ }
482
+ warn(`[triage] judgment promote failed for ${item.id}: ${message}`);
435
483
  stillDeferred.push(item);
436
484
  }
437
485
  }
@@ -467,6 +515,16 @@ function classifyPendingProposals(opts) {
467
515
  if (proposal.gateDecision?.outcome === "auto-rejected" && !proposal.gateDecision.gate?.startsWith("triage:")) {
468
516
  continue;
469
517
  }
518
+ // REVIEW: a `review_needed` distill/promote-memory row is stamped
519
+ // `deferred`/`quality-gate` by `writeQualityRejection` (distill/quality-gate.ts)
520
+ // precisely because the quality judge could not decide and wants a human,
521
+ // not the judgment tier, to see it. Skip it here — before `classifyProposal`
522
+ // would otherwise defer it to the judgment tier (which can auto-accept
523
+ // under `applyMode: promote`) and before the policy-deferred re-stamp loop
524
+ // in `drainProposals` would overwrite this stamp with a `triage:` one.
525
+ if (proposal.gateDecision?.outcome === "deferred" && proposal.gateDecision.gate === "quality-gate") {
526
+ continue;
527
+ }
470
528
  if (proposal.gateDecision?.outcome === "staged" &&
471
529
  proposal.gateDecision.gate === gateLabel &&
472
530
  proposal.gateDecision.contentHash === judgedContentHash(proposal)) {
@@ -588,7 +646,13 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
588
646
  deterministicPromoted += 1;
589
647
  }
590
648
  catch (err) {
591
- const message = pushDrainFailure(result, id, err, "promote-error");
649
+ const message = err instanceof Error ? err.message : String(err);
650
+ if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON &&
651
+ (await autoRejectStaleTarget(opts.stashDir, gateLabel, id, message, rejectFn))) {
652
+ result.rejected.push(id);
653
+ continue;
654
+ }
655
+ pushDrainFailure(result, id, err, "promote-error");
592
656
  warn(`[triage] promote failed for ${id}: ${message}`);
593
657
  }
594
658
  }
@@ -618,7 +682,12 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
618
682
  deterministicPromoted += 1;
619
683
  }
620
684
  catch (err) {
621
- const message = pushDrainFailure(result, id, err, "promote-error");
685
+ const message = err instanceof Error ? err.message : String(err);
686
+ if (categorizeDrainFailure(message, "promote-error") === STALE_TARGET_GATE_REASON) {
687
+ result.rejected.push(id);
688
+ continue;
689
+ }
690
+ pushDrainFailure(result, id, err, "promote-error");
622
691
  warn(`[triage] preflight failed for ${id}: ${message}`);
623
692
  }
624
693
  }
@@ -54,3 +54,24 @@ export function isValidProposalSource(source) {
54
54
  export function isAutomatedProposalSource(source) {
55
55
  return AUTOMATED_PROPOSAL_SOURCES.includes(source);
56
56
  }
57
+ /**
58
+ * Gate-decision reason token for a promote refusal caused by the TARGET
59
+ * changing after mint — often akm's own bookkeeping, not a merit judgement on
60
+ * the proposed content (STALE, R20). The drain stamps this on the auto-reject
61
+ * it issues instead of retrying a promote that will fail identically forever;
62
+ * {@link isStaleTargetRejection} and the rejection-backoff guard
63
+ * (`repository.ts`'s `checkFingerprintAndBackoff`) both key off the exact
64
+ * string so they can't drift apart.
65
+ */
66
+ export const STALE_TARGET_GATE_REASON = "stale-target";
67
+ /**
68
+ * True for a rejected proposal whose rejection was the drain's stale-target
69
+ * auto-reject (STALE, R20), not a judgement on the proposed content. Readers
70
+ * that treat a rejection as "this content was refused" — the Reflexion
71
+ * "previously rejected" context (`improve/reflect.ts`, `improve/distill.ts`)
72
+ * and the accept-rate health metric (`health/accept-rate.ts`) — exclude these
73
+ * rows so a procedural refusal doesn't misrepresent content quality.
74
+ */
75
+ export function isStaleTargetRejection(proposal) {
76
+ return (proposal.gateDecision?.outcome === "auto-rejected" && proposal.gateDecision.reason === STALE_TARGET_GATE_REASON);
77
+ }