akm-cli 0.9.18 → 0.9.19-alpha.1
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 +135 -0
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +4 -2
- package/dist/assets/hints/cli-hints-short.md +5 -3
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
- package/dist/commands/feedback-cli.js +1 -1
- package/dist/commands/improve/consolidate/coverage.js +132 -0
- package/dist/commands/improve/consolidate/pair-pass.js +30 -20
- package/dist/commands/improve/consolidate.js +43 -10
- package/dist/commands/improve/distill.js +7 -3
- package/dist/commands/improve/eligibility.js +39 -9
- package/dist/commands/improve/improve-cli.js +9 -6
- package/dist/commands/improve/improve.js +13 -4
- package/dist/commands/improve/ledger.js +2 -2
- package/dist/commands/improve/loop-stages.js +2 -0
- package/dist/commands/improve/preparation.js +1 -1
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/stage.js +38 -9
- package/dist/commands/proposal/diff-format.js +21 -0
- package/dist/commands/proposal/proposal-cli.js +48 -10
- package/dist/commands/proposal/proposal-types.js +11 -0
- package/dist/commands/proposal/proposal.js +60 -5
- package/dist/commands/proposal/repository.js +245 -17
- package/dist/commands/read/knowledge.js +13 -11
- package/dist/commands/read/remember-cli.js +7 -3
- package/dist/commands/sources/source-clone.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +1 -1
- package/dist/commands/tasks/tasks.js +10 -3
- package/dist/core/mutation-target.js +8 -3
- package/dist/core/write-source.js +3 -2
- package/dist/indexer/usage/usage-events.js +2 -1
- package/dist/output/shapes/helpers.js +7 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/shapes/proposal/reopen.js +14 -0
- package/dist/output/shapes.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/proposal/proposal.js +3 -1
- package/dist/output/text/proposal-format.js +87 -32
- package/dist/scripts/akm-migrate-node.js +79 -23
- package/dist/scripts/akm-migrate.js +79 -23
- package/dist/storage/repositories/improve-ledger-repository.js +65 -6
- package/dist/storage/repositories/index-vec-repository.js +13 -8
- package/dist/storage/repositories/proposals-repository.js +23 -0
- package/dist/tasks/run/load-task.js +5 -1
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.19.md +134 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/migration/v0.8-to-v0.9.md +5 -1
- package/docs/reference/cli.md +189 -28
- package/docs/reference/configuration.md +9 -8
- package/docs/reference/data-and-telemetry.md +19 -14
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
/** The refs an improve run may consider, read from the index, and the small predicates over them. */
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
7
8
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
8
9
|
import { conceptIdFromTypeName, parseRefInput, resolveRef, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
9
10
|
import { loadConfig } from "../../core/config/config.js";
|
|
@@ -109,6 +110,18 @@ export function dedupeRefs(refs) {
|
|
|
109
110
|
return true;
|
|
110
111
|
});
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The bundle an improve run reads candidates from and files proposals into: its
|
|
115
|
+
* write target, the source rooted at `stashDir` (else the working bundle).
|
|
116
|
+
* Reflect reads a candidate from its owning bundle but the proposal lands in
|
|
117
|
+
* the write target, so a candidate owned by any other bundle would be read from
|
|
118
|
+
* one bundle and filed in another (#1000).
|
|
119
|
+
*/
|
|
120
|
+
function runBundleIdOf(sources, installations, stashDir) {
|
|
121
|
+
const root = stashDir === undefined ? undefined : path.resolve(stashDir);
|
|
122
|
+
const index = sources.findIndex((source) => root === undefined ? source.isDefault === true : path.resolve(source.path) === root);
|
|
123
|
+
return installations[index]?.id;
|
|
124
|
+
}
|
|
112
125
|
export async function collectEligibleRefs(scope, stashDir, improveProfile, config) {
|
|
113
126
|
return collectEligibleRefsFromIndex(scope, stashDir, improveProfile, false, config);
|
|
114
127
|
}
|
|
@@ -135,6 +148,9 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
135
148
|
return empty();
|
|
136
149
|
const installations = deriveInstallations(sources);
|
|
137
150
|
const writableBundleIds = deriveWritableBundleIds(sources);
|
|
151
|
+
const runBundleId = runBundleIdOf(sources, installations, stashDir);
|
|
152
|
+
// Candidates come from the write target alone, and only while it is writable.
|
|
153
|
+
const isCandidateBundle = (bundleId) => bundleId !== undefined && bundleId === runBundleId && writableBundleIds.has(bundleId);
|
|
138
154
|
const ready = {
|
|
139
155
|
status: "ready",
|
|
140
156
|
reason: readOnly ? "loaded a non-mutating point-in-time copy of the existing index" : "loaded the prepared index",
|
|
@@ -152,20 +168,34 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
152
168
|
return empty(missing);
|
|
153
169
|
if (scope.mode === "ref" && scope.value) {
|
|
154
170
|
const entriesByItemRef = new Map(getAllEntries(db).map((entry) => [entry.itemRef, entry]));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
let resolved;
|
|
172
|
+
try {
|
|
173
|
+
resolved = resolveRef(scope.value, {
|
|
174
|
+
defaultBundle: config.defaultBundle,
|
|
175
|
+
bundles: installations.map((installation) => ({
|
|
176
|
+
id: installation.id,
|
|
177
|
+
hasConcept: (conceptId) => entriesByItemRef.has(`${installation.id}//${conceptId}`),
|
|
178
|
+
})),
|
|
179
|
+
...(runBundleId ? { only: runBundleId } : {}),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (!(error instanceof NotFoundError) || !runBundleId)
|
|
184
|
+
throw error;
|
|
185
|
+
// Name the bundle that owns the ref; a typo or an unindexed ref keeps the default advice.
|
|
186
|
+
const { conceptId } = parseBundleRef(scope.value);
|
|
187
|
+
const owner = installations.find((installation) => installation.id !== runBundleId && entriesByItemRef.has(`${installation.id}//${conceptId}`))?.id;
|
|
188
|
+
if (!owner)
|
|
189
|
+
throw error;
|
|
190
|
+
throw new NotFoundError(error.message, error.code, `"${conceptId}" is in bundle "${owner}"; this run improves bundle "${runBundleId}" only. Run \`akm improve ${owner}//${conceptId}\` (or pass \`--bundle ${owner}\`).`);
|
|
191
|
+
}
|
|
162
192
|
const indexed = entriesByItemRef.get(`${resolved.bundle}//${resolved.conceptId}`);
|
|
163
193
|
if (!indexed?.bundleId || !indexed.conceptId || !fs.existsSync(indexed.filePath)) {
|
|
164
194
|
if (await findAssetFilePath(scope.value, stashDir))
|
|
165
195
|
return empty(ready);
|
|
166
196
|
throw new NotFoundError(`Asset not found in the selected writable source: ${scope.value}`, "ASSET_NOT_FOUND");
|
|
167
197
|
}
|
|
168
|
-
if (!
|
|
198
|
+
if (!isCandidateBundle(indexed.bundleId))
|
|
169
199
|
return empty(ready);
|
|
170
200
|
const isMemory = indexed.entry.type === "memory";
|
|
171
201
|
return {
|
|
@@ -180,7 +210,7 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
180
210
|
indexSnapshot: ready,
|
|
181
211
|
};
|
|
182
212
|
}
|
|
183
|
-
const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) =>
|
|
213
|
+
const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) => isCandidateBundle(indexed.bundleId));
|
|
184
214
|
const planned = new Map();
|
|
185
215
|
const strategyFiltered = new Map();
|
|
186
216
|
let memoryEligible = 0;
|
|
@@ -19,7 +19,7 @@ import { collectEngineCredentialValues } from "../../integrations/agent/engine-r
|
|
|
19
19
|
import { probeLlmReachable } from "../../llm/client.js";
|
|
20
20
|
import { getOutputMode } from "../../output/context.js";
|
|
21
21
|
import { deliverRendered } from "../../output/html-render.js";
|
|
22
|
-
import { akmImprove, resolveImproveReadSource } from "./improve.js";
|
|
22
|
+
import { akmImprove, IMPROVE_TARGET_FLAG, resolveImproveReadSource } from "./improve.js";
|
|
23
23
|
import { runImproveReportQuery } from "./improve-report.js";
|
|
24
24
|
import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
|
|
25
25
|
import { runImproveSession } from "./improve-session.js";
|
|
@@ -209,8 +209,11 @@ export const improveCommand = defineCommand({
|
|
|
209
209
|
description: "Alias for --dry-run (#947). Sets the exact same internal flag; use it when previewing resolved process -> engine -> model routing (plan.processes) rather than checking what would write.",
|
|
210
210
|
default: false,
|
|
211
211
|
},
|
|
212
|
-
bundle: {
|
|
213
|
-
|
|
212
|
+
bundle: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "Bundle to improve and write proposals to (default: defaultWriteTarget, else the working bundle); only its assets are planned",
|
|
215
|
+
},
|
|
216
|
+
limit: { type: "string", description: "Maximum number of assets to process (highest salience first)" },
|
|
214
217
|
"timeout-ms": {
|
|
215
218
|
type: "string",
|
|
216
219
|
description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
|
|
@@ -227,7 +230,7 @@ export const improveCommand = defineCommand({
|
|
|
227
230
|
},
|
|
228
231
|
"skip-if-locked": {
|
|
229
232
|
type: "boolean",
|
|
230
|
-
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit
|
|
233
|
+
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 75). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
|
|
231
234
|
default: false,
|
|
232
235
|
},
|
|
233
236
|
"require-engines": {
|
|
@@ -286,8 +289,8 @@ export const improveCommand = defineCommand({
|
|
|
286
289
|
const writeTarget = dryRun
|
|
287
290
|
? undefined
|
|
288
291
|
: scopeRef
|
|
289
|
-
? resolveMutationTarget(effectiveConfig, scopeRef, targetArg).target
|
|
290
|
-
: resolveWriteTarget(effectiveConfig, targetArg);
|
|
292
|
+
? resolveMutationTarget(effectiveConfig, scopeRef, targetArg, { flag: IMPROVE_TARGET_FLAG }).target
|
|
293
|
+
: resolveWriteTarget(effectiveConfig, targetArg, { flag: IMPROVE_TARGET_FLAG });
|
|
291
294
|
// Every model-backed process resolves before any side effect; a dry run
|
|
292
295
|
// never dispatches, so it tolerates every process being disabled.
|
|
293
296
|
const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
|
|
@@ -19,7 +19,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
|
|
|
19
19
|
import { openStateDatabase } from "../../core/state-db.js";
|
|
20
20
|
import { info, warn, warnVerbose } from "../../core/warn.js";
|
|
21
21
|
import { beginWriteProvenance, relativeWrittenPath } from "../../core/write-provenance.js";
|
|
22
|
-
import { resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
|
|
22
|
+
import { resolveWorkingStashTarget, resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
|
|
23
23
|
import { ensureIndex } from "../../indexer/ensure-index.js";
|
|
24
24
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
25
25
|
import { akmIndex } from "../../indexer/indexer.js";
|
|
@@ -268,17 +268,24 @@ function describeRunWrittenPaths(setup, writtenPaths) {
|
|
|
268
268
|
}
|
|
269
269
|
return [...described].sort();
|
|
270
270
|
}
|
|
271
|
+
/** How `akm improve` spells its destination flag: the shared target resolvers name it in their errors. */
|
|
272
|
+
export const IMPROVE_TARGET_FLAG = "--bundle";
|
|
271
273
|
/**
|
|
272
274
|
* The source a dry run (or `--show-prompt`) inspects, without adapting it into
|
|
273
275
|
* a write target.
|
|
274
276
|
*/
|
|
275
277
|
export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
|
|
276
278
|
if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
|
|
277
|
-
throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with
|
|
279
|
+
throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with ${IMPROVE_TARGET_FLAG} "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop ${IMPROVE_TARGET_FLAG} or use ${IMPROVE_TARGET_FLAG} ${scopedRef.origin}.`);
|
|
278
280
|
}
|
|
279
281
|
const selector = scopedRef?.origin ?? explicitTarget ?? config.defaultWriteTarget;
|
|
280
282
|
if (!selector && fallbackStashDir)
|
|
281
283
|
return { source: { name: "stash", path: fallbackStashDir } };
|
|
284
|
+
if (!selector && process.env.AKM_BUNDLE_DIR?.trim()) {
|
|
285
|
+
// A live run's working bundle starts from AKM_BUNDLE_DIR (`resolveWorkingStashTarget`), so its preview does too.
|
|
286
|
+
const { source } = resolveWorkingStashTarget(config, { requireWritable: false });
|
|
287
|
+
return { source: { name: source.name, path: source.path } };
|
|
288
|
+
}
|
|
282
289
|
const configuredSelector = selector ?? config.defaultBundle;
|
|
283
290
|
if (configuredSelector) {
|
|
284
291
|
const entry = bundlesToSourceEntries(config)?.find((source) => source.name === configuredSelector);
|
|
@@ -339,10 +346,12 @@ function resolveImproveRunSetup(options) {
|
|
|
339
346
|
const writeTarget = options.dryRun
|
|
340
347
|
? undefined
|
|
341
348
|
: scopedRef?.origin
|
|
342
|
-
? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target
|
|
349
|
+
? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target, {
|
|
350
|
+
flag: IMPROVE_TARGET_FLAG,
|
|
351
|
+
}).target
|
|
343
352
|
: (options.writeTarget ??
|
|
344
353
|
(options.target || config.defaultWriteTarget || !options.stashDir
|
|
345
|
-
? resolveWriteTarget(config, options.target)
|
|
354
|
+
? resolveWriteTarget(config, options.target, { flag: IMPROVE_TARGET_FLAG })
|
|
346
355
|
: {
|
|
347
356
|
source: { kind: "filesystem", name: "stash", path: options.stashDir },
|
|
348
357
|
config: { type: "filesystem", name: "stash", path: options.stashDir, writable: true },
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
11
11
|
import { warn } from "../../core/warn.js";
|
|
12
|
-
import { isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
12
|
+
import { isContentDrivenRow, isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
13
13
|
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
|
|
14
|
-
export { isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
|
|
14
|
+
export { isContentDrivenRow, isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
|
|
15
15
|
/** An improve candidate's durable state key: its index item_ref, else its conceptId. */
|
|
16
16
|
export function stateKey(ref, itemRef) {
|
|
17
17
|
return itemRef ?? ref;
|
|
@@ -136,6 +136,8 @@ async function runLoopReflectPass(planned, env, tally) {
|
|
|
136
136
|
if (reflectErrors.length > 0)
|
|
137
137
|
tally.reflectsWithErrorContext++;
|
|
138
138
|
const budgetMs = env.remainingBudgetMs();
|
|
139
|
+
// `planned` was selected from the run's write target alone (eligibility.ts), so the
|
|
140
|
+
// `target` below is the bundle `planned.itemRef` is read from and the proposal is filed in.
|
|
139
141
|
const reflectArgs = {
|
|
140
142
|
ref: planned.ref,
|
|
141
143
|
...(planned.itemRef ? { itemRef: planned.itemRef } : {}),
|
|
@@ -793,7 +793,7 @@ function fetchRetrievalSignals(options, signalFiltered, noFeedbackCandidates, ev
|
|
|
793
793
|
// usage_events live in state.db, entries in index.db.
|
|
794
794
|
withRunState(eventsCtx, persist, (stateDb) => {
|
|
795
795
|
if (countUsageEventsByType(stateDb, "show") === 0) {
|
|
796
|
-
warn("Warning: show events not yet in usage_events —
|
|
796
|
+
warn("Warning: show events not yet in usage_events — the retrieval scope will match only search-retrieved assets.");
|
|
797
797
|
}
|
|
798
798
|
const refs = [...new Set([...signalFiltered, ...noFeedbackCandidates].map((r) => r.ref))];
|
|
799
799
|
out.retrievalCounts = getRetrievalCounts(indexDb, stateDb, refs, { sourceName: options.sourceName });
|
|
@@ -1036,7 +1036,7 @@ async function finalizeReflectProposal(args) {
|
|
|
1036
1036
|
payload: { content: payload.content, ...(Object.keys(frontmatter).length > 0 ? { frontmatter } : {}) },
|
|
1037
1037
|
...(typeof payload.confidence === "number" ? { confidence: payload.confidence } : {}),
|
|
1038
1038
|
...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
|
|
1039
|
-
...(options.itemRef ? { attemptedRefs: [options.itemRef] } : {}),
|
|
1039
|
+
...(options.itemRef ? { itemRef: options.itemRef, attemptedRefs: [options.itemRef] } : {}),
|
|
1040
1040
|
}, reviewReasons.length > 0
|
|
1041
1041
|
? {
|
|
1042
1042
|
review: {
|
|
@@ -158,18 +158,20 @@ export function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
|
|
|
158
158
|
"Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
|
|
159
159
|
"1. NOVELTY: Does the lesson add information not already present in the source asset?",
|
|
160
160
|
"2. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
|
|
161
|
+
"3. GROUNDING: Is the lesson about what the source asset is about? Score 1-2 only if it is about a different subject than the source; 3 if it is on the source's subject but goes beyond or corrects what the source says (it may draw on feedback you are not shown); 4-5 if the source supports it. A lesson may generalize the source's point.",
|
|
161
162
|
"",
|
|
162
163
|
"Source asset content:",
|
|
163
164
|
"```",
|
|
164
|
-
|
|
165
|
+
// The window distill generates from (buildDistillPrompt): grounding can reject, so the judge reads all of it.
|
|
166
|
+
sourceContent.slice(0, 3000),
|
|
165
167
|
"```",
|
|
166
168
|
];
|
|
167
169
|
if (similarLessons && similarLessons.length > 0) {
|
|
168
|
-
lines.push("", "Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
|
|
170
|
+
lines.push("", "Existing similar lessons (top-3 by similarity). Rate NOVELTY and NON-REDUNDANCY lower if the proposed lesson is substantially similar to any of these:");
|
|
169
171
|
for (const sl of similarLessons)
|
|
170
172
|
lines.push(`\nExisting lesson ref: ${sl.ref}`, "```", sl.content.slice(0, 500), "```");
|
|
171
173
|
}
|
|
172
|
-
lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>}, "reason": "<one sentence>"}');
|
|
174
|
+
lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>, "grounding": <1-5 integer>}, "reason": "<one sentence>"}');
|
|
173
175
|
return lines.join("\n");
|
|
174
176
|
}
|
|
175
177
|
function boundedDocument(content, maxChars = 6000) {
|
|
@@ -229,12 +231,26 @@ export function buildReflectJudgePrompt(candidateContent, sourceContent, feedbac
|
|
|
229
231
|
'Return ONLY valid JSON, no prose: {"scores": {"feedbackAlignment": <1-5 integer>, "preservation": <1-5 integer>, "quality": <1-5 integer>}, "reason": "<one sentence>"}',
|
|
230
232
|
].join("\n");
|
|
231
233
|
}
|
|
232
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `grounding` is scored with the other lesson criteria but left out of their
|
|
236
|
+
* mean: a lesson about a different subject than its source reads as novel and
|
|
237
|
+
* non-redundant, so the mean would pass it (or, in the review band, mint it as
|
|
238
|
+
* a pending proposal). A score of {@link UNGROUNDED_MAX_SCORE} or less is a
|
|
239
|
+
* rejection whatever the mean says (#999). Only a different subject scores that
|
|
240
|
+
* low. A lesson that goes beyond or corrects its source is on its subject:
|
|
241
|
+
* distill folds feedback into the lesson, and the judge is never shown it. A
|
|
242
|
+
* contradiction of the source is the optional fidelity check's to send to a
|
|
243
|
+
* human (`judgeAndQueue` in distill.ts), so the rubric must not pre-empt it.
|
|
244
|
+
*/
|
|
245
|
+
const GROUNDING_CRITERION = "grounding";
|
|
246
|
+
const UNGROUNDED_MAX_SCORE = 2;
|
|
247
|
+
const LESSON_JUDGE_CRITERIA = ["novelty", "nonRedundancy", GROUNDING_CRITERION];
|
|
233
248
|
const REFLECT_JUDGE_CRITERIA = ["feedbackAlignment", "preservation", "quality"];
|
|
234
249
|
/**
|
|
235
|
-
* Read a judge response: the per-criterion shape (averaged here
|
|
236
|
-
* `{"score"}` shape. Only the expected criteria are read;
|
|
237
|
-
* out-of-range (1..5) value is a parse failure, extra keys are
|
|
250
|
+
* Read a judge response: the per-criterion shape (averaged here, `grounding`
|
|
251
|
+
* aside) or the older `{"score"}` shape. Only the expected criteria are read;
|
|
252
|
+
* any missing or out-of-range (1..5) value is a parse failure, extra keys are
|
|
253
|
+
* ignored.
|
|
238
254
|
*/
|
|
239
255
|
function parseJudgeResponse(raw, keys) {
|
|
240
256
|
const parsed = parseEmbeddedJsonResponse(raw);
|
|
@@ -253,7 +269,10 @@ function parseJudgeResponse(raw, keys) {
|
|
|
253
269
|
return undefined;
|
|
254
270
|
criteria[key] = value;
|
|
255
271
|
}
|
|
256
|
-
|
|
272
|
+
const averaged = Object.entries(criteria)
|
|
273
|
+
.filter(([key]) => key !== GROUNDING_CRITERION)
|
|
274
|
+
.map(([, value]) => value);
|
|
275
|
+
return { score: averaged.reduce((a, b) => a + b, 0) / averaged.length, reason, criteria };
|
|
257
276
|
}
|
|
258
277
|
return inRange(parsed.score) ? { score: parsed.score, reason } : undefined;
|
|
259
278
|
}
|
|
@@ -276,7 +295,8 @@ function judgeResponseSchema(keys) {
|
|
|
276
295
|
/**
|
|
277
296
|
* The quality judge. Fails closed: no runner, an unparseable verdict or a
|
|
278
297
|
* provider failure never passes content. Bands: >= 3.5 pass, 2.5-3.5 review,
|
|
279
|
-
* < 2.5 reject
|
|
298
|
+
* < 2.5 reject; a `grounding` score of {@link UNGROUNDED_MAX_SCORE} or less
|
|
299
|
+
* rejects whatever the mean is. Temperature is pinned to 0 so verdicts do not flip.
|
|
280
300
|
*/
|
|
281
301
|
async function runQualityJudge(feature, config, prompt, keys, chat, options) {
|
|
282
302
|
const resolved = !options.runnerSelectionFrozen && !options.llmRunner
|
|
@@ -309,6 +329,15 @@ async function runQualityJudge(feature, config, prompt, keys, chat, options) {
|
|
|
309
329
|
if (!parsed)
|
|
310
330
|
return { pass: false, score: -1, reason: "judge parse failed — routed to review", reviewNeeded: true };
|
|
311
331
|
const { score, reason, criteria } = parsed;
|
|
332
|
+
const grounding = criteria?.[GROUNDING_CRITERION];
|
|
333
|
+
if (criteria && grounding !== undefined && grounding <= UNGROUNDED_MAX_SCORE) {
|
|
334
|
+
return {
|
|
335
|
+
pass: false,
|
|
336
|
+
score,
|
|
337
|
+
reason: `Off-subject for its source (grounding ${grounding}/5): ${reason}`,
|
|
338
|
+
criteria,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
312
341
|
const verdict = score >= 3.5 ? { pass: true } : score >= 2.5 ? { pass: false, reviewNeeded: true } : { pass: false };
|
|
313
342
|
return { ...verdict, score, reason, ...(criteria ? { criteria } : {}) };
|
|
314
343
|
}
|
|
@@ -48,3 +48,24 @@ export function formatNewAssetDiff(ref, content) {
|
|
|
48
48
|
}
|
|
49
49
|
return lines.join("\n");
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Render the all-removals diff for a retire proposal (#997): the file its
|
|
53
|
+
* accept archives, line by line, and nothing added. Padding the proposed side
|
|
54
|
+
* with an empty string, as {@link formatUnifiedDiff} does, reads as "the whole
|
|
55
|
+
* file replaced by one blank line" — a retirement reviewed as data loss.
|
|
56
|
+
* `existing` is `null` when the retired file is already gone.
|
|
57
|
+
*/
|
|
58
|
+
export function formatRetireDiff(ref, existing, successorRef) {
|
|
59
|
+
const destination = `+++ /dev/null (retired: archived${successorRef ? `; successor ${successorRef}` : ""})`;
|
|
60
|
+
if (existing === null)
|
|
61
|
+
return [`--- ${ref} (missing)`, destination].join("\n");
|
|
62
|
+
const removed = existing.split("\n");
|
|
63
|
+
if (removed[removed.length - 1] === "")
|
|
64
|
+
removed.pop(); // the newline ending the file is not a line of its own
|
|
65
|
+
return [
|
|
66
|
+
`--- ${ref} (existing)`,
|
|
67
|
+
destination,
|
|
68
|
+
`@@ 1,${removed.length} 0,0 @@`,
|
|
69
|
+
...removed.map((line) => `-${line}`),
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
@@ -25,7 +25,8 @@ import { resolveImproveExecution } from "../improve/execution.js";
|
|
|
25
25
|
import { extractCommand } from "../improve/extract-cli.js";
|
|
26
26
|
import { resolveImproveStrategy } from "../improve/improve-strategies.js";
|
|
27
27
|
import { drainProposals } from "./drain.js";
|
|
28
|
-
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
|
|
28
|
+
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalReopen, akmProposalRevert, akmProposalShow, bulkAdjudicateProposals, } from "./proposal.js";
|
|
29
|
+
import { proposalWaitingSince } from "./proposal-types.js";
|
|
29
30
|
import { proposeCommand } from "./propose-cli.js";
|
|
30
31
|
export function mergeProposalDrainNotices(resolutionNotices, dispatchNotices) {
|
|
31
32
|
const byKey = new Map();
|
|
@@ -128,7 +129,7 @@ const proposalAcceptCommand = defineJsonCommand({
|
|
|
128
129
|
},
|
|
129
130
|
"older-than": {
|
|
130
131
|
type: "string",
|
|
131
|
-
description: "When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
132
|
+
description: "When bulk-accepting, only accept proposals created (or last reopened) more than this many days ago (e.g. '7' for 7 days).",
|
|
132
133
|
},
|
|
133
134
|
"dry-run": {
|
|
134
135
|
type: "boolean",
|
|
@@ -207,7 +208,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
207
208
|
},
|
|
208
209
|
"older-than": {
|
|
209
210
|
type: "string",
|
|
210
|
-
description: "When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
211
|
+
description: "When bulk-rejecting, only reject proposals created (or last reopened) more than this many days ago (e.g. '7' for 7 days).",
|
|
211
212
|
},
|
|
212
213
|
"dry-run": {
|
|
213
214
|
type: "boolean",
|
|
@@ -230,7 +231,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
230
231
|
// F-6 / #393: Bulk-reject when --generator is provided without a positional id.
|
|
231
232
|
if (generator && !args.id) {
|
|
232
233
|
const { confirmDestructive } = await import("../../cli/confirm.js");
|
|
233
|
-
const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"?
|
|
234
|
+
const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"? A rejection can be undone with akm proposal reopen.`, { yes: args.yes === true || args["dry-run"] === true });
|
|
234
235
|
if (!confirmed) {
|
|
235
236
|
process.stderr.write("Aborted.\n");
|
|
236
237
|
return;
|
|
@@ -252,7 +253,7 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
252
253
|
throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
|
|
253
254
|
}
|
|
254
255
|
const { confirmDestructive } = await import("../../cli/confirm.js");
|
|
255
|
-
const confirmed = await confirmDestructive(`Reject proposal "${args.id}"?
|
|
256
|
+
const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? A rejection can be undone with akm proposal reopen.`, {
|
|
256
257
|
yes: args.yes === true,
|
|
257
258
|
});
|
|
258
259
|
if (!confirmed) {
|
|
@@ -267,6 +268,41 @@ const proposalRejectCommand = defineJsonCommand({
|
|
|
267
268
|
output("proposal-reject", result);
|
|
268
269
|
},
|
|
269
270
|
});
|
|
271
|
+
// `proposal reopen` (#997): the undo a rejection lacked. Unlike `reject` it is
|
|
272
|
+
// reversible (reject again), so it asks no confirmation.
|
|
273
|
+
const proposalReopenCommand = defineJsonCommand({
|
|
274
|
+
meta: {
|
|
275
|
+
name: "reopen",
|
|
276
|
+
description: "Reopen rejected proposals: move them back to pending, keeping the rejection in their history. " +
|
|
277
|
+
"Takes full proposal ids; refused (and none reopened) if any is not rejected or its target changed since it was created.",
|
|
278
|
+
},
|
|
279
|
+
args: {
|
|
280
|
+
id: {
|
|
281
|
+
type: "positional",
|
|
282
|
+
description: "Rejected proposal id (full uuid); repeat it to reopen several at once",
|
|
283
|
+
required: true,
|
|
284
|
+
},
|
|
285
|
+
reason: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Why the rejection is being undone (kept in the proposal's review history)",
|
|
288
|
+
},
|
|
289
|
+
queue: { type: "string", description: "Select the proposal queue by source name" },
|
|
290
|
+
},
|
|
291
|
+
async run({ args }) {
|
|
292
|
+
// citty keeps every positional token in `_`, though it declares only `id`.
|
|
293
|
+
const ids = (Array.isArray(args._) && args._.length > 0 ? args._ : [args.id]).map(String);
|
|
294
|
+
const reason = typeof args.reason === "string" && args.reason.trim() ? args.reason.trim() : undefined;
|
|
295
|
+
const results = await akmProposalReopen({
|
|
296
|
+
ids,
|
|
297
|
+
queue: args.queue,
|
|
298
|
+
...(reason !== undefined ? { reason } : {}),
|
|
299
|
+
});
|
|
300
|
+
if (results.length === 1)
|
|
301
|
+
output("proposal-reopen", results[0]);
|
|
302
|
+
else
|
|
303
|
+
output("proposal-reopen-batch", { reopened: results.length, results });
|
|
304
|
+
},
|
|
305
|
+
});
|
|
270
306
|
const proposalDiffCommand = defineJsonCommand({
|
|
271
307
|
meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
|
|
272
308
|
args: {
|
|
@@ -356,7 +392,7 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
356
392
|
},
|
|
357
393
|
"older-than": {
|
|
358
394
|
type: "string",
|
|
359
|
-
description: "Only consider proposals created more than this many days ago.",
|
|
395
|
+
description: "Only consider proposals created (or last reopened) more than this many days ago.",
|
|
360
396
|
},
|
|
361
397
|
promote: {
|
|
362
398
|
type: "boolean",
|
|
@@ -410,10 +446,11 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
410
446
|
const now = Date.now();
|
|
411
447
|
excludeIds = new Set(listProposals(stashDir, { status: "pending" })
|
|
412
448
|
// Fail SAFE: exclude a proposal when its age cannot be computed
|
|
413
|
-
// (NaN
|
|
414
|
-
//
|
|
449
|
+
// (NaN date) OR it is too fresh. An unparseable date must never be
|
|
450
|
+
// treated as old enough to drain/promote. Age counts from creation,
|
|
451
|
+
// or from the last reopen (#997) — a proposal just reopened is fresh.
|
|
415
452
|
.filter((proposal) => {
|
|
416
|
-
const age = now - new Date(proposal
|
|
453
|
+
const age = now - new Date(proposalWaitingSince(proposal)).getTime();
|
|
417
454
|
return Number.isNaN(age) || age < olderThanMs;
|
|
418
455
|
})
|
|
419
456
|
.map((proposal) => proposal.id));
|
|
@@ -480,7 +517,7 @@ const proposalDrainCommand = defineJsonCommand({
|
|
|
480
517
|
export const proposalCommand = defineGroupCommand({
|
|
481
518
|
meta: {
|
|
482
519
|
name: "proposal",
|
|
483
|
-
description: "Manage the proposal queue: list, show, diff, accept, reject, revert, extract, new, drain",
|
|
520
|
+
description: "Manage the proposal queue: list, show, diff, accept, reject, reopen, revert, extract, new, drain",
|
|
484
521
|
},
|
|
485
522
|
// The group declared `--queue`/`--status`/`--ref`/`--type` only so the bare
|
|
486
523
|
// form could act as `proposal list`. That form is gone (see below), and
|
|
@@ -493,6 +530,7 @@ export const proposalCommand = defineGroupCommand({
|
|
|
493
530
|
diff: proposalDiffCommand,
|
|
494
531
|
accept: proposalAcceptCommand,
|
|
495
532
|
reject: proposalRejectCommand,
|
|
533
|
+
reopen: proposalReopenCommand,
|
|
496
534
|
revert: proposalRevertCommand,
|
|
497
535
|
drain: proposalDrainCommand,
|
|
498
536
|
extract: extractCommand,
|
|
@@ -36,6 +36,17 @@ export function isValidProposalSource(source) {
|
|
|
36
36
|
export function isAutomatedProposalSource(source) {
|
|
37
37
|
return AUTOMATED_PROPOSAL_SOURCES.includes(source);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* When a pending proposal's wait for review began: its last reopen (#997), else
|
|
41
|
+
* its creation. Every age-based sweep — retention expiry, and `--older-than` on
|
|
42
|
+
* bulk accept/reject and on `drain` — counts from here, so a proposal just put
|
|
43
|
+
* back in the queue is not swept as if it had been waiting since it was first
|
|
44
|
+
* created (a scheduled `drain --older-than 7 --promote` would otherwise take
|
|
45
|
+
* it at once).
|
|
46
|
+
*/
|
|
47
|
+
export function proposalWaitingSince(proposal) {
|
|
48
|
+
return proposal.reviewHistory?.at(-1)?.reopenedAt ?? proposal.createdAt;
|
|
49
|
+
}
|
|
39
50
|
/** A pending or accepted proposal whose primary change deletes its target (a consolidate retire proposal, alpha.9). */
|
|
40
51
|
export function isRetireProposal(proposal) {
|
|
41
52
|
return proposal.changes[0]?.op === "delete";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
* `akm proposal {list,show,accept,reject,diff}` — review surface for the
|
|
5
|
+
* `akm proposal {list,show,accept,reject,reopen,diff}` — review surface for the
|
|
6
6
|
* proposal substrate (#225).
|
|
7
7
|
*
|
|
8
8
|
* Each function returns a plain JSON envelope; the CLI dispatcher in
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { resolveStashDir } from "../../core/common.js";
|
|
15
15
|
import { loadConfig } from "../../core/config/config.js";
|
|
16
|
+
import { NotFoundError } from "../../core/errors.js";
|
|
16
17
|
import { resolveWriteTarget } from "../../core/write-source.js";
|
|
17
18
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
18
|
-
import { isRetireProposal } from "./proposal-types.js";
|
|
19
|
-
import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, resolveProposalId, revertProposal, } from "./repository.js";
|
|
19
|
+
import { isRetireProposal, proposalWaitingSince } from "./proposal-types.js";
|
|
20
|
+
import { diffProposal, listProposals, preflightProposalPromotion, promoteProposal, proposalContent, rejectProposalDurably, reopenProposals, resolveProposalId, revertProposal, } from "./repository.js";
|
|
20
21
|
import { validateProposal } from "./validators/proposals.js";
|
|
21
22
|
// ── Shared helpers ──────────────────────────────────────────────────────────
|
|
22
23
|
function resolveStash(stashDir) {
|
|
@@ -29,7 +30,7 @@ function resolveProposalQueue(stashDir, queue, config) {
|
|
|
29
30
|
return { stashDir };
|
|
30
31
|
if (!queue)
|
|
31
32
|
return { stashDir: resolveStash() };
|
|
32
|
-
const target = resolveWriteTarget(config ?? loadConfig(), queue);
|
|
33
|
+
const target = resolveWriteTarget(config ?? loadConfig(), queue, { flag: "--queue" });
|
|
33
34
|
return { stashDir: target.source.path, target };
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
@@ -120,6 +121,59 @@ export async function akmProposalReject(options) {
|
|
|
120
121
|
};
|
|
121
122
|
});
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Put rejected proposals back in the queue as `pending` (#997) — the undo the
|
|
126
|
+
* proposal queue lacked. All-or-nothing: see {@link reopenProposals}. An
|
|
127
|
+
* archived proposal is found by its full id (a prefix only matches the pending
|
|
128
|
+
* queue), the same as `revert`.
|
|
129
|
+
*/
|
|
130
|
+
export async function akmProposalReopen(options) {
|
|
131
|
+
return withAssetMutationLease("proposal-reopen", async () => {
|
|
132
|
+
const config = options.config ?? loadConfig();
|
|
133
|
+
const queue = resolveProposalQueue(options.stashDir, options.queue, config);
|
|
134
|
+
const ids = options.ids.map((id) => {
|
|
135
|
+
try {
|
|
136
|
+
return resolveProposalId(queue.stashDir, id, options.ctx).id;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof NotFoundError))
|
|
140
|
+
throw error;
|
|
141
|
+
throw new NotFoundError(error.message, error.code, "A rejected proposal is addressed by its full id (a prefix only matches pending proposals): `akm proposal list --status rejected` lists them.");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
const reopened = reopenProposals(queue.stashDir, config, ids, { queueTarget: queue.target, ...(options.reason !== undefined ? { reason: options.reason } : {}) }, options.ctx);
|
|
145
|
+
return reopened.map((proposal) => ({
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
ok: true,
|
|
148
|
+
id: proposal.id,
|
|
149
|
+
ref: proposal.ref,
|
|
150
|
+
...(options.reason !== undefined ? { reason: options.reason } : {}),
|
|
151
|
+
proposal,
|
|
152
|
+
}));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** What a reviewer of a retire proposal needs to know that its diff (a file leaving) does not say. */
|
|
156
|
+
const RETIRE_DIFF_NOTE = "Accepting archives the retired file under .akm/memory-cleanup/archive/ (nothing is deleted); " +
|
|
157
|
+
"`akm proposal revert` restores it byte-exactly.";
|
|
158
|
+
/** The fields a retire proposal's diff result adds to an ordinary one (#997). */
|
|
159
|
+
function retireDiffFields(retirement) {
|
|
160
|
+
return {
|
|
161
|
+
op: "delete",
|
|
162
|
+
...(retirement
|
|
163
|
+
? {
|
|
164
|
+
retirement: {
|
|
165
|
+
retiredRef: retirement.retiredRef,
|
|
166
|
+
successorRef: retirement.successorRef,
|
|
167
|
+
judgeLabel: retirement.judgeLabel,
|
|
168
|
+
judgeReason: retirement.judgeReason,
|
|
169
|
+
cosine: retirement.cosine,
|
|
170
|
+
...(retirement.continuityRisk ? { continuityRisk: retirement.continuityRisk } : {}),
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
174
|
+
note: RETIRE_DIFF_NOTE,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
123
177
|
export function akmProposalDiff(options) {
|
|
124
178
|
const config = options.config ?? loadConfig();
|
|
125
179
|
const queue = resolveProposalQueue(options.stashDir, options.queue, config);
|
|
@@ -133,6 +187,7 @@ export function akmProposalDiff(options) {
|
|
|
133
187
|
isNew: diff.isNew,
|
|
134
188
|
unified: diff.unified,
|
|
135
189
|
...(diff.targetPath ? { targetPath: diff.targetPath } : {}),
|
|
190
|
+
...(isRetireProposal(proposal) ? retireDiffFields(proposal.retirement) : {}),
|
|
136
191
|
};
|
|
137
192
|
}
|
|
138
193
|
/**
|
|
@@ -204,7 +259,7 @@ export async function bulkAdjudicateProposals(options) {
|
|
|
204
259
|
return false;
|
|
205
260
|
}
|
|
206
261
|
if (options.olderThanMs !== undefined) {
|
|
207
|
-
const age = Date.now() - new Date(p
|
|
262
|
+
const age = Date.now() - new Date(proposalWaitingSince(p)).getTime();
|
|
208
263
|
if (age < options.olderThanMs)
|
|
209
264
|
return false;
|
|
210
265
|
}
|