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.
Files changed (52) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/STABILITY.md +2 -1
  3. package/dist/assets/hints/cli-hints-full.md +4 -2
  4. package/dist/assets/hints/cli-hints-short.md +5 -3
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
  6. package/dist/commands/feedback-cli.js +1 -1
  7. package/dist/commands/improve/consolidate/coverage.js +132 -0
  8. package/dist/commands/improve/consolidate/pair-pass.js +30 -20
  9. package/dist/commands/improve/consolidate.js +43 -10
  10. package/dist/commands/improve/distill.js +7 -3
  11. package/dist/commands/improve/eligibility.js +39 -9
  12. package/dist/commands/improve/improve-cli.js +9 -6
  13. package/dist/commands/improve/improve.js +13 -4
  14. package/dist/commands/improve/ledger.js +2 -2
  15. package/dist/commands/improve/loop-stages.js +2 -0
  16. package/dist/commands/improve/preparation.js +1 -1
  17. package/dist/commands/improve/reflect.js +1 -1
  18. package/dist/commands/improve/stage.js +38 -9
  19. package/dist/commands/proposal/diff-format.js +21 -0
  20. package/dist/commands/proposal/proposal-cli.js +48 -10
  21. package/dist/commands/proposal/proposal-types.js +11 -0
  22. package/dist/commands/proposal/proposal.js +60 -5
  23. package/dist/commands/proposal/repository.js +245 -17
  24. package/dist/commands/read/knowledge.js +13 -11
  25. package/dist/commands/read/remember-cli.js +7 -3
  26. package/dist/commands/sources/source-clone.js +1 -1
  27. package/dist/commands/tasks/tasks-cli.js +1 -1
  28. package/dist/commands/tasks/tasks.js +10 -3
  29. package/dist/core/mutation-target.js +8 -3
  30. package/dist/core/write-source.js +3 -2
  31. package/dist/indexer/usage/usage-events.js +2 -1
  32. package/dist/output/shapes/helpers.js +7 -0
  33. package/dist/output/shapes/passthrough.js +1 -0
  34. package/dist/output/shapes/proposal/reopen.js +14 -0
  35. package/dist/output/shapes.js +2 -0
  36. package/dist/output/text/helpers.js +1 -1
  37. package/dist/output/text/proposal/proposal.js +3 -1
  38. package/dist/output/text/proposal-format.js +87 -32
  39. package/dist/scripts/akm-migrate-node.js +79 -23
  40. package/dist/scripts/akm-migrate.js +79 -23
  41. package/dist/storage/repositories/improve-ledger-repository.js +65 -6
  42. package/dist/storage/repositories/index-vec-repository.js +13 -8
  43. package/dist/storage/repositories/proposals-repository.js +23 -0
  44. package/dist/tasks/run/load-task.js +5 -1
  45. package/docs/migration/README.md +1 -0
  46. package/docs/migration/release-notes/0.9.19.md +134 -0
  47. package/docs/migration/release-notes/README.md +5 -0
  48. package/docs/migration/v0.8-to-v0.9.md +5 -1
  49. package/docs/reference/cli.md +189 -28
  50. package/docs/reference/configuration.md +9 -8
  51. package/docs/reference/data-and-telemetry.md +19 -14
  52. 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
- const resolved = resolveRef(scope.value, {
156
- defaultBundle: config.defaultBundle,
157
- bundles: installations.map((installation) => ({
158
- id: installation.id,
159
- hasConcept: (conceptId) => entriesByItemRef.has(`${installation.id}//${conceptId}`),
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 (!writableBundleIds.has(indexed.bundleId))
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) => writableBundleIds.has(indexed.bundleId));
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: { type: "string", description: "Override the write target for accepted proposals" },
213
- limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
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 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
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 --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or use --target ${scopedRef.origin}.`);
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).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 — zero-feedback fallback will match only search-retrieved assets.");
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
- sourceContent.slice(0, 2000),
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
- const LESSON_JUDGE_CRITERIA = ["novelty", "nonRedundancy"];
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) or the older
236
- * `{"score"}` shape. Only the expected criteria are read; any missing or
237
- * out-of-range (1..5) value is a parse failure, extra keys are ignored.
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
- return { score: Object.values(criteria).reduce((a, b) => a + b, 0) / keys.length, reason, criteria };
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. Temperature is pinned to 0 so verdicts do not flip.
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}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
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}"? This cannot be undone.`, {
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 createdAt) OR it is too fresh. An unparseable createdAt must
414
- // never be treated as old enough to drain/promote.
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.createdAt).getTime();
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.createdAt).getTime();
262
+ const age = Date.now() - new Date(proposalWaitingSince(p)).getTime();
208
263
  if (age < options.olderThanMs)
209
264
  return false;
210
265
  }