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.
- package/CHANGELOG.md +504 -0
- package/dist/assets/prompts/consolidate-system.md +4 -11
- package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
- package/dist/commands/health/accept-rate.js +6 -0
- package/dist/commands/health/checks.js +54 -0
- package/dist/commands/health/improve-metrics.js +1 -5
- package/dist/commands/health/report-view-model.js +0 -1
- package/dist/commands/health.js +10 -0
- package/dist/commands/improve/consolidate/chunking.js +19 -35
- package/dist/commands/improve/consolidate/merge.js +6 -9
- package/dist/commands/improve/consolidate.js +104 -91
- package/dist/commands/improve/distill/promote-memory.js +40 -2
- package/dist/commands/improve/distill/quality-gate.js +186 -23
- package/dist/commands/improve/distill.js +42 -8
- package/dist/commands/improve/eligibility.js +13 -3
- package/dist/commands/improve/improve-cli.js +32 -9
- package/dist/commands/improve/improve-strategies.js +23 -1
- package/dist/commands/improve/improve.js +121 -84
- package/dist/commands/improve/loop-stages.js +241 -108
- package/dist/commands/improve/preparation.js +50 -17
- package/dist/commands/improve/reflect.js +16 -5
- package/dist/commands/improve/shared.js +0 -10
- package/dist/commands/proposal/drain.js +79 -10
- package/dist/commands/proposal/proposal-types.js +21 -0
- package/dist/commands/proposal/repository.js +108 -29
- package/dist/commands/tasks/tasks.js +19 -2
- package/dist/core/asset/frontmatter.js +106 -1
- package/dist/core/config/config.js +5 -2
- package/dist/core/config/retired-experimental-keys-shim.js +62 -0
- package/dist/core/config/schema/improve-processes.js +29 -2
- package/dist/core/improve-result.js +9 -0
- package/dist/core/paths.js +7 -0
- package/dist/core/write-source.js +10 -2
- package/dist/indexer/ensure-index.js +52 -7
- package/dist/indexer/graph/graph-extraction.js +82 -8
- package/dist/indexer/passes/memory-inference.js +16 -1
- package/dist/llm/client.js +16 -2
- package/dist/llm/graph-extract.js +162 -18
- package/dist/scripts/akm-migrate-node.js +97 -36
- package/dist/scripts/akm-migrate.js +97 -36
- package/dist/storage/repositories/index-entries-repository.js +43 -0
- package/dist/storage/repositories/proposals-repository.js +4 -1
- package/dist/storage/state-db-integrity.js +123 -0
- package/dist/workflows/program/schema.js +1 -0
- package/docs/reference/cli.md +17 -7
- package/docs/reference/data-and-telemetry.md +1 -0
- package/package.json +1 -1
- package/schemas/akm-config.json +44 -0
- package/schemas/akm-workflow.json +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|