akm-cli 0.9.17 → 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 (57) hide show
  1. package/CHANGELOG.md +157 -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/health/checks.js +6 -6
  8. package/dist/commands/health.js +3 -3
  9. package/dist/commands/improve/consolidate/coverage.js +132 -0
  10. package/dist/commands/improve/consolidate/pair-pass.js +31 -21
  11. package/dist/commands/improve/consolidate.js +46 -13
  12. package/dist/commands/improve/distill.js +8 -4
  13. package/dist/commands/improve/eligibility.js +39 -9
  14. package/dist/commands/improve/improve-cli.js +9 -6
  15. package/dist/commands/improve/improve.js +13 -4
  16. package/dist/commands/improve/ledger.js +2 -2
  17. package/dist/commands/improve/loop-stages.js +2 -0
  18. package/dist/commands/improve/preparation.js +3 -1
  19. package/dist/commands/improve/reflect.js +2 -2
  20. package/dist/commands/improve/stage.js +43 -12
  21. package/dist/commands/proposal/diff-format.js +21 -0
  22. package/dist/commands/proposal/proposal-cli.js +48 -10
  23. package/dist/commands/proposal/proposal-types.js +11 -0
  24. package/dist/commands/proposal/proposal.js +60 -5
  25. package/dist/commands/proposal/repository.js +250 -18
  26. package/dist/commands/read/knowledge.js +13 -11
  27. package/dist/commands/read/remember-cli.js +7 -3
  28. package/dist/commands/sources/source-clone.js +1 -1
  29. package/dist/commands/tasks/tasks-cli.js +1 -1
  30. package/dist/commands/tasks/tasks.js +10 -3
  31. package/dist/core/mutation-target.js +8 -3
  32. package/dist/core/write-source.js +3 -2
  33. package/dist/indexer/usage/usage-events.js +2 -1
  34. package/dist/output/shapes/helpers.js +7 -0
  35. package/dist/output/shapes/passthrough.js +1 -0
  36. package/dist/output/shapes/proposal/reopen.js +14 -0
  37. package/dist/output/shapes.js +2 -0
  38. package/dist/output/text/helpers.js +1 -1
  39. package/dist/output/text/proposal/proposal.js +3 -1
  40. package/dist/output/text/proposal-format.js +87 -32
  41. package/dist/scripts/akm-migrate-node.js +102 -25
  42. package/dist/scripts/akm-migrate.js +102 -25
  43. package/dist/storage/repositories/improve-ledger-repository.js +65 -6
  44. package/dist/storage/repositories/index-vec-repository.js +13 -8
  45. package/dist/storage/repositories/proposals-repository.js +23 -0
  46. package/dist/storage/sqlite-read-snapshot.js +46 -2
  47. package/dist/storage/state-db-integrity.js +12 -9
  48. package/dist/tasks/run/load-task.js +5 -1
  49. package/docs/migration/README.md +1 -0
  50. package/docs/migration/release-notes/0.9.19.md +134 -0
  51. package/docs/migration/release-notes/README.md +5 -0
  52. package/docs/migration/v0.7-to-v0.8.md +2 -2
  53. package/docs/migration/v0.8-to-v0.9.md +5 -1
  54. package/docs/reference/cli.md +189 -28
  55. package/docs/reference/configuration.md +9 -8
  56. package/docs/reference/data-and-telemetry.md +24 -16
  57. package/package.json +1 -1
@@ -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
  }
@@ -21,7 +21,7 @@ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-seri
21
21
  import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
22
22
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
23
23
  import { loadConfig } from "../../core/config/config.js";
24
- import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
+ import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
25
25
  import { appendEvent } from "../../core/events.js";
26
26
  import { proposalContent } from "../../core/file-change.js";
27
27
  import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
@@ -34,7 +34,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
34
34
  import { deriveInstallations } from "../../indexer/installations.js";
35
35
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
36
36
  import { insertEventOnce } from "../../storage/repositories/events-repository.js";
37
- import { recordImproveLedger, recordImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
37
+ import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
38
38
  import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
39
39
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
40
40
  import { pkgVersion } from "../../version.js";
@@ -42,8 +42,8 @@ import { contentHash } from "../improve/content-hash.js";
42
42
  import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
43
43
  import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
44
44
  import { runBaseChecks } from "../lint/base-linter.js";
45
- import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
46
- import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
45
+ import { formatNewAssetDiff, formatRetireDiff, formatUnifiedDiff } from "./diff-format.js";
46
+ import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, proposalWaitingSince, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
47
47
  import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
48
48
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
49
49
  export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
@@ -141,6 +141,28 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
141
141
  }
142
142
  return { source: bundleId, root };
143
143
  }
144
+ /**
145
+ * The configured bundle that owns the asset `itemRef` names when it is not the
146
+ * bundle rooted at `target`; otherwise `undefined`. A differing name is
147
+ * confirmed by the owner's root, so one bundle known by two spellings is never
148
+ * taken for two, and an owner that cannot be resolved is never refused on.
149
+ */
150
+ function otherOwningBundle(itemRef, target) {
151
+ try {
152
+ const owner = parseBundleRef(itemRef).bundle;
153
+ if (owner === undefined || owner === target.source)
154
+ return undefined;
155
+ const sources = resolveSourceEntries(target.root, loadConfig());
156
+ const ownerSource = sources[deriveInstallations(sources).findIndex((installation) => installation.id === owner)];
157
+ return ownerSource !== undefined && path.resolve(ownerSource.path) !== path.resolve(target.root)
158
+ ? owner
159
+ : undefined;
160
+ }
161
+ catch (err) {
162
+ rethrowIfTestIsolationError(err);
163
+ return undefined;
164
+ }
165
+ }
144
166
  /**
145
167
  * Create a pending proposal (a random UUID id). Obviously invalid input is
146
168
  * refused with a typed `proposal_creation_rejected` event. The mint and its
@@ -190,9 +212,13 @@ export function createProposal(stashDir, input, ctx) {
190
212
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
191
213
  }
192
214
  }
215
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
216
+ const owner = input.itemRef ? otherOwningBundle(input.itemRef, proposalTarget) : undefined;
217
+ if (owner) {
218
+ return rejectProposal("cross_bundle", `Proposal for "${input.ref}" rewrites ${input.itemRef}, which bundle "${owner}" owns, but its queue target is bundle "${proposalTarget.source}". Filing it there would fork the asset out of "${owner}" or overwrite this bundle's copy with content taken from the other. Improve it in its own bundle instead (\`akm improve --bundle ${owner}\`).`);
219
+ }
193
220
  // The FileChange envelope, and the target's before-hashes as of mint (the
194
221
  // freshness check at accept compares against them).
195
- const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
196
222
  const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
197
223
  const targetRoot = path.resolve(proposalTarget.root);
198
224
  let targetRelPath;
@@ -373,9 +399,13 @@ export function listProposals(stashDir, options = {}, ctx) {
373
399
  /**
374
400
  * {@link listProposals} on a read snapshot that never creates or migrates
375
401
  * state.db: prompt building runs before the first dispatch has validated its
376
- * credentials, and a missing store is simply empty.
402
+ * credentials, and a missing store is simply empty. A caller that already
403
+ * holds a live connection (`ctx.db`) reads through it: a snapshot would copy
404
+ * the whole database for nothing.
377
405
  */
378
406
  export function listProposalsReadOnly(stashDir, options = {}, ctx) {
407
+ if (ctx?.db)
408
+ return queryProposals(ctx.db, stashDir, options);
379
409
  const dbPath = ctx?.dbPath ?? getStateDbPath();
380
410
  if (!fs.existsSync(dbPath))
381
411
  return [];
@@ -450,6 +480,25 @@ function ledgerOutcomeForDecision(status, gateDecision) {
450
480
  }
451
481
  return "rejected";
452
482
  }
483
+ /**
484
+ * What a decision's ledger row is about. A promotion's row is keyed by its
485
+ * source memory, so a decision on it names that memory, together with the body
486
+ * hash the promotion was queued against — this is what holds the memory back
487
+ * until it changes (`isContentDrivenDecision`). Any other proposal, and a
488
+ * promotion minted before the hash was recorded, is keyed by its own ref.
489
+ * Every decision that can create a row when none carries the proposal id — a
490
+ * reject, an accept and a drain deferral alike — must key it this way: a stray
491
+ * row under the knowledge ref carries the proposal id, so a later verdict
492
+ * updates that one and never reaches the memory's own row.
493
+ */
494
+ function decisionLedgerSubject(proposal) {
495
+ if (proposal.source === CONSOLIDATE_LEDGER_SOURCE &&
496
+ proposal.promotionSource !== undefined &&
497
+ proposal.promotionSourceHash !== undefined) {
498
+ return { ref: proposal.promotionSource, contentHash: proposal.promotionSourceHash };
499
+ }
500
+ return { ref: proposal.ref };
501
+ }
453
502
  /** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
454
503
  export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
455
504
  return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
@@ -469,7 +518,7 @@ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision)
469
518
  recordImproveLedgerDecision(db, {
470
519
  proposalId: updated.id,
471
520
  stashDir,
472
- ref: updated.ref,
521
+ ...decisionLedgerSubject(updated),
473
522
  source: updated.source,
474
523
  outcome: ledgerOutcomeForDecision(status, gateDecision),
475
524
  at: decidedAt,
@@ -496,7 +545,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
496
545
  recordImproveLedgerDecision(db, {
497
546
  proposalId: updated.id,
498
547
  stashDir,
499
- ref: updated.ref,
548
+ ...decisionLedgerSubject(updated),
500
549
  source: updated.source,
501
550
  outcome: "review_needed",
502
551
  at: decidedAt,
@@ -547,9 +596,10 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
547
596
  }
548
597
  /**
549
598
  * Archive pending proposals older than `archiveRetentionDays` (default 90;
550
- * 0 disables) as rejected with an `expired` gate decision and a
551
- * `proposal_expired` event. The ledger records `expired` — a short grace, not
552
- * the rejection window, since nobody judged the content.
599
+ * 0 disables; counted from the last reopen, if any) as rejected with an
600
+ * `expired` gate decision and a `proposal_expired` event. The ledger records
601
+ * `expired` — a short grace, not the rejection window, since nobody judged the
602
+ * content.
553
603
  */
554
604
  export function expireStaleProposals(stashDir, config, ctx) {
555
605
  const t0 = Date.now();
@@ -568,7 +618,9 @@ export function expireStaleProposals(stashDir, config, ctx) {
568
618
  // way back short of the pair pass finding it again from scratch.
569
619
  if (isRetireProposal(p))
570
620
  continue;
571
- const createdMs = new Date(p.createdAt).getTime();
621
+ // A reopened proposal's wait starts over at the reopen (#997): expiring it
622
+ // on its original age would undo the reopen at the next sweep.
623
+ const createdMs = new Date(proposalWaitingSince(p)).getTime();
572
624
  if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
573
625
  continue;
574
626
  try {
@@ -686,7 +738,7 @@ function persistProposalDecision(stashDir, proposal, decision, ctx) {
686
738
  recordImproveLedgerDecision(db, {
687
739
  proposalId: next.id,
688
740
  stashDir,
689
- ref: next.ref,
741
+ ...decisionLedgerSubject(next),
690
742
  source: next.source,
691
743
  outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
692
744
  at: decision.decidedAt,
@@ -1587,17 +1639,197 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1587
1639
  await indexWrittenProposalAsset(target, assetPath);
1588
1640
  return { proposal: reverted, assetPath, ref: proposal.ref };
1589
1641
  }
1642
+ /** The message of the stale-target refusal `check` raises, else `undefined`. */
1643
+ function staleRefusal(check) {
1644
+ try {
1645
+ check();
1646
+ return undefined;
1647
+ }
1648
+ catch (error) {
1649
+ if (error instanceof UsageError)
1650
+ return error.message;
1651
+ throw error;
1652
+ }
1653
+ }
1654
+ /**
1655
+ * Why `proposal` cannot be reopened, or `undefined` when it can. Only a
1656
+ * rejected proposal comes back, and only one accept would not refuse as stale:
1657
+ * a retire proposal needs its successor and both recorded body hashes to still
1658
+ * match (B2), any other its target to be what it was minted against
1659
+ * (STALE, R20) — so a reopened proposal is never one the next accept refuses.
1660
+ */
1661
+ function reopenRefusal(config, proposal, queueTarget) {
1662
+ if (proposal.status !== "rejected") {
1663
+ return `it is not rejected (current status: ${proposal.status}); only a rejected proposal can be reopened.`;
1664
+ }
1665
+ if (proposal.changes.length === 0 || proposal.proposedTarget === undefined) {
1666
+ // A pending row must carry both (proposalToRowValues), which a row from
1667
+ // before the change envelope existed cannot.
1668
+ return "it was recorded before proposals carried their change envelope, so it cannot go back in the queue.";
1669
+ }
1670
+ const target = resolveProposalWriteTarget(config, proposal, undefined, queueTarget);
1671
+ const assetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1672
+ if (!assetPath)
1673
+ return "its target cannot be resolved.";
1674
+ if (!isRetireProposal(proposal)) {
1675
+ return staleRefusal(() => void readFreshProposalTarget(proposal, assetPath, proposalContent(proposal)));
1676
+ }
1677
+ if (!proposal.retirement)
1678
+ return "it has no retirement metadata.";
1679
+ if (!fs.existsSync(assetPath)) {
1680
+ return "its retired file no longer exists (already retired, or removed by something else).";
1681
+ }
1682
+ const { retirement } = proposal;
1683
+ return staleRefusal(() => assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, fs.readFileSync(assetPath)));
1684
+ }
1685
+ /** The assets a retire proposal speaks for — the one it retires and its successor — as bundle-less concept ids, the way the pair pass keys them. */
1686
+ function retireHeldRefs(proposal) {
1687
+ return [proposal.ref, proposal.retirement?.successorRef].flatMap((ref) => {
1688
+ const conceptId = ref === undefined ? undefined : proposalRefIdentity(ref)?.conceptId;
1689
+ return conceptId === undefined ? [] : [conceptId];
1690
+ });
1691
+ }
1692
+ const REOPEN_REFUSED_HINT = "Only a rejected proposal whose target is unchanged can be reopened; `akm proposal list --status rejected` lists the candidates.";
1693
+ const REOPEN_RETIRE_CONFLICT_HINT = "Only one pending retire proposal can involve a document: accept or reject the pending one (or reopen just one of a clashing pair), then reopen the other.";
1694
+ /**
1695
+ * The pair pass never has two pending retire proposals speak for one asset
1696
+ * (`pendingRetireRefs`): accepting one would strand the other. Reopening must
1697
+ * not break that either — a rejected pair can meanwhile have been re-paired
1698
+ * with something else. `held` maps each asset to the pending retire proposal
1699
+ * that has it; a proposal that clears this claims its assets, so two in one
1700
+ * batch that clash refuse the later.
1701
+ */
1702
+ function retireConflict(proposal, held) {
1703
+ if (!isRetireProposal(proposal))
1704
+ return undefined;
1705
+ const refs = retireHeldRefs(proposal);
1706
+ for (const ref of refs) {
1707
+ const holder = held.get(ref);
1708
+ if (holder !== undefined) {
1709
+ return `${ref} is already part of retire proposal ${holder}, which is pending or being reopened with this one; only one pending retire proposal may involve an asset.`;
1710
+ }
1711
+ }
1712
+ for (const ref of refs)
1713
+ held.set(ref, proposal.id);
1714
+ return undefined;
1715
+ }
1716
+ /**
1717
+ * Put rejected proposals back in the queue (`akm proposal reopen`, #997): a
1718
+ * rejection is otherwise final, and it also suppresses the pair pass from ever
1719
+ * re-proposing a retirement (its record keys the pair), so a mistaken one
1720
+ * could not be undone. Each proposal returns to `pending` with the rejection —
1721
+ * and the gate verdict that came with it — kept in `reviewHistory` (the verdict
1722
+ * itself is cleared, unless it is a `deferred` hand-off to a person), its
1723
+ * ledger row reset (the pair pass keys off proposal status, so the pair is no
1724
+ * longer suppressed and, while pending, cannot be minted twice), and a
1725
+ * `proposal_reopened` event recorded, all in one transaction.
1726
+ *
1727
+ * All-or-nothing: every id is checked (see {@link reopenRefusal} and
1728
+ * {@link retireConflict}) before any is reopened, and one refusal leaves the
1729
+ * whole batch untouched.
1730
+ */
1731
+ export function reopenProposals(stashDir, config, ids, options = {}, ctx) {
1732
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1733
+ const proposals = [...new Set(ids)].map((id) => requireProposal(db, stashDir, id));
1734
+ const held = new Map();
1735
+ for (const pending of listStateProposals(db, { stashDir, status: "pending" })) {
1736
+ if (isRetireProposal(pending))
1737
+ for (const ref of retireHeldRefs(pending))
1738
+ held.set(ref, pending.id);
1739
+ }
1740
+ const refusals = proposals.flatMap((proposal) => {
1741
+ const refusal = reopenRefusal(config, proposal, options.queueTarget);
1742
+ const conflict = refusal === undefined ? retireConflict(proposal, held) : undefined;
1743
+ const reason = refusal ?? conflict;
1744
+ return reason === undefined
1745
+ ? []
1746
+ : [{ proposal: `${proposal.id} (${proposal.ref})`, reason, isConflict: conflict !== undefined }];
1747
+ });
1748
+ if (refusals.length > 0) {
1749
+ // A retire conflict is not about the target changing, so it gets its own hint.
1750
+ const hints = [
1751
+ ...(refusals.some((refusal) => !refusal.isConflict) ? [REOPEN_REFUSED_HINT] : []),
1752
+ ...(refusals.some((refusal) => refusal.isConflict) ? [REOPEN_RETIRE_CONFLICT_HINT] : []),
1753
+ ];
1754
+ throw new UsageError(proposals.length === 1
1755
+ ? `Proposal ${refusals[0]?.proposal} cannot be reopened: ${refusals[0]?.reason}`
1756
+ : `Cannot reopen ${refusals.length} of ${proposals.length} proposals; none were reopened:\n${refusals
1757
+ .map((refusal) => ` - ${refusal.proposal}: ${refusal.reason}`)
1758
+ .join("\n")}`, "INVALID_FLAG_VALUE", hints.join(" "));
1759
+ }
1760
+ const decidedAt = nowIso(ctx);
1761
+ return proposals.map((existing) => {
1762
+ const reopened = {
1763
+ ...existing,
1764
+ status: "pending",
1765
+ updatedAt: decidedAt,
1766
+ review: undefined,
1767
+ // A reopened proposal is adjudicated afresh: a `staged` verdict would
1768
+ // let the drain accept it unseen, and another gate's `auto-rejected`
1769
+ // would have the drain skip it. A `deferred` one is the quality
1770
+ // gate's hand-off to a person, which the drain must keep honouring
1771
+ // (drainProposals leaves it alone), so it stays.
1772
+ gateDecision: existing.gateDecision?.outcome === "deferred" ? existing.gateDecision : undefined,
1773
+ reviewHistory: [
1774
+ ...(existing.reviewHistory ?? []),
1775
+ {
1776
+ ...(existing.review !== undefined ? { review: existing.review } : {}),
1777
+ ...(existing.gateDecision !== undefined ? { gateDecision: existing.gateDecision } : {}),
1778
+ reopenedAt: decidedAt,
1779
+ ...(options.reason !== undefined ? { reopenReason: options.reason } : {}),
1780
+ },
1781
+ ],
1782
+ };
1783
+ upsertProposal(db, reopened, stashDir);
1784
+ if (isRetireProposal(existing)) {
1785
+ // A retire mint writes no ledger row, so the one its rejection wrote goes.
1786
+ forgetImproveLedgerDecision(db, stashDir, existing.id);
1787
+ }
1788
+ else {
1789
+ reopenImproveLedgerDecision(db, {
1790
+ proposalId: existing.id,
1791
+ stashDir,
1792
+ source: existing.source,
1793
+ at: decidedAt,
1794
+ detail: options.reason !== undefined ? `reopened: ${options.reason}` : "reopened",
1795
+ });
1796
+ }
1797
+ insertEventOnce(db, {
1798
+ eventType: "proposal_reopened",
1799
+ ts: decidedAt,
1800
+ ref: reopened.ref,
1801
+ metadata: {
1802
+ proposalId: reopened.id,
1803
+ source: reopened.source,
1804
+ ...(reopened.sourceRun !== undefined ? { sourceRun: reopened.sourceRun } : {}),
1805
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
1806
+ },
1807
+ idempotencyKey: `${reopened.id}:reopened:${decidedAt}`,
1808
+ });
1809
+ return reopened;
1810
+ });
1811
+ }));
1812
+ }
1590
1813
  /** The proposal against the asset its accept would overwrite (same target resolution as accept). */
1591
1814
  export function diffProposal(stashDir, config, id, options = {}, ctx) {
1592
1815
  const proposal = getProposal(stashDir, id, ctx);
1593
1816
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1594
1817
  const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1595
1818
  const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
1596
- // A retire proposal's primary change deletes its target rather than
1597
- // writing content: "proposed" is empty and the diff shows the whole body
1598
- // being removed, reusing the ordinary unified-diff formatter instead of
1599
- // proposalContent() (which has nothing to read for a delete).
1600
- const proposed = isRetireProposal(proposal) ? "" : proposalContent(proposal);
1819
+ if (isRetireProposal(proposal)) {
1820
+ // A retire proposal's primary change deletes its target rather than
1821
+ // writing content (proposalContent() has nothing to read for a delete):
1822
+ // accept archives the file, it never replaces it with a blank one, so the
1823
+ // diff shows the file leaving — not a "proposed" side (#997).
1824
+ return {
1825
+ existing,
1826
+ proposed: "",
1827
+ unified: formatRetireDiff(proposal.ref, existing, proposal.retirement?.successorRef),
1828
+ isNew: false,
1829
+ ...(targetPath ? { targetPath } : {}),
1830
+ };
1831
+ }
1832
+ const proposed = proposalContent(proposal);
1601
1833
  return {
1602
1834
  existing,
1603
1835
  proposed,