akm-cli 0.9.17-alpha.8 → 0.9.17

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 (111) hide show
  1. package/CHANGELOG.md +277 -1694
  2. package/STABILITY.md +9 -8
  3. package/dist/assets/hints/cli-hints-full.md +6 -7
  4. package/dist/assets/improve-strategies/catchup.json +0 -3
  5. package/dist/assets/improve-strategies/consolidate.json +0 -1
  6. package/dist/assets/improve-strategies/default.json +1 -2
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  8. package/dist/assets/improve-strategies/quick.json +1 -2
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  10. package/dist/assets/improve-strategies/thorough.json +0 -3
  11. package/dist/assets/prompts/consolidate-pair.md +20 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +17 -19
  13. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  14. package/dist/assets/templates/html/health.html +3 -5
  15. package/dist/cli/retired-commands.js +1 -1
  16. package/dist/cli/unknown-flags.js +24 -1
  17. package/dist/cli.js +46 -1
  18. package/dist/commands/health/archive-usage.js +92 -0
  19. package/dist/commands/health/data-dir-usage.js +25 -13
  20. package/dist/commands/health/html-report.js +1 -4
  21. package/dist/commands/health/improve-metrics.js +25 -37
  22. package/dist/commands/health/md-report.js +1 -6
  23. package/dist/commands/health/report-view-model.js +4 -14
  24. package/dist/commands/health/windows.js +0 -1
  25. package/dist/commands/health.js +13 -0
  26. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  27. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  28. package/dist/commands/improve/consolidate.js +38 -63
  29. package/dist/commands/improve/extract-prompt.js +1 -2
  30. package/dist/commands/improve/improve-cli.js +1 -1
  31. package/dist/commands/improve/improve-strategies.js +23 -5
  32. package/dist/commands/improve/improve.js +19 -30
  33. package/dist/commands/improve/ledger.js +3 -2
  34. package/dist/commands/improve/loop-stages.js +5 -85
  35. package/dist/commands/improve/memory/memory-belief.js +3 -1
  36. package/dist/commands/improve/memory/memory-improve.js +262 -11
  37. package/dist/commands/improve/planner.js +0 -5
  38. package/dist/commands/improve/preparation.js +20 -135
  39. package/dist/commands/improve/retrieval-scope.js +19 -4
  40. package/dist/commands/improve/salience.js +1 -14
  41. package/dist/commands/improve/stage.js +0 -1
  42. package/dist/commands/lint/base-linter.js +19 -11
  43. package/dist/commands/proposal/drain.js +8 -1
  44. package/dist/commands/proposal/proposal-cli.js +16 -2
  45. package/dist/commands/proposal/proposal-types.js +7 -0
  46. package/dist/commands/proposal/proposal.js +37 -6
  47. package/dist/commands/proposal/repository.js +613 -4
  48. package/dist/commands/proposal/validators/proposals.js +9 -0
  49. package/dist/commands/read/knowledge.js +3 -2
  50. package/dist/commands/read/show.js +0 -14
  51. package/dist/commands/sources/info.js +122 -18
  52. package/dist/commands/sources/stash-cli.js +23 -3
  53. package/dist/core/bundle-rename.js +1 -7
  54. package/dist/core/config/config-schema.js +8 -1
  55. package/dist/core/config/config.js +23 -48
  56. package/dist/core/config/engine-semantics.js +0 -2
  57. package/dist/core/config/schema/improve-processes.js +17 -42
  58. package/dist/core/config/schema/index-config.js +5 -25
  59. package/dist/core/file-change.js +13 -5
  60. package/dist/core/improve-result.js +22 -6
  61. package/dist/core/improve-types.js +0 -1
  62. package/dist/core/loopback.js +7 -12
  63. package/dist/core/parse.js +13 -16
  64. package/dist/core/state/migrations.js +15 -0
  65. package/dist/core/time.js +0 -20
  66. package/dist/indexer/db/llm-cache.js +2 -2
  67. package/dist/indexer/ensure-index.js +2 -2
  68. package/dist/indexer/index-written-assets.js +2 -3
  69. package/dist/indexer/indexer.js +18 -418
  70. package/dist/indexer/passes/metadata.js +0 -19
  71. package/dist/indexer/walk/walker.js +3 -4
  72. package/dist/llm/client.js +8 -10
  73. package/dist/llm/embedders/remote.js +1 -2
  74. package/dist/llm/feature-gate.js +0 -5
  75. package/dist/output/shapes/helpers.js +20 -4
  76. package/dist/output/text/command-format.js +9 -8
  77. package/dist/output/text/proposal-format.js +47 -1
  78. package/dist/output/text/show-format.js +0 -20
  79. package/dist/scripts/akm-migrate-node.js +923 -950
  80. package/dist/scripts/akm-migrate.js +923 -950
  81. package/dist/setup/steps/connection.js +5 -6
  82. package/dist/setup/steps/platforms.js +2 -2
  83. package/dist/sources/providers/git-stash.js +83 -4
  84. package/dist/storage/repositories/improve-ledger-repository.js +48 -7
  85. package/dist/storage/repositories/index-connection.js +5 -2
  86. package/dist/storage/repositories/index-entries-repository.js +4 -7
  87. package/dist/storage/repositories/index-entry-schema.js +4 -2
  88. package/dist/storage/repositories/index-llm-cache-repository.js +7 -26
  89. package/dist/storage/repositories/index-schema.js +55 -104
  90. package/dist/storage/repositories/proposals-repository.js +61 -0
  91. package/dist/storage/repositories/salience-repository.js +1 -19
  92. package/docs/migration/README.md +1 -1
  93. package/docs/migration/release-notes/0.9.17.md +130 -41
  94. package/docs/migration/release-notes/README.md +7 -0
  95. package/docs/reference/cli.md +27 -21
  96. package/docs/reference/configuration.md +21 -12
  97. package/docs/reference/data-and-telemetry.md +0 -1
  98. package/package.json +1 -1
  99. package/schemas/akm-config.json +0 -342
  100. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  101. package/dist/assets/prompts/contradiction-judge.md +0 -33
  102. package/dist/assets/prompts/graph-extract-system.md +0 -1
  103. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  104. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  105. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  106. package/dist/indexer/db/graph-db.js +0 -399
  107. package/dist/indexer/graph/graph-extraction.js +0 -809
  108. package/dist/indexer/graph/graph-related.js +0 -131
  109. package/dist/indexer/graph/graph-types.js +0 -4
  110. package/dist/llm/graph-extract.js +0 -892
  111. package/dist/llm/metadata-enhance.js +0 -95
@@ -39,9 +39,11 @@ import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, ups
39
39
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
40
40
  import { pkgVersion } from "../../version.js";
41
41
  import { contentHash } from "../improve/content-hash.js";
42
+ import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
43
+ import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
42
44
  import { runBaseChecks } from "../lint/base-linter.js";
43
45
  import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
44
- import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
46
+ import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
45
47
  import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
46
48
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
47
49
  export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
@@ -257,6 +259,8 @@ export function createProposal(stashDir, input, ctx) {
257
259
  : {}),
258
260
  ...(confidence !== undefined ? { confidence } : {}),
259
261
  ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
262
+ ...(input.promotionSource !== undefined ? { promotionSource: input.promotionSource } : {}),
263
+ ...(input.promotionSourceHash !== undefined ? { promotionSourceHash: input.promotionSourceHash } : {}),
260
264
  };
261
265
  upsertProposal(db, proposal, stashDir);
262
266
  for (const ref of input.attemptedRefs ?? [normalizedRef]) {
@@ -272,6 +276,76 @@ export function createProposal(stashDir, input, ctx) {
272
276
  return proposal;
273
277
  }));
274
278
  }
279
+ /**
280
+ * Mint a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): its
281
+ * primary `FileChange` deletes `ref`'s own file rather than writing new
282
+ * content, so this does not reuse {@link createProposal} (which always
283
+ * builds a create/update change and enforces content/description rules that
284
+ * do not apply to a delete). `ref` must already exist on disk — retiring a
285
+ * phantom is a caller bug, refused rather than silently accepted.
286
+ *
287
+ * Deliberately does NOT record an `improve_ledger` "proposed" row: the pair
288
+ * pass keys its own ledger cadence per INITIATOR (source `consolidate-pair`,
289
+ * `pair-pass.ts`'s own end-of-run write), which may differ from this
290
+ * proposal's `ref` — the initiator and the retired side are not always the
291
+ * same asset (see `runConsolidatePairPass`). Recording one here, keyed by
292
+ * the retired ref instead, would be a second, competing row for whichever
293
+ * asset happens to be both.
294
+ */
295
+ export function createRetireProposal(stashDir, input, ctx) {
296
+ if (!isValidProposalSource(input.source)) {
297
+ warn(`[proposal] Unknown source "${input.source}" for a retire proposal. Expected one of: ${PROPOSAL_SOURCES.join(", ")}.`);
298
+ }
299
+ let parsedRef;
300
+ try {
301
+ parsedRef = parseRefInput(input.ref);
302
+ }
303
+ catch (err) {
304
+ throw new UsageError(`Invalid retire proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_PROPOSAL");
305
+ }
306
+ const typeDir = stashDirFor(parsedRef.type);
307
+ if (!typeDir) {
308
+ throw new UsageError(`Unknown asset type "${parsedRef.type}" in retire proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_PROPOSAL");
309
+ }
310
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
311
+ const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
312
+ const targetRoot = path.resolve(proposalTarget.root);
313
+ const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
314
+ if (!fs.existsSync(targetAbs)) {
315
+ throw new UsageError(`Retire proposal target "${input.ref}" does not exist at ${targetAbs}.`, "INVALID_PROPOSAL");
316
+ }
317
+ const targetRelPath = path.relative(targetRoot, targetAbs);
318
+ const beforeContent = fs.readFileSync(targetAbs, "utf8");
319
+ const changes = [{ path: targetRelPath, op: "delete" }];
320
+ const proposedTarget = { source: proposalTarget.source, root: targetRoot };
321
+ const confidence = typeof input.confidence === "number" &&
322
+ Number.isFinite(input.confidence) &&
323
+ input.confidence >= 0 &&
324
+ input.confidence <= 1
325
+ ? input.confidence
326
+ : undefined;
327
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
328
+ const created = nowIso(ctx);
329
+ const proposal = {
330
+ id: (ctx?.randomUUID ?? randomUUID)(),
331
+ ref: normalizedRef,
332
+ status: "pending",
333
+ source: input.source,
334
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
335
+ createdAt: created,
336
+ updatedAt: created,
337
+ payload: { content: "" },
338
+ changes,
339
+ proposedTarget,
340
+ beforeHash: contentHash(beforeContent),
341
+ beforeHashNormalized: contentHash(beforeContent, "normalized"),
342
+ ...(confidence !== undefined ? { confidence } : {}),
343
+ retirement: input.retirement,
344
+ };
345
+ upsertProposal(db, proposal, stashDir);
346
+ return proposal;
347
+ }));
348
+ }
275
349
  function queryProposals(db, stashDir, options) {
276
350
  // The live queue holds only pending proposals, so without the archive a
277
351
  // non-pending status matches nothing.
@@ -336,8 +410,14 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
336
410
  return exact;
337
411
  if (idOrRef.includes(":") || idOrRef.includes("/")) {
338
412
  const wantRef = filterRefIdentity(idOrRef);
413
+ // Should-fix 7: by-ref resolution never picks a retire proposal — the
414
+ // newest pending proposal for a ref could be a `consolidate-pair`
415
+ // retirement rather than the reflect/distill edit a person typed the
416
+ // ref to accept, and accepting it archives the asset instead. A retire
417
+ // is reached by its own proposal id, or by the explicit generator
418
+ // `consolidate-pair` (bulk accept/reject).
339
419
  const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
340
- .filter((p) => proposalMatchesRef(p.ref, wantRef))
420
+ .filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
341
421
  .sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
342
422
  const found = newest("pending") ?? newest();
343
423
  if (found)
@@ -480,6 +560,14 @@ export function expireStaleProposals(stashDir, config, ctx) {
480
560
  const nowMs = (ctx?.now ?? Date.now)();
481
561
  const pending = listProposals(stashDir, { status: "pending" }, ctx);
482
562
  for (const p of pending) {
563
+ // Should-fix 8: a retire proposal never expires by age. B2's accept-time
564
+ // hash check already refuses it once it goes stale, and the
565
+ // one-pending-retire-per-asset rule (pair-pass.ts's pendingRetireRefs)
566
+ // bounds how many can queue up — retention expiry would instead
567
+ // permanently drop a still-fresh pair nobody has reviewed yet, with no
568
+ // way back short of the pair pass finding it again from scratch.
569
+ if (isRetireProposal(p))
570
+ continue;
483
571
  const createdMs = new Date(p.createdAt).getTime();
484
572
  if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
485
573
  continue;
@@ -900,8 +988,72 @@ function requireAcceptedTarget(proposal) {
900
988
  }
901
989
  return proposal.acceptedTarget;
902
990
  }
991
+ /**
992
+ * O1 (alpha.9): an accepted consolidate PROMOTION retires its source memory
993
+ * (and its `.derived` twin), so promotion no longer leaves a memory/
994
+ * knowledge duplicate behind. Runs whether a person accepted the promotion
995
+ * or triage auto-promotion did — this is called from inside
996
+ * `promoteProposalWithLease`'s ordinary accept path, below the drain/CLI
997
+ * layer, so both routes hit it the same way. Best-effort: a failure here
998
+ * only warns — the promotion itself already succeeded and is not undone —
999
+ * and a source already gone (raced with something else, or never existed)
1000
+ * is silently skipped, not an error.
1001
+ *
1002
+ * B3: the promotion was queued against the source's content as it stood at
1003
+ * mint time (`accepted.promotionSourceHash`). If the source was edited since
1004
+ * — the freshest edit is exactly what a person would not want silently
1005
+ * discarded into the archive — this only warns and leaves the source alone;
1006
+ * the promotion itself still stands. A proposal minted before this field
1007
+ * existed carries no hash at all, so it is treated the same way: never
1008
+ * archived, not verified against a hash that was never recorded.
1009
+ */
1010
+ function retirePromotionSource(mutationTarget, accepted) {
1011
+ if (!accepted.promotionSource)
1012
+ return;
1013
+ try {
1014
+ const sourceRef = parseRefInput(accepted.promotionSource);
1015
+ const typeDir = stashDirFor(sourceRef.type);
1016
+ if (!typeDir)
1017
+ return;
1018
+ const sourcePath = assetPathForName(sourceRef.type, path.join(mutationTarget.source.path, typeDir), sourceRef.name);
1019
+ if (!fs.existsSync(sourcePath))
1020
+ return;
1021
+ if (!accepted.promotionSourceHash) {
1022
+ warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
1023
+ return;
1024
+ }
1025
+ const currentHash = contentHash(fs.readFileSync(sourcePath, "utf8"), "body");
1026
+ if (currentHash !== accepted.promotionSourceHash) {
1027
+ warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
1028
+ return;
1029
+ }
1030
+ const candidate = {
1031
+ ref: accepted.promotionSource,
1032
+ reason: "promoted",
1033
+ proposalId: accepted.id,
1034
+ successorRefs: [accepted.ref],
1035
+ };
1036
+ const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
1037
+ const paths = [
1038
+ sourcePath,
1039
+ path.join(mutationTarget.source.path, record.archivedPath),
1040
+ path.join(mutationTarget.source.path, record.auditPath),
1041
+ ];
1042
+ const twin = derivedTwinPath(sourcePath, sourceRef.type);
1043
+ if (twin) {
1044
+ const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
1045
+ paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1046
+ }
1047
+ commitWriteTargetBoundary(mutationTarget, `Retire promoted source ${accepted.promotionSource}`, { paths });
1048
+ }
1049
+ catch (error) {
1050
+ warn(`[proposal] O1: failed to retire promotion source ${accepted.promotionSource} for ${accepted.id}: ${error instanceof Error ? error.message : String(error)}`);
1051
+ }
1052
+ }
903
1053
  async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
904
1054
  const proposal = getProposal(stashDir, id, ctx);
1055
+ if (isRetireProposal(proposal))
1056
+ return retireProposalWithLease(stashDir, config, proposal, options, ctx);
905
1057
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
906
1058
  if (proposal.status === "accepted") {
907
1059
  // Accepting again is a no-op, provided the published bytes are still there.
@@ -942,8 +1094,288 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
942
1094
  decidedAt,
943
1095
  }, ctx);
944
1096
  await indexWrittenProposalAsset(mutationTarget, assetPath);
1097
+ if (accepted.status === "accepted" && accepted.source === "consolidate")
1098
+ retirePromotionSource(mutationTarget, accepted);
945
1099
  return { proposal: accepted, assetPath, ref: accepted.ref };
946
1100
  }
1101
+ /**
1102
+ * Every archive dir a tombstone under `.akm/memory-cleanup/archive/` claims
1103
+ * for `proposalId` — the primary asset's, and its `.derived` twin's if one
1104
+ * was archived alongside it. Used to detect what a resumed retire accept
1105
+ * (should-fix 5) has already moved.
1106
+ */
1107
+ function findRetireArchiveDirsByProposalId(stashRoot, proposalId) {
1108
+ const archiveRoot = path.join(stashRoot, ".akm", "memory-cleanup", "archive");
1109
+ let entries;
1110
+ try {
1111
+ entries = fs.readdirSync(archiveRoot);
1112
+ }
1113
+ catch {
1114
+ return undefined;
1115
+ }
1116
+ const dirs = [];
1117
+ for (const name of entries) {
1118
+ let data;
1119
+ try {
1120
+ data = parseFrontmatter(fs.readFileSync(path.join(archiveRoot, name, "cleanup.md"), "utf8")).data;
1121
+ }
1122
+ catch {
1123
+ continue;
1124
+ }
1125
+ if (data.proposalId === proposalId)
1126
+ dirs.push(path.relative(stashRoot, path.join(archiveRoot, name)));
1127
+ }
1128
+ return dirs.length > 0 ? dirs : undefined;
1129
+ }
1130
+ /** The absolute original paths a set of archive dirs' own tombstones claim — for resume detection. */
1131
+ function alreadyArchivedOriginalPaths(stashRoot, dirs) {
1132
+ const paths = new Set();
1133
+ for (const dirRel of dirs) {
1134
+ try {
1135
+ const data = parseFrontmatter(fs.readFileSync(path.join(stashRoot, dirRel, "cleanup.md"), "utf8")).data;
1136
+ if (typeof data.originalPath === "string")
1137
+ paths.add(path.resolve(stashRoot, data.originalPath));
1138
+ }
1139
+ catch {
1140
+ // An unreadable tombstone just is not counted "already done" — the move below re-attempts that file.
1141
+ }
1142
+ }
1143
+ return paths;
1144
+ }
1145
+ /**
1146
+ * Should-fix 5: record a retire accept's intent — `backupContent` and which
1147
+ * files (asset, `.derived` twin) are about to move — on the still-pending
1148
+ * proposal BEFORE any file is moved. A crash after this point resumes from
1149
+ * exactly this record instead of re-deriving `backupContent` from whatever
1150
+ * is on disk afterward, or from the archived copy, which for a `supersedes`
1151
+ * judgement already carries the edge the accept itself is about to write.
1152
+ */
1153
+ function recordRetireAcceptIntent(stashDir, proposalId, intent, ctx) {
1154
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1155
+ const current = requireProposal(db, stashDir, proposalId);
1156
+ if (current.retireAcceptIntent)
1157
+ return current;
1158
+ const next = { ...current, retireAcceptIntent: intent };
1159
+ upsertProposal(db, next, stashDir);
1160
+ return next;
1161
+ }));
1162
+ }
1163
+ /**
1164
+ * Persist a retire's "accepted" decision — the row, its ledger decision and
1165
+ * its event — the one finalize step a fresh accept and one resumed after a
1166
+ * crash (should-fix 5) share: by the time either calls it, every file move
1167
+ * is already confirmed done. Mirrors the accept branch of
1168
+ * {@link persistProposalDecision}, kept separate since a retire's
1169
+ * accepted-shape fields (`retiredArchive`, no published `content`) do not
1170
+ * fit that function's create/update-shaped `decision` union.
1171
+ */
1172
+ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
1173
+ const decidedAt = nowIso(ctx);
1174
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1175
+ const current = requireProposal(db, stashDir, proposal.id);
1176
+ if (current.status === "accepted")
1177
+ return current;
1178
+ if (current.status !== "pending") {
1179
+ throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
1180
+ }
1181
+ const next = {
1182
+ ...proposal,
1183
+ retireAcceptIntent: undefined, // finalized — the intent only matters while still pending
1184
+ status: "accepted",
1185
+ updatedAt: decidedAt,
1186
+ review: { outcome: "accepted", decidedAt },
1187
+ acceptedTarget: {
1188
+ source: info.targetName,
1189
+ root: info.targetRoot,
1190
+ path: info.assetPath,
1191
+ contentHash: info.contentHash,
1192
+ },
1193
+ retiredArchive: { dirs: info.archiveDirs },
1194
+ backupContent: info.backupContent,
1195
+ ...(info.gateDecision
1196
+ ? { gateDecision: { ...info.gateDecision, decidedAt: info.gateDecision.decidedAt ?? decidedAt } }
1197
+ : {}),
1198
+ };
1199
+ upsertProposal(db, next, stashDir);
1200
+ recordImproveLedgerDecision(db, {
1201
+ proposalId: next.id,
1202
+ stashDir,
1203
+ ref: next.ref,
1204
+ source: next.source,
1205
+ outcome: "accepted",
1206
+ at: decidedAt,
1207
+ });
1208
+ insertEventOnce(db, {
1209
+ eventType: "promoted",
1210
+ ts: decidedAt,
1211
+ ref: next.ref,
1212
+ metadata: {
1213
+ proposalId: next.id,
1214
+ source: next.source,
1215
+ ...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
1216
+ assetPath: info.assetPath,
1217
+ retired: true,
1218
+ ...(info.eventMetadata ? info.eventMetadata : {}),
1219
+ },
1220
+ idempotencyKey: `${next.id}:promoted`,
1221
+ });
1222
+ return next;
1223
+ }));
1224
+ }
1225
+ /**
1226
+ * B2: throws a stale-retire `UsageError` unless the successor still exists
1227
+ * and both sides' recorded body hashes still match their current files — the
1228
+ * durable half of the chain guard. Used for a fresh accept, and (4b, third
1229
+ * review round) to re-check a resumed accept whose intent was recorded but
1230
+ * nothing has moved yet: a separate proposal accepted in between (e.g. this
1231
+ * one's successor itself retired by a B->C accept) can make the decision
1232
+ * stale even though nothing about the retired side's own file changed.
1233
+ */
1234
+ function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
1235
+ const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
1236
+ const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
1237
+ const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
1238
+ const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
1239
+ if (!successorBytes || !retiredFresh || !successorFresh) {
1240
+ throw new UsageError(`Retire proposal ${proposalId} is stale — successor ${retirement.successorRef} ` +
1241
+ `${successorBytes === undefined ? "no longer exists" : !successorFresh ? "changed" : `and ${proposalRef} changed`} ` +
1242
+ "since judging; refusing to retire.", "INVALID_FLAG_VALUE");
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Accept a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): no
1247
+ * new content is written. Should-fix 5 (second review round) makes this a
1248
+ * three-phase, resume-safe sequence: (1) record intent — `backupContent`
1249
+ * and the exact files about to move — on the still-pending proposal; (2)
1250
+ * move each file into the recoverable cleanup archive
1251
+ * (`archiveCleanupCandidate`), skipping any the tombstone scan shows a prior,
1252
+ * crashed attempt already moved; (3) finalize via
1253
+ * {@link persistRetireAcceptance}. A `supersedes` judgement writes the
1254
+ * supersede edge on the retired (older) side AFTER intent is recorded (4a,
1255
+ * third review round — recording it first means a crash before the edge
1256
+ * write can never cause a resume to re-read the file and capture its own
1257
+ * edge into `backupContent`), so the archived copy still preserves it. A
1258
+ * target already gone with no recorded intent (raced with something else)
1259
+ * fails cleanly with a `UsageError`, the same clean-error idiom every other
1260
+ * staleness check in this file uses — never an unhandled throw.
1261
+ */
1262
+ async function retireProposalWithLease(stashDir, config, proposal, options, ctx) {
1263
+ const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1264
+ const ref = parseRefInput(proposal.ref);
1265
+ if (proposal.status === "accepted") {
1266
+ // Accepting again is a no-op, provided the target is still gone (retired) and its archive is still there.
1267
+ const recorded = requireAcceptedTarget(proposal);
1268
+ const assetPath = acceptedAssetPath(proposal, target, ref);
1269
+ const archive = proposal.retiredArchive;
1270
+ const archivedStill = archive?.dirs.every((dir) => fs.existsSync(path.join(target.source.path, dir))) === true;
1271
+ if (fs.existsSync(assetPath) || !archivedStill) {
1272
+ throw new UsageError(`Accepted retire proposal ${proposal.id} no longer matches its archive.`, "INVALID_FLAG_VALUE");
1273
+ }
1274
+ return { proposal, assetPath: recorded.path, ref: proposal.ref };
1275
+ }
1276
+ if (proposal.status !== "pending") {
1277
+ throw new UsageError(`Proposal ${proposal.id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1278
+ }
1279
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1280
+ if (!assetPath)
1281
+ throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
1282
+ let working = proposal;
1283
+ let intent = proposal.retireAcceptIntent;
1284
+ if (!intent) {
1285
+ // Fresh accept — no recorded intent yet, so the target must still be there.
1286
+ if (!fs.existsSync(assetPath)) {
1287
+ throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists — it may already have been retired, promoted away, or removed by another proposal.`, "INVALID_FLAG_VALUE");
1288
+ }
1289
+ const currentBytes = fs.readFileSync(assetPath);
1290
+ const retirement = proposal.retirement;
1291
+ if (!retirement) {
1292
+ // createRetireProposal always sets this — a row without one is corrupt, not merely stale.
1293
+ throw new Error(`Retire proposal ${proposal.id} has no retirement metadata.`);
1294
+ }
1295
+ // B2 (this is the durable half of the chain guard; the same-run half is
1296
+ // `retiredThisRun` in pair-pass.ts):
1297
+ assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, currentBytes);
1298
+ assertAkmAssetWrite(target.source);
1299
+ // Phase 1: record intent BEFORE any move, and BEFORE the supersede edge
1300
+ // (4a, third review round) — `backupContent` is `currentBytes`, read
1301
+ // above, before any mutation of this file. Recording first means a crash
1302
+ // between here and the edge write below can never cause a resume to
1303
+ // re-read the file and capture the edge INTO backupContent as if it were
1304
+ // the original.
1305
+ intent = { assetPath, backupContent: currentBytes.toString("utf8") };
1306
+ working = recordRetireAcceptIntent(stashDir, proposal.id, intent, ctx);
1307
+ if (retirement.judgeLabel === "supersedes") {
1308
+ try {
1309
+ writeSupersededEdge(assetPath, retirement.successorRef);
1310
+ }
1311
+ catch (error) {
1312
+ warn(`[proposal] failed to write the supersede edge for ${proposal.id} (continuing with the retire): ${error instanceof Error ? error.message : String(error)}`);
1313
+ }
1314
+ }
1315
+ }
1316
+ else {
1317
+ assertAkmAssetWrite(target.source);
1318
+ // 4b (third review round): intent was recorded but Phase 2 never moved
1319
+ // anything yet — re-run the B2 freshness check before resuming. A
1320
+ // separate proposal accepted in the meantime (this one's successor
1321
+ // itself retired by a B->C accept) can make the decision stale even
1322
+ // though nothing here changed. Once something has moved, it is too late
1323
+ // to cleanly refuse — Phase 2 below already tolerates a partial move.
1324
+ if (working.retirement && fs.existsSync(intent.assetPath)) {
1325
+ assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
1326
+ }
1327
+ }
1328
+ // Phase 2: move, idempotently — a resumed call skips whichever file a
1329
+ // tombstone under this proposal's own id already claims.
1330
+ const mutationTarget = prepareWriteTargetForMutation(target);
1331
+ const already = findRetireArchiveDirsByProposalId(mutationTarget.source.path, proposal.id) ?? [];
1332
+ const alreadyDone = alreadyArchivedOriginalPaths(mutationTarget.source.path, already);
1333
+ const archiveDirs = [...already];
1334
+ const paths = [];
1335
+ const candidate = {
1336
+ ref: proposal.ref,
1337
+ reason: working.retirement?.reason ?? "duplicate",
1338
+ proposalId: proposal.id,
1339
+ ...(working.retirement?.successorRef ? { successorRefs: [working.retirement.successorRef] } : {}),
1340
+ };
1341
+ if (!alreadyDone.has(path.resolve(intent.assetPath)) && fs.existsSync(intent.assetPath)) {
1342
+ const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, intent.assetPath);
1343
+ archiveDirs.push(path.dirname(record.auditPath));
1344
+ paths.push(intent.assetPath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
1345
+ }
1346
+ // 4d (third review round): the twin path is re-derived, not carried on the
1347
+ // intent — it is a pure function of assetPath and the ref's type, and the
1348
+ // tombstone scan above (`alreadyDone`) already finds one archived earlier,
1349
+ // so storing it was redundant persisted state.
1350
+ const twinPath = derivedTwinPath(intent.assetPath, ref.type);
1351
+ if (twinPath && !alreadyDone.has(path.resolve(twinPath)) && fs.existsSync(twinPath)) {
1352
+ const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
1353
+ archiveDirs.push(path.dirname(twinRecord.auditPath));
1354
+ paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1355
+ }
1356
+ if (paths.length > 0)
1357
+ commitWriteTargetBoundary(mutationTarget, `Retire ${proposal.ref}`, { paths });
1358
+ if (archiveDirs.length === 0) {
1359
+ // Recorded intent, but neither file is at its original location NOR
1360
+ // archived under this proposal's id: something else removed the target
1361
+ // between intent and move. Refuse cleanly rather than finalize on
1362
+ // nothing.
1363
+ throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists and was not archived by this proposal — refusing to accept.`, "INVALID_FLAG_VALUE");
1364
+ }
1365
+ // Phase 3: finalize — always from the recorded intent's own backupContent,
1366
+ // never a hash guessed from the archived copy.
1367
+ const accepted = persistRetireAcceptance(stashDir, working, {
1368
+ targetName: mutationTarget.source.name,
1369
+ targetRoot: mutationTarget.source.path,
1370
+ assetPath: intent.assetPath,
1371
+ contentHash: contentHash(intent.backupContent),
1372
+ archiveDirs,
1373
+ backupContent: intent.backupContent,
1374
+ ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1375
+ ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1376
+ }, ctx);
1377
+ return { proposal: accepted, assetPath: intent.assetPath, ref: accepted.ref };
1378
+ }
947
1379
  /**
948
1380
  * Restore an accepted proposal's target from the backup taken at promotion.
949
1381
  * New-asset proposals have no backup; a target edited since acceptance is
@@ -952,8 +1384,175 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
952
1384
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
953
1385
  return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
954
1386
  }
1387
+ /**
1388
+ * Revert an accepted `retire` proposal (0.9.17-alpha.9): move the archived
1389
+ * file(s) — the retired asset, and its `.derived` twin when one was archived
1390
+ * alongside it — back to where they lived, then overwrite the primary with
1391
+ * the exact pre-retire bytes `backupContent` recorded at accept (S4) —
1392
+ * byte-exact, so it also undoes any supersede edge accept wrote without
1393
+ * touching one a person had already written, and without appending a
1394
+ * trailing newline the original never had. Each archive dir is located from
1395
+ * `retiredArchive.dirs` (set at accept time) and its own `cleanup.md`
1396
+ * tombstone names the exact paths to restore — no re-scan of every
1397
+ * tombstone in the archive.
1398
+ *
1399
+ * Should-fix 5 (second review round): every archive dir is resolved and
1400
+ * validated before any of them are moved, AND that validation tells "not
1401
+ * yet moved" apart from "already moved by an earlier, crashed attempt of
1402
+ * our own" (original present, archived copy gone) rather than treating the
1403
+ * latter as a conflict — so a retry of a crashed revert resumes instead of
1404
+ * erroring on its own prior work. The archive dirs (tombstones) are removed
1405
+ * only after the "reverted" decision is durably recorded, not interleaved
1406
+ * with the moves — a crash between moving a file and recording the
1407
+ * decision used to delete that file's tombstone first, leaving an
1408
+ * "accepted" proposal a retry could neither finish nor re-validate.
1409
+ */
1410
+ async function unretireProposalWithLease(stashDir, config, proposal, options, ctx) {
1411
+ const ref = parseRefInput(proposal.ref);
1412
+ if (!stashDirFor(ref.type)) {
1413
+ throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1414
+ }
1415
+ if (proposal.status !== "accepted" && proposal.status !== "reverted") {
1416
+ throw new UsageError(`only accepted proposals can be reverted (proposal ${proposal.id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
1417
+ }
1418
+ const recorded = requireAcceptedTarget(proposal);
1419
+ const boundTarget = resolveRecordedProposalTarget(config, proposal.id, recorded, options.target);
1420
+ const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
1421
+ if (proposal.status === "reverted") {
1422
+ return { proposal, assetPath, ref: proposal.ref };
1423
+ }
1424
+ const archive = proposal.retiredArchive;
1425
+ if (!archive || archive.dirs.length === 0) {
1426
+ throw new UsageError(`no archive recorded for this retire proposal (id: ${proposal.id})`, "MISSING_REQUIRED_ARGUMENT", "A retire proposal's archive is recorded at accept time; a proposal missing it cannot be reverted through this path.");
1427
+ }
1428
+ const target = prepareWriteTargetForMutation(boundTarget);
1429
+ const pending = [];
1430
+ for (const dirRel of archive.dirs) {
1431
+ const dirAbs = path.join(target.source.path, dirRel);
1432
+ const auditPath = path.join(dirAbs, "cleanup.md");
1433
+ let tombstoneData;
1434
+ try {
1435
+ tombstoneData = parseFrontmatter(fs.readFileSync(auditPath, "utf8")).data;
1436
+ }
1437
+ catch (error) {
1438
+ throw new UsageError(`Archive for proposal ${proposal.id} is missing its tombstone (${dirRel}); cannot revert: ${error instanceof Error ? error.message : String(error)}`, "INVALID_FLAG_VALUE");
1439
+ }
1440
+ const originalRel = typeof tombstoneData.originalPath === "string" ? tombstoneData.originalPath : undefined;
1441
+ const archivedRel = typeof tombstoneData.archivedPath === "string" ? tombstoneData.archivedPath : undefined;
1442
+ if (!originalRel || !archivedRel) {
1443
+ throw new UsageError(`Archive tombstone for proposal ${proposal.id} (${dirRel}) is malformed.`, "INVALID_FLAG_VALUE");
1444
+ }
1445
+ const originalAbs = path.join(target.source.path, originalRel);
1446
+ const archivedAbs = path.join(target.source.path, archivedRel);
1447
+ const originalExists = fs.existsSync(originalAbs);
1448
+ const archivedExists = fs.existsSync(archivedAbs);
1449
+ if (originalExists && archivedExists) {
1450
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} already exists (created since retirement); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
1451
+ }
1452
+ if (!originalExists && !archivedExists) {
1453
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: archived copy ${archivedRel} is missing.`, "INVALID_FLAG_VALUE");
1454
+ }
1455
+ // Must-fix 2 (third review round): "original present, archived copy
1456
+ // missing" is not necessarily our own earlier, crashed revert — once a
1457
+ // purge can delete an archived copy on its own, a LATER, unrelated file
1458
+ // can occupy this same path (a new memory reusing a retired one's name),
1459
+ // and the write step below would overwrite it with the retired asset's
1460
+ // pre-retire bytes. Only the primary has a recorded pre-retire hash
1461
+ // (`retirement.retiredContentHash`) to tell the two apart; resume only
1462
+ // when it matches the file actually sitting there, otherwise refuse
1463
+ // exactly as the conflict case above does.
1464
+ if (originalExists && !archivedExists && path.resolve(originalAbs) === path.resolve(assetPath)) {
1465
+ const expectedHash = proposal.retirement?.retiredContentHash;
1466
+ let currentHash;
1467
+ try {
1468
+ currentHash = contentHash(fs.readFileSync(originalAbs, "utf8"), "body");
1469
+ }
1470
+ catch {
1471
+ currentHash = undefined;
1472
+ }
1473
+ if (!expectedHash || currentHash !== expectedHash) {
1474
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} exists but its content does not match what was retired (its path may have been reused since); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
1475
+ }
1476
+ }
1477
+ pending.push({ dirAbs, auditPath, originalAbs, archivedAbs, alreadyDone: originalExists });
1478
+ }
1479
+ const restoredPaths = [];
1480
+ let primaryOriginalAbs;
1481
+ for (const p of pending) {
1482
+ if (!p.alreadyDone) {
1483
+ fs.mkdirSync(path.dirname(p.originalAbs), { recursive: true });
1484
+ fs.renameSync(p.archivedAbs, p.originalAbs);
1485
+ recordWrittenPath(p.archivedAbs);
1486
+ recordWrittenPath(p.originalAbs);
1487
+ }
1488
+ restoredPaths.push(p.originalAbs, p.archivedAbs, p.auditPath);
1489
+ if (path.resolve(p.originalAbs) === path.resolve(assetPath))
1490
+ primaryOriginalAbs = p.originalAbs;
1491
+ }
1492
+ // S4 / nit: overwrite the primary with the EXACT pre-retire bytes recorded
1493
+ // at accept (`backupContent`) — no appended trailing newline either, so
1494
+ // YAML comments, key order, a pre-existing human `supersededBy` edge, and
1495
+ // even the exact absence of a final newline all survive the round trip.
1496
+ // The archived copy just moved back may carry a `supersededBy` edge THIS
1497
+ // accept wrote (a `supersedes` judgement); restoring the recorded original
1498
+ // bytes already removes exactly that edge, so no separate
1499
+ // removeSupersededEdge mutation runs here — one that could not tell "the
1500
+ // edge accept wrote" from "an edge a person had already written" apart,
1501
+ // and would delete either.
1502
+ if (primaryOriginalAbs && proposal.backupContent !== undefined) {
1503
+ writeProposalAssetFile(primaryOriginalAbs, proposal.backupContent);
1504
+ }
1505
+ commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: restoredPaths });
1506
+ const decidedAt = nowIso(ctx);
1507
+ const reverted = withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1508
+ const current = requireProposal(db, stashDir, proposal.id);
1509
+ if (current.status === "reverted")
1510
+ return current;
1511
+ const next = {
1512
+ ...current,
1513
+ status: "reverted",
1514
+ updatedAt: decidedAt,
1515
+ review: { outcome: "rejected", reason: "reverted: archived asset restored", decidedAt },
1516
+ };
1517
+ upsertProposal(db, next, stashDir);
1518
+ recordImproveLedgerDecision(db, {
1519
+ proposalId: next.id,
1520
+ stashDir,
1521
+ ref: next.ref,
1522
+ source: next.source,
1523
+ outcome: "rejected",
1524
+ at: decidedAt,
1525
+ detail: "reverted",
1526
+ });
1527
+ insertEventOnce(db, {
1528
+ eventType: "proposal_reverted",
1529
+ ts: decidedAt,
1530
+ ref: next.ref,
1531
+ metadata: { proposalId: next.id, source: next.source, assetPath },
1532
+ idempotencyKey: `${next.id}:reverted`,
1533
+ });
1534
+ return next;
1535
+ }));
1536
+ // Only now — after the decision is durably recorded — remove the archive
1537
+ // dirs (tombstones). See the function doc comment for why this ordering
1538
+ // matters.
1539
+ for (const p of pending) {
1540
+ fs.rmSync(p.dirAbs, { recursive: true, force: true });
1541
+ }
1542
+ try {
1543
+ if (!(await indexWrittenAssets(target.source.path, restoredPaths, { bundleId: target.source.name }))) {
1544
+ warn(`[proposals] ${restoredPaths.join(", ")} were restored but not indexed; run \`akm index\`.`);
1545
+ }
1546
+ }
1547
+ catch (error) {
1548
+ warn(`[proposals] restored paths were not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
1549
+ }
1550
+ return { proposal: reverted, assetPath, ref: proposal.ref };
1551
+ }
955
1552
  async function revertProposalWithLease(stashDir, config, id, options, ctx) {
956
1553
  const proposal = getProposal(stashDir, id, ctx);
1554
+ if (isRetireProposal(proposal))
1555
+ return unretireProposalWithLease(stashDir, config, proposal, options, ctx);
957
1556
  const ref = parseRefInput(proposal.ref);
958
1557
  if (!stashDirFor(ref.type)) {
959
1558
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
@@ -973,7 +1572,13 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
973
1572
  }
974
1573
  const target = prepareWriteTargetForMutation(boundTarget);
975
1574
  if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
976
- throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1575
+ // Nit (third review round): by-ref resolution skips retire proposals
1576
+ // (should-fix 7), so reverting by ref when a SEPARATE retire proposal
1577
+ // for the same ref exists (pending or already accepted) lands here with
1578
+ // no clue that proposal is the real story — name it when one does.
1579
+ const siblingRetire = listProposalsReadOnly(stashDir, { ref: proposal.ref, includeArchive: true }, ctx).find((p) => isRetireProposal(p) && (p.status === "pending" || p.status === "accepted"));
1580
+ throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content` +
1581
+ (siblingRetire ? ` (a retire proposal for this ref exists: ${siblingRetire.id})` : ""), "INVALID_FLAG_VALUE");
977
1582
  }
978
1583
  const decidedAt = nowIso(ctx);
979
1584
  writeProposalAssetFile(assetPath, backupContent.endsWith("\n") ? backupContent : `${backupContent}\n`);
@@ -988,7 +1593,11 @@ export function diffProposal(stashDir, config, id, options = {}, ctx) {
988
1593
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
989
1594
  const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
990
1595
  const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
991
- const proposed = proposalContent(proposal);
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);
992
1601
  return {
993
1602
  existing,
994
1603
  proposed,