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.
- package/CHANGELOG.md +277 -1694
- package/STABILITY.md +9 -8
- package/dist/assets/hints/cli-hints-full.md +6 -7
- package/dist/assets/improve-strategies/catchup.json +0 -3
- package/dist/assets/improve-strategies/consolidate.json +0 -1
- package/dist/assets/improve-strategies/default.json +1 -2
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
- package/dist/assets/improve-strategies/quick.json +1 -2
- package/dist/assets/improve-strategies/reflect-distill.json +1 -2
- package/dist/assets/improve-strategies/thorough.json +0 -3
- package/dist/assets/prompts/consolidate-pair.md +20 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +17 -19
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
- package/dist/assets/templates/html/health.html +3 -5
- package/dist/cli/retired-commands.js +1 -1
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +46 -1
- package/dist/commands/health/archive-usage.js +92 -0
- package/dist/commands/health/data-dir-usage.js +25 -13
- package/dist/commands/health/html-report.js +1 -4
- package/dist/commands/health/improve-metrics.js +25 -37
- package/dist/commands/health/md-report.js +1 -6
- package/dist/commands/health/report-view-model.js +4 -14
- package/dist/commands/health/windows.js +0 -1
- package/dist/commands/health.js +13 -0
- package/dist/commands/improve/consolidate/continuity-check.js +137 -0
- package/dist/commands/improve/consolidate/pair-pass.js +791 -0
- package/dist/commands/improve/consolidate.js +38 -63
- package/dist/commands/improve/extract-prompt.js +1 -2
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/improve/improve-strategies.js +23 -5
- package/dist/commands/improve/improve.js +19 -30
- package/dist/commands/improve/ledger.js +3 -2
- package/dist/commands/improve/loop-stages.js +5 -85
- package/dist/commands/improve/memory/memory-belief.js +3 -1
- package/dist/commands/improve/memory/memory-improve.js +262 -11
- package/dist/commands/improve/planner.js +0 -5
- package/dist/commands/improve/preparation.js +20 -135
- package/dist/commands/improve/retrieval-scope.js +19 -4
- package/dist/commands/improve/salience.js +1 -14
- package/dist/commands/improve/stage.js +0 -1
- package/dist/commands/lint/base-linter.js +19 -11
- package/dist/commands/proposal/drain.js +8 -1
- package/dist/commands/proposal/proposal-cli.js +16 -2
- package/dist/commands/proposal/proposal-types.js +7 -0
- package/dist/commands/proposal/proposal.js +37 -6
- package/dist/commands/proposal/repository.js +613 -4
- package/dist/commands/proposal/validators/proposals.js +9 -0
- package/dist/commands/read/knowledge.js +3 -2
- package/dist/commands/read/show.js +0 -14
- package/dist/commands/sources/info.js +122 -18
- package/dist/commands/sources/stash-cli.js +23 -3
- package/dist/core/bundle-rename.js +1 -7
- package/dist/core/config/config-schema.js +8 -1
- package/dist/core/config/config.js +23 -48
- package/dist/core/config/engine-semantics.js +0 -2
- package/dist/core/config/schema/improve-processes.js +17 -42
- package/dist/core/config/schema/index-config.js +5 -25
- package/dist/core/file-change.js +13 -5
- package/dist/core/improve-result.js +22 -6
- package/dist/core/improve-types.js +0 -1
- package/dist/core/loopback.js +7 -12
- package/dist/core/parse.js +13 -16
- package/dist/core/state/migrations.js +15 -0
- package/dist/core/time.js +0 -20
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/ensure-index.js +2 -2
- package/dist/indexer/index-written-assets.js +2 -3
- package/dist/indexer/indexer.js +18 -418
- package/dist/indexer/passes/metadata.js +0 -19
- package/dist/indexer/walk/walker.js +3 -4
- package/dist/llm/client.js +8 -10
- package/dist/llm/embedders/remote.js +1 -2
- package/dist/llm/feature-gate.js +0 -5
- package/dist/output/shapes/helpers.js +20 -4
- package/dist/output/text/command-format.js +9 -8
- package/dist/output/text/proposal-format.js +47 -1
- package/dist/output/text/show-format.js +0 -20
- package/dist/scripts/akm-migrate-node.js +923 -950
- package/dist/scripts/akm-migrate.js +923 -950
- package/dist/setup/steps/connection.js +5 -6
- package/dist/setup/steps/platforms.js +2 -2
- package/dist/sources/providers/git-stash.js +83 -4
- package/dist/storage/repositories/improve-ledger-repository.js +48 -7
- package/dist/storage/repositories/index-connection.js +5 -2
- package/dist/storage/repositories/index-entries-repository.js +4 -7
- package/dist/storage/repositories/index-entry-schema.js +4 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +7 -26
- package/dist/storage/repositories/index-schema.js +55 -104
- package/dist/storage/repositories/proposals-repository.js +61 -0
- package/dist/storage/repositories/salience-repository.js +1 -19
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +130 -41
- package/docs/migration/release-notes/README.md +7 -0
- package/docs/reference/cli.md +27 -21
- package/docs/reference/configuration.md +21 -12
- package/docs/reference/data-and-telemetry.md +0 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -342
- package/dist/assets/improve-strategies/graph-refresh.json +0 -15
- package/dist/assets/prompts/contradiction-judge.md +0 -33
- package/dist/assets/prompts/graph-extract-system.md +0 -1
- package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
- package/dist/assets/prompts/metadata-enhance-system.md +0 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
- package/dist/indexer/db/graph-db.js +0 -399
- package/dist/indexer/graph/graph-extraction.js +0 -809
- package/dist/indexer/graph/graph-related.js +0 -131
- package/dist/indexer/graph/graph-types.js +0 -4
- package/dist/llm/graph-extract.js +0 -892
- 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
|
-
|
|
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
|
-
|
|
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,
|