UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

1,857 lines • 96.8 kB
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
 * The proposal queue (#225; storage in state.db since #578). Proposals are
 * queue state, not assets: rows in the `proposals` table partitioned by
 * `stash_dir`, where archival is a status flip that keeps the full audit trail
 * (review, reason, backup for revert). They never go through the asset writer
 * until accepted — {@link promoteProposal} is the bridge that writes the
 * accepted payload into the bundle.
 */
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
import { loadConfig } from "../../core/config/config.js";
import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
import { appendEvent } from "../../core/events.js";
import { proposalContent } from "../../core/file-change.js";
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
import { warn } from "../../core/warn.js";
import { recordWrittenPath } from "../../core/write-provenance.js";
import { assertAkmAssetWrite, commitAcceptedPaths, commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js";
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
import { deriveInstallations } from "../../indexer/installations.js";
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
import { pkgVersion } from "../../version.js";
import { contentHash } from "../improve/content-hash.js";
import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
import { runBaseChecks } from "../lint/base-linter.js";
import { formatNewAssetDiff, formatRetireDiff, formatUnifiedDiff } from "./diff-format.js";
import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, proposalWaitingSince, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
import { repairProposalContent, validateProposal } from "./validators/proposals.js";
export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
export { proposalContent };
const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
const MS_PER_DAY = 86_400_000;
/** `p` with `content` as both the payload and the primary change's `after` (they must agree). */
function withProposalContent(p, content) {
    return {
        ...p,
        payload: { ...p.payload, content },
        // A delete-op primary change carries no `after`.
        changes: p.changes.map((c, i) => (i === 0 && c.op !== "delete" ? { ...c, after: content } : c)),
    };
}
function nowIso(ctx) {
    return new Date((ctx?.now ?? Date.now)()).toISOString();
}
function withProposalsDb(ctx, fn) {
    return withStateDb(fn, { path: ctx?.dbPath });
}
function isRetiredProposalConceptId(conceptId) {
    const colon = conceptId.indexOf(":");
    return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
}
function proposalRefIdentity(ref) {
    try {
        const parsed = parseBundleRef(ref);
        if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
            return undefined;
        return { conceptId: parsed.conceptId, ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}) };
    }
    catch {
        return undefined;
    }
}
/** A `--ref` filter: a short ref matches its concept in the queue, a qualified one also its bundle. */
function filterRefIdentity(ref) {
    const identity = proposalRefIdentity(ref);
    if (!identity) {
        throw new UsageError(`Invalid asset-ref filter "${ref}". Use the 0.9.0 grammar [bundle//]conceptId, e.g. knowledge/guide.md or lessons/deploy.`, "INVALID_FLAG_VALUE");
    }
    return identity;
}
function proposalMatchesRef(proposalRef, filter) {
    const proposal = proposalRefIdentity(proposalRef);
    return (proposal !== undefined &&
        proposal.conceptId === filter.conceptId &&
        (filter.bundle === undefined || proposal.bundle === filter.bundle));
}
/**
 * The durable `proposals.ref`: `bundle//conceptId`, the conceptId built from
 * the type table (so a not-yet-existing asset keys onto its final spelling) and
 * the bundle the queue writes to — byte-identical to the item_ref the indexer
 * mints for the accepted asset.
 */
function proposalDurableRef(parsedRef, target) {
    const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
    if (!isBundleSlug(target.source)) {
        throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
    }
    if (parsedRef.origin !== undefined && target.source !== parsedRef.origin) {
        throw new UsageError(`Proposal ref bundle "${parsedRef.origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
    }
    return `${target.source}//${conceptId}`;
}
function resolveCreateProposalTarget(stashDir, explicit, bundle) {
    if (explicit) {
        if (!isBundleSlug(explicit.source) || !explicit.root.trim()) {
            throw new UsageError("Proposal targets require a current bundle name and materialized root.", "INVALID_FLAG_VALUE");
        }
        return { source: explicit.source, root: path.resolve(explicit.root) };
    }
    const config = loadConfig();
    const local = resolveProposalQueueTarget(stashDir, config);
    if (!bundle || bundle === local.source)
        return local;
    const target = resolveBundleWriteTarget(config, bundle);
    return { source: target.source.name, root: target.source.path };
}
export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
    const root = path.resolve(stashDir);
    const sources = resolveSourceEntries(root, config);
    const sourceIndex = sources.findIndex((source) => path.resolve(source.path) === root);
    const source = sources[sourceIndex];
    const bundleId = sourceIndex >= 0 ? deriveInstallations(sources)[sourceIndex]?.id : undefined;
    if (!source || !bundleId) {
        throw new ConfigError(`No bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
    }
    if (!source.registryId && Object.keys(config.bundles ?? {}).length > 0) {
        throw new ConfigError(`No configured bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
    }
    if (source.writable !== true) {
        throw new UsageError(`Proposal bundle "${bundleId}" is not writable.`, "INVALID_FLAG_VALUE");
    }
    return { source: bundleId, root };
}
/**
 * The configured bundle that owns the asset `itemRef` names when it is not the
 * bundle rooted at `target`; otherwise `undefined`. A differing name is
 * confirmed by the owner's root, so one bundle known by two spellings is never
 * taken for two, and an owner that cannot be resolved is never refused on.
 */
function otherOwningBundle(itemRef, target) {
    try {
        const owner = parseBundleRef(itemRef).bundle;
        if (owner === undefined || owner === target.source)
            return undefined;
        const sources = resolveSourceEntries(target.root, loadConfig());
        const ownerSource = sources[deriveInstallations(sources).findIndex((installation) => installation.id === owner)];
        return ownerSource !== undefined && path.resolve(ownerSource.path) !== path.resolve(target.root)
            ? owner
            : undefined;
    }
    catch (err) {
        rethrowIfTestIsolationError(err);
        return undefined;
    }
}
/**
 * Create a pending proposal (a random UUID id). Obviously invalid input is
 * refused with a typed `proposal_creation_rejected` event. The mint and its
 * `proposed` ledger rows commit in one transaction; whether a ref may be
 * proposed again is decided earlier, by the stage's candidate selection.
 */
export function createProposal(stashDir, input, ctx) {
    if (!isValidProposalSource(input.source)) {
        warn(`[proposal] Unknown source "${input.source}". ` +
            `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
            "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
    }
    else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
        warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
            "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
    }
    const rejectProposal = (reason, message) => {
        appendEvent({
            eventType: "proposal_creation_rejected",
            ref: input.ref,
            metadata: { source: input.source, reason },
        });
        throw new UsageError(message, "INVALID_PROPOSAL");
    };
    let parsedRef;
    try {
        parsedRef = parseRefInput(input.ref);
    }
    catch (err) {
        return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
    }
    const typeDir = stashDirFor(parsedRef.type);
    if (!typeDir) {
        return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`);
    }
    if (!input.payload.content.trim()) {
        return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
    }
    if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
        return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
    }
    // Only consolidate — the pipeline that historically flooded the queue with
    // frontmatter-less proposals — must carry a description.
    if (input.source === "consolidate") {
        const desc = input.payload.frontmatter?.description;
        if (typeof desc !== "string" || desc.trim() === "") {
            return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
        }
    }
    const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
    const owner = input.itemRef ? otherOwningBundle(input.itemRef, proposalTarget) : undefined;
    if (owner) {
        return rejectProposal("cross_bundle", `Proposal for "${input.ref}" rewrites ${input.itemRef}, which bundle "${owner}" owns, but its queue target is bundle "${proposalTarget.source}". Filing it there would fork the asset out of "${owner}" or overwrite this bundle's copy with content taken from the other. Improve it in its own bundle instead (\`akm improve --bundle ${owner}\`).`);
    }
    // The FileChange envelope, and the target's before-hashes as of mint (the
    // freshness check at accept compares against them).
    const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
    const targetRoot = path.resolve(proposalTarget.root);
    let targetRelPath;
    let beforeContent;
    try {
        const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
        targetRelPath = path.relative(targetRoot, targetAbs);
        if (fs.existsSync(targetAbs))
            beforeContent = fs.readFileSync(targetAbs, "utf8");
    }
    catch {
        targetRelPath = path.join(typeDir, parsedRef.name);
    }
    const content = targetRelPath.toLowerCase().endsWith(".md")
        ? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
        : input.payload.content;
    const changes = [
        { path: targetRelPath, after: content, op: beforeContent !== undefined ? "update" : "create" },
    ];
    const proposedTarget = { source: proposalTarget.source, root: targetRoot };
    if (hasCanonicalProposalValidator(parsedRef.type)) {
        // Mint checks structure only; the quality validators run at accept.
        const report = runProposalValidators({
            id: "pending",
            ref: normalizedRef,
            status: "pending",
            source: input.source,
            createdAt: "",
            updatedAt: "",
            payload: { ...input.payload, content },
            changes,
            proposedTarget,
        }, canonicalOnlyProposalValidators);
        if (!report.ok) {
            return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
                .map((finding) => `[${finding.kind}] ${finding.message}`)
                .join("\n")}`);
        }
    }
    const confidence = typeof input.confidence === "number" &&
        Number.isFinite(input.confidence) &&
        input.confidence >= 0 &&
        input.confidence <= 1
        ? input.confidence
        : undefined;
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const created = nowIso(ctx);
        const proposal = {
            id: (ctx?.randomUUID ?? randomUUID)(),
            ref: normalizedRef,
            status: "pending",
            source: input.source,
            ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
            createdAt: created,
            updatedAt: created,
            payload: {
                content,
                ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
            },
            changes,
            proposedTarget,
            ...(beforeContent !== undefined
                ? { beforeHash: contentHash(beforeContent), beforeHashNormalized: contentHash(beforeContent, "normalized") }
                : {}),
            ...(confidence !== undefined ? { confidence } : {}),
            ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
            ...(input.promotionSource !== undefined ? { promotionSource: input.promotionSource } : {}),
            ...(input.promotionSourceHash !== undefined ? { promotionSourceHash: input.promotionSourceHash } : {}),
        };
        upsertProposal(db, proposal, stashDir);
        for (const ref of input.attemptedRefs ?? [normalizedRef]) {
            recordImproveLedger(db, {
                stashDir,
                ref,
                source: input.source,
                outcome: "proposed",
                at: created,
                proposalId: proposal.id,
            });
        }
        return proposal;
    }));
}
/**
 * Mint a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): its
 * primary `FileChange` deletes `ref`'s own file rather than writing new
 * content, so this does not reuse {@link createProposal} (which always
 * builds a create/update change and enforces content/description rules that
 * do not apply to a delete). `ref` must already exist on disk — retiring a
 * phantom is a caller bug, refused rather than silently accepted.
 *
 * Deliberately does NOT record an `improve_ledger` "proposed" row: the pair
 * pass keys its own ledger cadence per INITIATOR (source `consolidate-pair`,
 * `pair-pass.ts`'s own end-of-run write), which may differ from this
 * proposal's `ref` — the initiator and the retired side are not always the
 * same asset (see `runConsolidatePairPass`). Recording one here, keyed by
 * the retired ref instead, would be a second, competing row for whichever
 * asset happens to be both.
 */
export function createRetireProposal(stashDir, input, ctx) {
    if (!isValidProposalSource(input.source)) {
        warn(`[proposal] Unknown source "${input.source}" for a retire proposal. Expected one of: ${PROPOSAL_SOURCES.join(", ")}.`);
    }
    let parsedRef;
    try {
        parsedRef = parseRefInput(input.ref);
    }
    catch (err) {
        throw new UsageError(`Invalid retire proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_PROPOSAL");
    }
    const typeDir = stashDirFor(parsedRef.type);
    if (!typeDir) {
        throw new UsageError(`Unknown asset type "${parsedRef.type}" in retire proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_PROPOSAL");
    }
    const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
    const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
    const targetRoot = path.resolve(proposalTarget.root);
    const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
    if (!fs.existsSync(targetAbs)) {
        throw new UsageError(`Retire proposal target "${input.ref}" does not exist at ${targetAbs}.`, "INVALID_PROPOSAL");
    }
    const targetRelPath = path.relative(targetRoot, targetAbs);
    const beforeContent = fs.readFileSync(targetAbs, "utf8");
    const changes = [{ path: targetRelPath, op: "delete" }];
    const proposedTarget = { source: proposalTarget.source, root: targetRoot };
    const confidence = typeof input.confidence === "number" &&
        Number.isFinite(input.confidence) &&
        input.confidence >= 0 &&
        input.confidence <= 1
        ? input.confidence
        : undefined;
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const created = nowIso(ctx);
        const proposal = {
            id: (ctx?.randomUUID ?? randomUUID)(),
            ref: normalizedRef,
            status: "pending",
            source: input.source,
            ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
            createdAt: created,
            updatedAt: created,
            payload: { content: "" },
            changes,
            proposedTarget,
            beforeHash: contentHash(beforeContent),
            beforeHashNormalized: contentHash(beforeContent, "normalized"),
            ...(confidence !== undefined ? { confidence } : {}),
            retirement: input.retirement,
        };
        upsertProposal(db, proposal, stashDir);
        return proposal;
    }));
}
function queryProposals(db, stashDir, options) {
    // The live queue holds only pending proposals, so without the archive a
    // non-pending status matches nothing.
    if (!options.includeArchive && options.status !== undefined && options.status !== "pending")
        return [];
    const status = options.includeArchive ? options.status : "pending";
    const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
    return listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => {
        if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef))
            return false;
        if (!options.type)
            return true;
        try {
            return parseRefInput(p.ref).type === options.type;
        }
        catch {
            return false;
        }
    });
}
/** One stash's proposals: the pending queue, or with `includeArchive` the decided ones too. */
export function listProposals(stashDir, options = {}, ctx) {
    return withProposalsDb(ctx, (db) => queryProposals(db, stashDir, options));
}
/**
 * {@link listProposals} on a read snapshot that never creates or migrates
 * state.db: prompt building runs before the first dispatch has validated its
 * credentials, and a missing store is simply empty. A caller that already
 * holds a live connection (`ctx.db`) reads through it: a snapshot would copy
 * the whole database for nothing.
 */
export function listProposalsReadOnly(stashDir, options = {}, ctx) {
    if (ctx?.db)
        return queryProposals(ctx.db, stashDir, options);
    const dbPath = ctx?.dbPath ?? getStateDbPath();
    if (!fs.existsSync(dbPath))
        return [];
    const db = openSqliteReadSnapshot(dbPath);
    if (!db)
        return [];
    try {
        return queryProposals(db, stashDir, options);
    }
    finally {
        db.close();
    }
}
/** A proposal by id, live or archived. */
export function getProposal(stashDir, id, ctx) {
    return withProposalsDb(ctx, (db) => requireProposal(db, stashDir, id));
}
function requireProposal(db, stashDir, id) {
    const proposal = getStateProposal(db, id, stashDir);
    if (!proposal)
        throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
    return proposal;
}
/**
 * A proposal by exact id; else, for an asset ref, its most recent pending
 * proposal (then most recent archived); else by a unique pending id prefix.
 */
export function resolveProposalId(stashDir, idOrRef, ctx) {
    return withProposalsDb(ctx, (db) => {
        const exact = getStateProposal(db, idOrRef, stashDir);
        if (exact)
            return exact;
        if (idOrRef.includes(":") || idOrRef.includes("/")) {
            const wantRef = filterRefIdentity(idOrRef);
            // Should-fix 7: by-ref resolution never picks a retire proposal — the
            // newest pending proposal for a ref could be a `consolidate-pair`
            // retirement rather than the reflect/distill edit a person typed the
            // ref to accept, and accepting it archives the asset instead. A retire
            // is reached by its own proposal id, or by the explicit generator
            // `consolidate-pair` (bulk accept/reject).
            const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
                .filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
                .sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
            const found = newest("pending") ?? newest();
            if (found)
                return found;
            throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
        }
        const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
        if (prefixMatches.length === 1)
            return requireProposal(db, stashDir, prefixMatches[0]);
        if (prefixMatches.length > 1) {
            throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
        }
        throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "PROPOSAL_NOT_FOUND");
    });
}
/**
 * The ledger outcome of a decision. A procedural refusal (retention expiry, a
 * stale target, a missing asset) judged nothing, so it never carries the
 * rejection window.
 */
function ledgerOutcomeForDecision(status, gateDecision) {
    if (status === "accepted")
        return "accepted";
    if (gateDecision?.outcome === "auto-rejected") {
        if (gateDecision.reason === EXPIRED_GATE_REASON)
            return "expired";
        if (gateDecision.reason === STALE_TARGET_GATE_REASON || gateDecision.reason === ASSET_MISSING_GATE_REASON) {
            return "failed";
        }
    }
    return "rejected";
}
/**
 * What a decision's ledger row is about. A promotion's row is keyed by its
 * source memory, so a decision on it names that memory, together with the body
 * hash the promotion was queued against — this is what holds the memory back
 * until it changes (`isContentDrivenDecision`). Any other proposal, and a
 * promotion minted before the hash was recorded, is keyed by its own ref.
 * Every decision that can create a row when none carries the proposal id — a
 * reject, an accept and a drain deferral alike — must key it this way: a stray
 * row under the knowledge ref carries the proposal id, so a later verdict
 * updates that one and never reaches the memory's own row.
 */
function decisionLedgerSubject(proposal) {
    if (proposal.source === CONSOLIDATE_LEDGER_SOURCE &&
        proposal.promotionSource !== undefined &&
        proposal.promotionSourceHash !== undefined) {
        return { ref: proposal.promotionSource, contentHash: proposal.promotionSourceHash };
    }
    return { ref: proposal.ref };
}
/** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const existing = requireProposal(db, stashDir, id);
        if (existing.status !== "pending") {
            throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
        }
        const decidedAt = nowIso(ctx);
        const updated = {
            ...existing,
            status,
            updatedAt: decidedAt,
            review: { outcome: status, ...(reason !== undefined ? { reason } : {}), decidedAt },
            ...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
        };
        upsertProposal(db, updated, stashDir);
        recordImproveLedgerDecision(db, {
            proposalId: updated.id,
            stashDir,
            ...decisionLedgerSubject(updated),
            source: updated.source,
            outcome: ledgerOutcomeForDecision(status, gateDecision),
            at: decidedAt,
            ...(reason !== undefined ? { detail: reason } : {}),
        });
        return updated;
    }));
}
/**
 * Stamp a gate's decision (#577) on a pending proposal without changing its
 * status: the drain's verdict, or the generating stage's judge pass or review
 * deferral (`deferred` records `review_needed` in the ledger). A proposal no
 * longer pending is skipped (`undefined`), so a batch never aborts.
 */
export function recordGateDecision(stashDir, id, decision, ctx) {
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const existing = getStateProposal(db, id, stashDir);
        if (!existing || existing.status !== "pending")
            return undefined;
        const decidedAt = decision.decidedAt ?? nowIso(ctx);
        const updated = { ...existing, gateDecision: { ...decision, decidedAt } };
        upsertProposal(db, updated, stashDir);
        if (decision.outcome === "deferred") {
            recordImproveLedgerDecision(db, {
                proposalId: updated.id,
                stashDir,
                ...decisionLedgerSubject(updated),
                source: updated.source,
                outcome: "review_needed",
                at: decidedAt,
                detail: decision.reason,
            });
        }
        return updated;
    }));
}
/**
 * Reject pending `reflect` proposals whose target no longer exists in any of
 * `sourceDirs` (a maintenance pass). Other sources — lessons above all — may
 * legitimately target assets that do not exist yet.
 */
export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
    const t0 = Date.now();
    const orphans = [];
    const byType = {};
    const reflectPending = listProposals(stashDir, { status: "pending" }, ctx).filter((p) => p.source === "reflect");
    for (const p of reflectPending) {
        let parsed;
        try {
            parsed = parseRefInput(p.ref);
        }
        catch {
            continue;
        }
        const spec = stashDirFor(parsed.type);
        if (parsed.type === "lesson" || !spec)
            continue;
        const exists = sourceDirs.some((root) => fs.existsSync(assetPathForName(parsed.type, path.join(root, spec), parsed.name)));
        if (exists)
            continue;
        try {
            archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx, {
                outcome: "auto-rejected",
                reason: ASSET_MISSING_GATE_REASON,
                gate: "orphan-purge",
            });
            orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
            byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
        }
        catch (err) {
            warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return { checked: reflectPending.length, rejected: orphans.length, durationMs: Date.now() - t0, byType, orphans };
}
/**
 * Archive pending proposals older than `archiveRetentionDays` (default 90;
 * 0 disables; counted from the last reopen, if any) as rejected with an
 * `expired` gate decision and a `proposal_expired` event. The ledger records
 * `expired` — a short grace, not the rejection window, since nobody judged the
 * content.
 */
export function expireStaleProposals(stashDir, config, ctx) {
    const t0 = Date.now();
    const retentionDays = config.archiveRetentionDays ?? 90;
    const expiredProposals = [];
    if (retentionDays <= 0)
        return { checked: 0, expired: 0, durationMs: Date.now() - t0, retentionDays, expiredProposals };
    const nowMs = (ctx?.now ?? Date.now)();
    const pending = listProposals(stashDir, { status: "pending" }, ctx);
    for (const p of pending) {
        // Should-fix 8: a retire proposal never expires by age. B2's accept-time
        // hash check already refuses it once it goes stale, and the
        // one-pending-retire-per-asset rule (pair-pass.ts's pendingRetireRefs)
        // bounds how many can queue up — retention expiry would instead
        // permanently drop a still-fresh pair nobody has reviewed yet, with no
        // way back short of the pair pass finding it again from scratch.
        if (isRetireProposal(p))
            continue;
        // A reopened proposal's wait starts over at the reopen (#997): expiring it
        // on its original age would undo the reopen at the next sweep.
        const createdMs = new Date(proposalWaitingSince(p)).getTime();
        if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
            continue;
        try {
            archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx, {
                outcome: "auto-rejected",
                reason: EXPIRED_GATE_REASON,
                gate: "retention",
            });
            const ageDays = Math.floor((nowMs - createdMs) / MS_PER_DAY);
            expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
            appendEvent({
                eventType: "proposal_expired",
                ref: p.ref,
                metadata: {
                    proposalId: p.id,
                    source: p.source,
                    ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
                    ageDays,
                    retentionDays,
                },
            });
        }
        catch (err) {
            warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return {
        checked: pending.length,
        expired: expiredProposals.length,
        durationMs: Date.now() - t0,
        retentionDays,
        expiredProposals,
    };
}
/** The hash a `staged` gate decision records, so a reader can tell the judged bytes from an edit. */
export function proposalContentHash(proposal) {
    return contentHash(proposalContent(proposal));
}
/**
 * Record an accept or revert — the row, its ledger decision and its event in
 * one transaction — after the asset file is on disk. A proposal already in the
 * requested state is returned unchanged.
 */
function persistProposalDecision(stashDir, proposal, decision, ctx) {
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const current = requireProposal(db, stashDir, proposal.id);
        let next;
        if (decision.operation === "accept") {
            const publishedHash = contentHash(decision.content);
            if (current.status === "accepted") {
                if (current.acceptedTarget?.contentHash !== publishedHash) {
                    throw new Error(`Accepted proposal ${proposal.id} does not match the published content.`);
                }
                return current;
            }
            if (current.status !== "pending") {
                throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
            }
            const root = decision.target.source.path;
            const persisted = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
                ? withProposalContent(proposal, decision.content)
                : {
                    ...proposal,
                    payload: { ...proposal.payload, content: decision.content },
                    changes: [
                        {
                            path: path.relative(root, decision.assetPath),
                            op: decision.existed ? "update" : "create",
                            after: decision.content,
                        },
                    ],
                    proposedTarget: { source: decision.target.source.name, root },
                };
            next = {
                ...persisted,
                status: "accepted",
                updatedAt: decision.decidedAt,
                review: { outcome: "accepted", decidedAt: decision.decidedAt },
                acceptedTarget: {
                    source: decision.target.source.name,
                    root,
                    path: decision.assetPath,
                    contentHash: publishedHash,
                },
                ...(decision.gateDecision
                    ? {
                        gateDecision: {
                            // The drain's verdict replaces the quality judge's stamp; the judge's evidence stays on the row.
                            ...(current.gateDecision?.scores ? { scores: current.gateDecision.scores } : {}),
                            ...(current.gateDecision?.judgeReason ? { judgeReason: current.gateDecision.judgeReason } : {}),
                            ...decision.gateDecision,
                            decidedAt: decision.gateDecision.decidedAt ?? decision.decidedAt,
                        },
                    }
                    : {}),
                ...(decision.backupContent !== undefined ? { backupContent: decision.backupContent } : {}),
            };
        }
        else {
            if (current.status === "reverted")
                return current;
            if (current.status !== "accepted") {
                throw new Error(`Proposal ${proposal.id} changed status during reversion (${current.status}).`);
            }
            next = {
                ...current,
                status: "reverted",
                updatedAt: decision.decidedAt,
                review: {
                    outcome: "rejected",
                    reason: "reverted: prior content restored from backup",
                    decidedAt: decision.decidedAt,
                },
            };
        }
        const accept = decision.operation === "accept";
        upsertProposal(db, next, stashDir);
        recordImproveLedgerDecision(db, {
            proposalId: next.id,
            stashDir,
            ...decisionLedgerSubject(next),
            source: next.source,
            outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
            at: decision.decidedAt,
            ...(accept ? {} : { detail: "reverted" }),
        });
        insertEventOnce(db, {
            eventType: accept ? "promoted" : "proposal_reverted",
            ts: decision.decidedAt,
            ref: next.ref,
            metadata: {
                proposalId: next.id,
                source: next.source,
                ...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
                assetPath: decision.assetPath,
                ...(next.eligibilitySource !== undefined ? { eligibilitySource: next.eligibilitySource } : {}),
                ...(decision.operation === "accept" && decision.eventMetadata ? decision.eventMetadata : {}),
            },
            idempotencyKey: `${next.id}:${accept ? "promoted" : "reverted"}`,
        });
        return next;
    }));
}
export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
    const decidedAt = nowIso(ctx);
    const rejected = archiveProposal(stashDir, proposalId, "rejected", reason, { ...ctx, now: () => Date.parse(decidedAt) }, gateDecision);
    withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        insertEventOnce(db, {
            eventType: "rejected",
            ts: decidedAt,
            ref: rejected.ref,
            metadata: {
                proposalId: rejected.id,
                source: rejected.source,
                ...(rejected.sourceRun !== undefined ? { sourceRun: rejected.sourceRun } : {}),
                ...(reason !== undefined ? { reason } : {}),
            },
            idempotencyKey: `${rejected.id}:rejected`,
        });
    }));
    return rejected;
}
/** Write `content` atomically (temp file + rename), keeping an existing file's mode. */
function writeProposalAssetFile(assetPath, content) {
    fs.mkdirSync(path.dirname(assetPath), { recursive: true });
    const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
    const tempPath = path.join(path.dirname(assetPath), `.akm-proposal-${process.pid}-${randomUUID()}.tmp`);
    fs.writeFileSync(tempPath, content, { encoding: "utf8", mode });
    try {
        fs.renameSync(tempPath, assetPath);
    }
    catch (error) {
        fs.rmSync(tempPath, { force: true });
        throw error;
    }
    recordWrittenPath(assetPath);
}
/** Index the file just written; the next `akm index` catches up when this cannot. */
async function indexWrittenProposalAsset(target, assetPath) {
    try {
        if (!(await indexWrittenAssets(target.source.path, [assetPath], { bundleId: target.source.name }))) {
            warn(`[proposals] ${assetPath} was written but not indexed; run \`akm index\`.`);
        }
    }
    catch (error) {
        warn(`[proposals] ${assetPath} was written but not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
    }
}
function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
    let target;
    try {
        target = explicitTarget
            ? resolveWriteTarget(config, explicitTarget)
            : resolveBundleWriteTarget(config, binding.source);
    }
    catch {
        throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}, but that writable target is no longer configured.`, "INVALID_FLAG_VALUE");
    }
    const targetBundleId = canonicalBundleIdForTarget(config, target);
    if (targetBundleId !== binding.source || path.resolve(target.source.path) !== path.resolve(binding.root)) {
        throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}; ` +
            `--target "${explicitTarget}" resolves to "${targetBundleId}" at ${target.source.path}.`, "INVALID_FLAG_VALUE");
    }
    return { ...target, source: { ...target.source, name: targetBundleId } };
}
function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
    const named = (target) => ({
        ...target,
        source: { ...target.source, name: canonicalBundleIdForTarget(config, target) },
    });
    if (!proposal.proposedTarget) {
        const identity = proposalRefIdentity(proposal.ref);
        if (!identity)
            throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
        if (identity.bundle !== undefined) {
            const target = named(resolveBundleWriteTarget(config, identity.bundle));
            if (explicitTarget !== undefined &&
                canonicalBundleIdForTarget(config, resolveWriteTarget(config, explicitTarget)) !== identity.bundle) {
                throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
            }
            if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
                throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
            }
            return target;
        }
        const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
        if (!target) {
            throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
        }
        return named(target);
    }
    if (queueTarget &&
        explicitTarget === undefined &&
        (canonicalBundleIdForTarget(config, queueTarget) !== proposal.proposedTarget.source ||
            path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root))) {
        throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
    }
    return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
}
// ── OKF v0.2 provenance on promotion (#730) ─────────────────────────────────
// `generated.by` names what produced the content (an automated source is
// `akm/<version>`, anything else `human:<actor>`); `verified[].by` names what
// accepted this promotion (a gate decision is `akm/<version>`, a direct
// `akm proposal accept` is `human:<actor>`). Only AKM-native targets get here.
function resolveActorId(ctx) {
    if (ctx?.actorId)
        return ctx.actorId();
    try {
        return os.userInfo().username?.trim() || "local";
    }
    catch {
        return "local";
    }
}
function isPlainRecord(value) {
    return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
 * Stamp provenance onto a promoted asset's frontmatter: bare top-level
 * `generated` and `verified` (as OKF v0.2 spells them; `verified` accumulates),
 * and `sources` under `provenance:`, since a bare `sources:` is the wiki
 * citation-string convention. An existing frontmatter block keeps its raw body
 * bytes.
 */
function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
    const parsed = parseFrontmatter(content);
    const fm = { ...parsed.data };
    const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
    const human = () => `human:${resolveActorId(ctx)}`;
    fm.generated = { by: isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : human(), at: nowIsoStr };
    // The older nested `provenance.verified` spelling is absorbed too, so history is never lost.
    const priorVerified = Array.isArray(fm.verified)
        ? fm.verified
        : Array.isArray(existingProvenance.verified)
            ? existingProvenance.verified
            : [];
    fm.verified = [
        ...priorVerified,
        { by: gateDecision !== undefined ? `akm/${pkgVersion}` : human(), at: gateDecision?.decidedAt ?? nowIsoStr },
    ];
    const provenance = { ...existingProvenance };
    delete provenance.generatedBy;
    delete provenance.generatedAt;
    delete provenance.verified;
    if (Array.isArray(fm.evidenceSources)) {
        const sources = fm.evidenceSources
            .filter((s) => typeof s === "string" && s.trim().length > 0)
            .map((resource) => ({ resource: resource.trim() }));
        if (sources.length > 0)
            provenance.sources = sources;
    }
    if (Object.keys(provenance).length > 0)
        fm.provenance = provenance;
    else
        delete fm.provenance;
    return parsed.frontmatter !== null
        ? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
        : assembleAsset(fm, parsed.content);
}
/**
 * Validate, stamp and write an accepted proposal into its bound target, then
 * archive it as accepted. Overwriting an existing asset keeps its prior content
 * on the row (`backupContent`) for `akm proposal revert`.
 */
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
    return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
}
function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
    let data;
    let body;
    let frontmatter;
    if (refType === "task") {
        try {
            const parsed = parseYaml(raw);
            data = isPlainRecord(parsed) ? parsed : {};
        }
        catch {
            data = {};
        }
        body = raw;
        frontmatter = null;
    }
    else {
        ({ data, content: body, frontmatter } = parseFrontmatter(raw));
    }
    const resolvedRoot = path.resolve(targetRoot);
    return runBaseChecks({
        filePath: assetPath,
        relPath: path.relative(targetRoot, assetPath),
        raw,
        data,
        body,
        frontmatter,
        fix: false,
        stashRoot: targetRoot,
        extraStashRoots: resolveSourceEntries(targetRoot, config)
            .map((source) => source.path)
            .filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot),
    }).filter((finding) => PROMOTION_LINT_ISSUE_TYPES.has(finding.issue));
}
/** The exact stamped bytes a promotion would publish, validated, without writing. */
export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
    const repairedContent = repairProposalContent(proposalContent(proposal));
    const prepared = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
    const report = validateProposal(prepared);
    if (!report.ok) {
        throw new UsageError(`Proposal ${proposal.id} failed validation:\n${report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n")}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
    }
    const ref = parseRefInput(prepared.ref);
    if (!stashDirFor(ref.type)) {
        throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
    }
    const target = resolveProposalWriteTarget(config, prepared, options.target, options.queueTarget);
    const assetPath = resolveAssetFilePathSafe(target.source, ref);
    if (!assetPath)
        throw new UsageError(`Cannot resolve proposal target ${prepared.ref}.`, "INVALID_PROPOSAL");
    assertAkmAssetWrite(target.source);
    const markdown = assetPath.toLowerCase().endsWith(".md");
    let stampedContent = markdown
        ? stampProposalProvenance(repairedContent, prepared, options.gateDecision, ctx, nowIso(ctx))
        : repairedContent;
    if (markdown && fs.existsSync(assetPath)) {
        // Keep the live target's bookkeeping frontmatter the proposal doesn't set
        // (STALE, R20) — dropping `inferenceProcessed` would re-run inference.
        try {
            stampedContent = carryForwardBookkeepingFrontmatter(stampedContent, fs.readFileSync(assetPath, "utf8"));
        }
        catch {
            // best-effort; the freshness check is the real staleness gate
        }
    }
    const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
    if (lintBlockers.length > 0) {
        const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
        warn(`[proposal] promotion lint for ${proposal.id} found (non-blocking): ${summary}`);
    }
    return { proposal: prepared, repairedContent, ref, target, assetPath, stampedContent };
}
/**
 * The target's current bytes, provided it is still the one the proposal was
 * minted against (STALE, R20): compared bookkeeping-insensitively when the
 * proposal carries a normalized before-hash, exactly otherwise. A target that
 * already holds this proposal's content is a promotion that wrote but did not
 * record — finishing it is allowed. Never overwrites newer content.
 */
export function readFreshProposalTarget(proposal, assetPath, stampedContent) {
    const current = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
    if (proposal.beforeHash !== undefined) {
        const fresh = current !== undefined &&
            (proposal.beforeHashNormalized !== undefined
                ? contentHash(current, "normalized") === proposal.beforeHashNormalized
                : contentHash(current) === proposal.beforeHash);
        const alreadyPublished = current !== undefined && contentHash(current, "normalized") === contentHash(stampedContent, "normalized");
        if (!fresh && !alreadyPublished) {
            throw new UsageError(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
        }
    }
    else if (current !== undefined && proposal.changes.some((change) => change.op === "create")) {
        throw new UsageError(`Proposal target was created after proposal ${proposal.id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
    }
    return current;
}
/** The recorded asset path of an accepted/reverted proposal, provided `target` is the same binding. */
function acceptedAssetPath(proposal, target, ref) {
    const accepted = proposal.acceptedTarget;
    const assetPath = resolveAssetFilePathSafe(target.source, ref);
    if (!accepted ||
        !assetPath ||
        accepted.source !== target.source.name ||
        path.resolve(accepted.root) !== path.resolve(target.source.path) ||
        path.resolve(accepted.path) !== path.resolve(assetPath)) {
        throw new UsageError(`proposal ${proposal.id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
    }
    return assetPath;
}
function requireAcceptedTarget(proposal) {
    if (!proposal.acceptedTarget) {
        const label = proposal.status === "reverted" ? "Reverted" : "Accepted";
        throw new UsageError(`${label} proposal ${proposal.id} has no recorded target.`, "INVALID_PROPOSAL");
    }
    return proposal.acceptedTarget;
}
/** An accept's commit subject: the generator, the proposal id's first 8 characters and the asset ref. */
function acceptCommitMessage(proposal) {
    return `akm accept: ${proposal.source} ${proposal.id.slice(0, 8)} ${proposal.ref}`;
}
/**
 * O1 (alpha.9): an accepted consolidate PROMOTION retires its source memory
 * (and its `.derived` twin), so promotion no longer leaves a memory/
 * knowledge duplicate behind. Runs whether a person accepted the promotion
 * or triage auto-promotion did — this is called from inside
 * `promoteProposalWithLease`'s ordinary accept path, below the drain/CLI
 * layer, so both routes hit it the same way. Best-effort: a failure here
 * only warns — the promotion itself already succeeded and is not undone —
 * and a source already gone (raced with something else, or never existed)
 * is silently skipped, not an error.
 *
 * B3: the promotion was queued against the source's content as it stood at
 * mint time (`accepted.promotionSourceHash`). If the source was edited since
 * — the freshest edit is exactly what a person would not want silently
 * discarded into the archive — this only warns and leaves the source alone;
 * the promotion itself still stands. A proposal minted before this field
 * existed carries no hash at all, so it is treated the same way: never
 * archived, not verified against a hash that was never recorded.
 */
function retirePromotionSource(mutationTarget, accepted, paths) {
    if (!accepted.promotionSource)
        return;
    try {
        const sourceRef = parseRefInput(accepted.promotionSource);
        const typeDir = stashDirFor(sourceRef.type);
        if (!typeDir)
            return;
        const sourcePath = assetPathForName(sourceRef.type, path.join(mutationTarget.source.path, typeDir), sourceRef.name);
        if (!fs.existsSync(sourcePath))
            return;
        if (!accepted.promotionSourceHash) {
            warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
            return;
        }
        const currentHash = contentHash(fs.readFileSync(sourcePath, "utf8"), "body");
        if (currentHash !== accepted.promotionSourceHash) {
            warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
            return;
        }
        const candidate = {
            ref: accepted.promotionSource,
            reason: "promoted",
            proposalId: accepted.id,
            successorRefs: [accepted.ref],
        };
        const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
        paths.push(sourcePath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
        const twin = derivedTwinPath(sourcePath, sourceRef.type);
        if (twin) {
            const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
            paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
        }
    }
    catch (error) {
        warn(`[proposal] O1: failed to retire promotion source ${accepted.promotionSource} for ${accepted.id}: ${error instanceof Error ? error.message : String(error)}`);
    }
}
async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
    const proposal = getProposal(stashDir, id, ctx);
    if (isRetireProposal(proposal))
        return retireProposalWithLease(stashDir, config, proposal, options, ctx);
    const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
    if (proposal.status === "accepted") {
        // Accepting again is a no-op, provided the published bytes are still there.
        const recorded = requireAcceptedTarget(proposal);
        const assetPath = acceptedAssetPath(proposal, target, parseRefInput(proposal.ref));
        if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
            throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
        }
        return { proposal, assetPath, ref: proposal.ref };
    }
    if (proposal.status !== "pending") {
        throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
    }
    const preflight = preflightProposalPromotion(config, proposal, { ...options, queueTarget: target }, ctx);
    const mutationTarget = prepareWriteTargetForMutation(target);
    const assetPath = resolveAssetFilePathSafe(mutationTarget.source, preflight.ref);
    if (!assetPath)
        throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
    const backup = readFreshProposalTarget(proposal, assetPath, preflight.stampedContent);
    assertAkmAssetWrite(mutationTarget.source);
    const refIdentity = proposalRefIdentity(preflight.proposal.ref);
    const proposalForMutation = refIdentity?.bundle === undefined
        ? { ...preflight.proposal, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
        : preflight.proposal;
    const decidedAt = nowIso(ctx);
    const content = preflight.stampedContent.endsWith("\n") ? preflight.stampedContent : `${preflight.stampedContent}\n`;
    writeProposalAssetFile(assetPath, content);
    const accepted = persistProposalDecision(stashDir, proposalForMutation, {
        operation: "accept",
        target: mutationTarget,
        assetPath,
        content,
        existed: backup !== undefined,
        ...(backup !== undefined ? { backupContent: backup.toString("utf8") } : {}),
        ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
        ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
        decidedAt,
    }, ctx);
    await indexWrittenProposalAsset(mutationTarget, assetPath);
    const paths = [assetPath];
    if (accepted.status === "accepted" && accepted.source === "consolidate")
        retirePromotionSource(mutationTarget, accepted, paths);
    commitAcceptedPaths(mutationTarget, acceptCommitMessage(accepted), paths);
    return { proposal: accepted, assetPath, ref: accepted.ref };
}
/**
 * Every archive dir a tombstone under `.akm/memory-cleanup/archive/` claims
 * for `proposalId` — the primary asset's, and its `.derived` twin's if one
 * was archived alongside it. Used to detect what a resumed retire accept
 * (should-fix 5) has already moved.
 */
function findRetireArchiveDirsByProposalId(stashRoot, proposalId) {
    const archiveRoot = path.join(stashRoot, ".akm", "memory-cleanup", "archive");
    let entries;
    try {
        entries = fs.readdirSync(archiveRoot);
    }
    catch {
        return undefined;
    }
    const dirs = [];
    for (const name of entries) {
        let data;
        try {
            data = parseFrontmatter(fs.readFileSync(path.join(archiveRoot, name, "cleanup.md"), "utf8")).data;
        }
        catch {
            continue;
        }
        if (data.proposalId === proposalId)
            dirs.push(path.relative(stashRoot, path.join(archiveRoot, name)));
    }
    return dirs.length > 0 ? dirs : undefined;
}
/** The absolute original paths a set of archive dirs' own tombstones claim — for resume detection. */
function alreadyArchivedOriginalPaths(stashRoot, dirs) {
    const paths = new Set();
    for (const dirRel of dirs) {
        try {
            const data = parseFrontmatter(fs.readFileSync(path.join(stashRoot, dirRel, "cleanup.md"), "utf8")).data;
            if (typeof data.originalPath === "string")
                paths.add(path.resolve(stashRoot, data.originalPath));
        }
        catch {
            // An unreadable tombstone just is not counted "already done" — the move below re-attempts that file.
        }
    }
    return paths;
}
/**
 * Should-fix 5: record a retire accept's intent — `backupContent` and which
 * files (asset, `.derived` twin) are about to move — on the still-pending
 * proposal BEFORE any file is moved. A crash after this point resumes from
 * exactly this record instead of re-deriving `backupContent` from whatever
 * is on disk afterward, or from the archived copy, which for a `supersedes`
 * judgement already carries the edge the accept itself is about to write.
 */
function recordRetireAcceptIntent(stashDir, proposalId, intent, ctx) {
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const current = requireProposal(db, stashDir, proposalId);
        if (current.retireAcceptIntent)
            return current;
        const next = { ...current, retireAcceptIntent: intent };
        upsertProposal(db, next, stashDir);
        return next;
    }));
}
/**
 * Persist a retire's "accepted" decision — the row, its ledger decision and
 * its event — the one finalize step a fresh accept and one resumed after a
 * crash (should-fix 5) share: by the time either calls it, every file move
 * is already confirmed done. Mirrors the accept branch of
 * {@link persistProposalDecision}, kept separate since a retire's
 * accepted-shape fields (`retiredArchive`, no published `content`) do not
 * fit that function's create/update-shaped `decision` union.
 */
function persistRetireAcceptance(stashDir, proposal, info, ctx) {
    const decidedAt = nowIso(ctx);
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const current = requireProposal(db, stashDir, proposal.id);
        if (current.status === "accepted")
            return current;
        if (current.status !== "pending") {
            throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
        }
        const next = {
            ...proposal,
            retireAcceptIntent: undefined, // finalized — the intent only matters while still pending
            status: "accepted",
            updatedAt: decidedAt,
            review: { outcome: "accepted", decidedAt },
            acceptedTarget: {
                source: info.targetName,
                root: info.targetRoot,
                path: info.assetPath,
                contentHash: info.contentHash,
            },
            retiredArchive: { dirs: info.archiveDirs },
            backupContent: info.backupContent,
            ...(info.gateDecision
                ? { gateDecision: { ...info.gateDecision, decidedAt: info.gateDecision.decidedAt ?? decidedAt } }
                : {}),
        };
        upsertProposal(db, next, stashDir);
        recordImproveLedgerDecision(db, {
            proposalId: next.id,
            stashDir,
            ref: next.ref,
            source: next.source,
            outcome: "accepted",
            at: decidedAt,
        });
        insertEventOnce(db, {
            eventType: "promoted",
            ts: decidedAt,
            ref: next.ref,
            metadata: {
                proposalId: next.id,
                source: next.source,
                ...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
                assetPath: info.assetPath,
                retired: true,
                ...(info.eventMetadata ? info.eventMetadata : {}),
            },
            idempotencyKey: `${next.id}:promoted`,
        });
        return next;
    }));
}
/**
 * B2: throws a stale-retire `UsageError` unless the successor still exists
 * and both sides' recorded body hashes still match their current files — the
 * durable half of the chain guard. Used for a fresh accept, and (4b, third
 * review round) to re-check a resumed accept whose intent was recorded but
 * nothing has moved yet: a separate proposal accepted in between (e.g. this
 * one's successor itself retired by a B->C accept) can make the decision
 * stale even though nothing about the retired side's own file changed.
 */
function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
    const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
    const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
    const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
    const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
    if (!successorBytes || !retiredFresh || !successorFresh) {
        throw new UsageError(`Retire proposal ${proposalId} is stale — successor ${retirement.successorRef} ` +
            `${successorBytes === undefined ? "no longer exists" : !successorFresh ? "changed" : `and ${proposalRef} changed`} ` +
            "since judging; refusing to retire.", "INVALID_FLAG_VALUE");
    }
}
/**
 * Accept a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): no
 * new content is written. Should-fix 5 (second review round) makes this a
 * three-phase, resume-safe sequence: (1) record intent — `backupContent`
 * and the exact files about to move — on the still-pending proposal; (2)
 * move each file into the recoverable cleanup archive
 * (`archiveCleanupCandidate`), skipping any the tombstone scan shows a prior,
 * crashed attempt already moved; (3) finalize via
 * {@link persistRetireAcceptance}. A `supersedes` judgement writes the
 * supersede edge on the retired (older) side AFTER intent is recorded (4a,
 * third review round — recording it first means a crash before the edge
 * write can never cause a resume to re-read the file and capture its own
 * edge into `backupContent`), so the archived copy still preserves it. A
 * target already gone with no recorded intent (raced with something else)
 * fails cleanly with a `UsageError`, the same clean-error idiom every other
 * staleness check in this file uses — never an unhandled throw.
 */
async function retireProposalWithLease(stashDir, config, proposal, options, ctx) {
    const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
    const ref = parseRefInput(proposal.ref);
    if (proposal.status === "accepted") {
        // Accepting again is a no-op, provided the target is still gone (retired) and its archive is still there.
        const recorded = requireAcceptedTarget(proposal);
        const assetPath = acceptedAssetPath(proposal, target, ref);
        const archive = proposal.retiredArchive;
        const archivedStill = archive?.dirs.every((dir) => fs.existsSync(path.join(target.source.path, dir))) === true;
        if (fs.existsSync(assetPath) || !archivedStill) {
            throw new UsageError(`Accepted retire proposal ${proposal.id} no longer matches its archive.`, "INVALID_FLAG_VALUE");
        }
        return { proposal, assetPath: recorded.path, ref: proposal.ref };
    }
    if (proposal.status !== "pending") {
        throw new UsageError(`Proposal ${proposal.id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
    }
    const assetPath = resolveAssetFilePathSafe(target.source, ref);
    if (!assetPath)
        throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
    let working = proposal;
    let intent = proposal.retireAcceptIntent;
    if (!intent) {
        // Fresh accept — no recorded intent yet, so the target must still be there.
        if (!fs.existsSync(assetPath)) {
            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");
        }
        const currentBytes = fs.readFileSync(assetPath);
        const retirement = proposal.retirement;
        if (!retirement) {
            // createRetireProposal always sets this — a row without one is corrupt, not merely stale.
            throw new Error(`Retire proposal ${proposal.id} has no retirement metadata.`);
        }
        // B2 (this is the durable half of the chain guard; the same-run half is
        // `retiredThisRun` in pair-pass.ts):
        assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, currentBytes);
        assertAkmAssetWrite(target.source);
        // Phase 1: record intent BEFORE any move, and BEFORE the supersede edge
        // (4a, third review round) — `backupContent` is `currentBytes`, read
        // above, before any mutation of this file. Recording first means a crash
        // between here and the edge write below can never cause a resume to
        // re-read the file and capture the edge INTO backupContent as if it were
        // the original.
        intent = { assetPath, backupContent: currentBytes.toString("utf8") };
        working = recordRetireAcceptIntent(stashDir, proposal.id, intent, ctx);
        if (retirement.judgeLabel === "supersedes") {
            try {
                writeSupersededEdge(assetPath, retirement.successorRef);
            }
            catch (error) {
                warn(`[proposal] failed to write the supersede edge for ${proposal.id} (continuing with the retire): ${error instanceof Error ? error.message : String(error)}`);
            }
        }
    }
    else {
        assertAkmAssetWrite(target.source);
        // 4b (third review round): intent was recorded but Phase 2 never moved
        // anything yet — re-run the B2 freshness check before resuming. A
        // separate proposal accepted in the meantime (this one's successor
        // itself retired by a B->C accept) can make the decision stale even
        // though nothing here changed. Once something has moved, it is too late
        // to cleanly refuse — Phase 2 below already tolerates a partial move.
        if (working.retirement && fs.existsSync(intent.assetPath)) {
            assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
        }
    }
    // Phase 2: move, idempotently — a resumed call skips whichever file a
    // tombstone under this proposal's own id already claims.
    const mutationTarget = prepareWriteTargetForMutation(target);
    const already = findRetireArchiveDirsByProposalId(mutationTarget.source.path, proposal.id) ?? [];
    const alreadyDone = alreadyArchivedOriginalPaths(mutationTarget.source.path, already);
    const archiveDirs = [...already];
    const paths = [];
    const candidate = {
        ref: proposal.ref,
        reason: working.retirement?.reason ?? "duplicate",
        proposalId: proposal.id,
        ...(working.retirement?.successorRef ? { successorRefs: [working.retirement.successorRef] } : {}),
    };
    if (!alreadyDone.has(path.resolve(intent.assetPath)) && fs.existsSync(intent.assetPath)) {
        const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, intent.assetPath);
        archiveDirs.push(path.dirname(record.auditPath));
        paths.push(intent.assetPath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
    }
    // 4d (third review round): the twin path is re-derived, not carried on the
    // intent — it is a pure function of assetPath and the ref's type, and the
    // tombstone scan above (`alreadyDone`) already finds one archived earlier,
    // so storing it was redundant persisted state.
    const twinPath = derivedTwinPath(intent.assetPath, ref.type);
    if (twinPath && !alreadyDone.has(path.resolve(twinPath)) && fs.existsSync(twinPath)) {
        const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
        archiveDirs.push(path.dirname(twinRecord.auditPath));
        paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
    }
    if (paths.length > 0)
        commitAcceptedPaths(mutationTarget, acceptCommitMessage(proposal), paths);
    if (archiveDirs.length === 0) {
        // Recorded intent, but neither file is at its original location NOR
        // archived under this proposal's id: something else removed the target
        // between intent and move. Refuse cleanly rather than finalize on
        // nothing.
        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");
    }
    // Phase 3: finalize — always from the recorded intent's own backupContent,
    // never a hash guessed from the archived copy.
    const accepted = persistRetireAcceptance(stashDir, working, {
        targetName: mutationTarget.source.name,
        targetRoot: mutationTarget.source.path,
        assetPath: intent.assetPath,
        contentHash: contentHash(intent.backupContent),
        archiveDirs,
        backupContent: intent.backupContent,
        ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
        ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
    }, ctx);
    return { proposal: accepted, assetPath: intent.assetPath, ref: accepted.ref };
}
/**
 * Restore an accepted proposal's target from the backup taken at promotion.
 * New-asset proposals have no backup; a target edited since acceptance is
 * never clobbered. Reverting twice is a no-op.
 */
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
    return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
}
/**
 * Revert an accepted `retire` proposal (0.9.17-alpha.9): move the archived
 * file(s) — the retired asset, and its `.derived` twin when one was archived
 * alongside it — back to where they lived, then overwrite the primary with
 * the exact pre-retire bytes `backupContent` recorded at accept (S4) —
 * byte-exact, so it also undoes any supersede edge accept wrote without
 * touching one a person had already written, and without appending a
 * trailing newline the original never had. Each archive dir is located from
 * `retiredArchive.dirs` (set at accept time) and its own `cleanup.md`
 * tombstone names the exact paths to restore — no re-scan of every
 * tombstone in the archive.
 *
 * Should-fix 5 (second review round): every archive dir is resolved and
 * validated before any of them are moved, AND that validation tells "not
 * yet moved" apart from "already moved by an earlier, crashed attempt of
 * our own" (original present, archived copy gone) rather than treating the
 * latter as a conflict — so a retry of a crashed revert resumes instead of
 * erroring on its own prior work. The archive dirs (tombstones) are removed
 * only after the "reverted" decision is durably recorded, not interleaved
 * with the moves — a crash between moving a file and recording the
 * decision used to delete that file's tombstone first, leaving an
 * "accepted" proposal a retry could neither finish nor re-validate.
 */
async function unretireProposalWithLease(stashDir, config, proposal, options, ctx) {
    const ref = parseRefInput(proposal.ref);
    if (!stashDirFor(ref.type)) {
        throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
    }
    if (proposal.status !== "accepted" && proposal.status !== "reverted") {
        throw new UsageError(`only accepted proposals can be reverted (proposal ${proposal.id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
    }
    const recorded = requireAcceptedTarget(proposal);
    const boundTarget = resolveRecordedProposalTarget(config, proposal.id, recorded, options.target);
    const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
    if (proposal.status === "reverted") {
        return { proposal, assetPath, ref: proposal.ref };
    }
    const archive = proposal.retiredArchive;
    if (!archive || archive.dirs.length === 0) {
        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.");
    }
    const target = prepareWriteTargetForMutation(boundTarget);
    const pending = [];
    for (const dirRel of archive.dirs) {
        const dirAbs = path.join(target.source.path, dirRel);
        const auditPath = path.join(dirAbs, "cleanup.md");
        let tombstoneData;
        try {
            tombstoneData = parseFrontmatter(fs.readFileSync(auditPath, "utf8")).data;
        }
        catch (error) {
            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");
        }
        const originalRel = typeof tombstoneData.originalPath === "string" ? tombstoneData.originalPath : undefined;
        const archivedRel = typeof tombstoneData.archivedPath === "string" ? tombstoneData.archivedPath : undefined;
        if (!originalRel || !archivedRel) {
            throw new UsageError(`Archive tombstone for proposal ${proposal.id} (${dirRel}) is malformed.`, "INVALID_FLAG_VALUE");
        }
        const originalAbs = path.join(target.source.path, originalRel);
        const archivedAbs = path.join(target.source.path, archivedRel);
        const originalExists = fs.existsSync(originalAbs);
        const archivedExists = fs.existsSync(archivedAbs);
        if (originalExists && archivedExists) {
            throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} already exists (created since retirement); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
        }
        if (!originalExists && !archivedExists) {
            throw new UsageError(`Cannot revert proposal ${proposal.id}: archived copy ${archivedRel} is missing.`, "INVALID_FLAG_VALUE");
        }
        // Must-fix 2 (third review round): "original present, archived copy
        // missing" is not necessarily our own earlier, crashed revert — once a
        // purge can delete an archived copy on its own, a LATER, unrelated file
        // can occupy this same path (a new memory reusing a retired one's name),
        // and the write step below would overwrite it with the retired asset's
        // pre-retire bytes. Only the primary has a recorded pre-retire hash
        // (`retirement.retiredContentHash`) to tell the two apart; resume only
        // when it matches the file actually sitting there, otherwise refuse
        // exactly as the conflict case above does.
        if (originalExists && !archivedExists && path.resolve(originalAbs) === path.resolve(assetPath)) {
            const expectedHash = proposal.retirement?.retiredContentHash;
            let currentHash;
            try {
                currentHash = contentHash(fs.readFileSync(originalAbs, "utf8"), "body");
            }
            catch {
                currentHash = undefined;
            }
            if (!expectedHash || currentHash !== expectedHash) {
                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");
            }
        }
        pending.push({ dirAbs, auditPath, originalAbs, archivedAbs, alreadyDone: originalExists });
    }
    const restoredPaths = [];
    let primaryOriginalAbs;
    for (const p of pending) {
        if (!p.alreadyDone) {
            fs.mkdirSync(path.dirname(p.originalAbs), { recursive: true });
            fs.renameSync(p.archivedAbs, p.originalAbs);
            recordWrittenPath(p.archivedAbs);
            recordWrittenPath(p.originalAbs);
        }
        restoredPaths.push(p.originalAbs, p.archivedAbs, p.auditPath);
        if (path.resolve(p.originalAbs) === path.resolve(assetPath))
            primaryOriginalAbs = p.originalAbs;
    }
    // S4 / nit: overwrite the primary with the EXACT pre-retire bytes recorded
    // at accept (`backupContent`) — no appended trailing newline either, so
    // YAML comments, key order, a pre-existing human `supersededBy` edge, and
    // even the exact absence of a final newline all survive the round trip.
    // The archived copy just moved back may carry a `supersededBy` edge THIS
    // accept wrote (a `supersedes` judgement); restoring the recorded original
    // bytes already removes exactly that edge, so no separate
    // removeSupersededEdge mutation runs here — one that could not tell "the
    // edge accept wrote" from "an edge a person had already written" apart,
    // and would delete either.
    if (primaryOriginalAbs && proposal.backupContent !== undefined) {
        writeProposalAssetFile(primaryOriginalAbs, proposal.backupContent);
    }
    commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: restoredPaths });
    const decidedAt = nowIso(ctx);
    const reverted = withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const current = requireProposal(db, stashDir, proposal.id);
        if (current.status === "reverted")
            return current;
        const next = {
            ...current,
            status: "reverted",
            updatedAt: decidedAt,
            review: { outcome: "rejected", reason: "reverted: archived asset restored", decidedAt },
        };
        upsertProposal(db, next, stashDir);
        recordImproveLedgerDecision(db, {
            proposalId: next.id,
            stashDir,
            ref: next.ref,
            source: next.source,
            outcome: "rejected",
            at: decidedAt,
            detail: "reverted",
        });
        insertEventOnce(db, {
            eventType: "proposal_reverted",
            ts: decidedAt,
            ref: next.ref,
            metadata: { proposalId: next.id, source: next.source, assetPath },
            idempotencyKey: `${next.id}:reverted`,
        });
        return next;
    }));
    // Only now — after the decision is durably recorded — remove the archive
    // dirs (tombstones). See the function doc comment for why this ordering
    // matters.
    for (const p of pending) {
        fs.rmSync(p.dirAbs, { recursive: true, force: true });
    }
    try {
        if (!(await indexWrittenAssets(target.source.path, restoredPaths, { bundleId: target.source.name }))) {
            warn(`[proposals] ${restoredPaths.join(", ")} were restored but not indexed; run \`akm index\`.`);
        }
    }
    catch (error) {
        warn(`[proposals] restored paths were not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
    }
    return { proposal: reverted, assetPath, ref: proposal.ref };
}
async function revertProposalWithLease(stashDir, config, id, options, ctx) {
    const proposal = getProposal(stashDir, id, ctx);
    if (isRetireProposal(proposal))
        return unretireProposalWithLease(stashDir, config, proposal, options, ctx);
    const ref = parseRefInput(proposal.ref);
    if (!stashDirFor(ref.type)) {
        throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
    }
    if (proposal.status !== "accepted" && proposal.status !== "reverted") {
        throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
    }
    const backupContent = proposal.backupContent;
    if (proposal.status === "accepted" && backupContent === undefined) {
        throw new UsageError(`no backup available for this proposal (id: ${id})`, "MISSING_REQUIRED_ARGUMENT", "Backups are only captured when a proposal overwrites an existing asset — new-asset proposals cannot be reverted via this path; delete the asset directly instead.");
    }
    const recorded = requireAcceptedTarget(proposal);
    const boundTarget = resolveRecordedProposalTarget(config, id, recorded, options.target);
    const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
    if (proposal.status === "reverted" || backupContent === undefined) {
        return { proposal, assetPath, ref: proposal.ref };
    }
    const target = prepareWriteTargetForMutation(boundTarget);
    if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
        // Nit (third review round): by-ref resolution skips retire proposals
        // (should-fix 7), so reverting by ref when a SEPARATE retire proposal
        // for the same ref exists (pending or already accepted) lands here with
        // no clue that proposal is the real story — name it when one does.
        const siblingRetire = listProposalsReadOnly(stashDir, { ref: proposal.ref, includeArchive: true }, ctx).find((p) => isRetireProposal(p) && (p.status === "pending" || p.status === "accepted"));
        throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content` +
            (siblingRetire ? ` (a retire proposal for this ref exists: ${siblingRetire.id})` : ""), "INVALID_FLAG_VALUE");
    }
    const decidedAt = nowIso(ctx);
    writeProposalAssetFile(assetPath, backupContent.endsWith("\n") ? backupContent : `${backupContent}\n`);
    commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: [assetPath] });
    const reverted = persistProposalDecision(stashDir, proposal, { operation: "revert", assetPath, decidedAt }, ctx);
    await indexWrittenProposalAsset(target, assetPath);
    return { proposal: reverted, assetPath, ref: proposal.ref };
}
/** The message of the stale-target refusal `check` raises, else `undefined`. */
function staleRefusal(check) {
    try {
        check();
        return undefined;
    }
    catch (error) {
        if (error instanceof UsageError)
            return error.message;
        throw error;
    }
}
/**
 * Why `proposal` cannot be reopened, or `undefined` when it can. Only a
 * rejected proposal comes back, and only one accept would not refuse as stale:
 * a retire proposal needs its successor and both recorded body hashes to still
 * match (B2), any other its target to be what it was minted against
 * (STALE, R20) — so a reopened proposal is never one the next accept refuses.
 */
function reopenRefusal(config, proposal, queueTarget) {
    if (proposal.status !== "rejected") {
        return `it is not rejected (current status: ${proposal.status}); only a rejected proposal can be reopened.`;
    }
    if (proposal.changes.length === 0 || proposal.proposedTarget === undefined) {
        // A pending row must carry both (proposalToRowValues), which a row from
        // before the change envelope existed cannot.
        return "it was recorded before proposals carried their change envelope, so it cannot go back in the queue.";
    }
    const target = resolveProposalWriteTarget(config, proposal, undefined, queueTarget);
    const assetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
    if (!assetPath)
        return "its target cannot be resolved.";
    if (!isRetireProposal(proposal)) {
        return staleRefusal(() => void readFreshProposalTarget(proposal, assetPath, proposalContent(proposal)));
    }
    if (!proposal.retirement)
        return "it has no retirement metadata.";
    if (!fs.existsSync(assetPath)) {
        return "its retired file no longer exists (already retired, or removed by something else).";
    }
    const { retirement } = proposal;
    return staleRefusal(() => assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, fs.readFileSync(assetPath)));
}
/** The assets a retire proposal speaks for — the one it retires and its successor — as bundle-less concept ids, the way the pair pass keys them. */
function retireHeldRefs(proposal) {
    return [proposal.ref, proposal.retirement?.successorRef].flatMap((ref) => {
        const conceptId = ref === undefined ? undefined : proposalRefIdentity(ref)?.conceptId;
        return conceptId === undefined ? [] : [conceptId];
    });
}
const REOPEN_REFUSED_HINT = "Only a rejected proposal whose target is unchanged can be reopened; `akm proposal list --status rejected` lists the candidates.";
const REOPEN_RETIRE_CONFLICT_HINT = "Only one pending retire proposal can involve a document: accept or reject the pending one (or reopen just one of a clashing pair), then reopen the other.";
/**
 * The pair pass never has two pending retire proposals speak for one asset
 * (`pendingRetireRefs`): accepting one would strand the other. Reopening must
 * not break that either — a rejected pair can meanwhile have been re-paired
 * with something else. `held` maps each asset to the pending retire proposal
 * that has it; a proposal that clears this claims its assets, so two in one
 * batch that clash refuse the later.
 */
function retireConflict(proposal, held) {
    if (!isRetireProposal(proposal))
        return undefined;
    const refs = retireHeldRefs(proposal);
    for (const ref of refs) {
        const holder = held.get(ref);
        if (holder !== undefined) {
            return `${ref} is already part of retire proposal ${holder}, which is pending or being reopened with this one; only one pending retire proposal may involve an asset.`;
        }
    }
    for (const ref of refs)
        held.set(ref, proposal.id);
    return undefined;
}
/**
 * Put rejected proposals back in the queue (`akm proposal reopen`, #997): a
 * rejection is otherwise final, and it also suppresses the pair pass from ever
 * re-proposing a retirement (its record keys the pair), so a mistaken one
 * could not be undone. Each proposal returns to `pending` with the rejection —
 * and the gate verdict that came with it — kept in `reviewHistory` (the verdict
 * itself is cleared, unless it is a `deferred` hand-off to a person), its
 * ledger row reset (the pair pass keys off proposal status, so the pair is no
 * longer suppressed and, while pending, cannot be minted twice), and a
 * `proposal_reopened` event recorded, all in one transaction.
 *
 * All-or-nothing: every id is checked (see {@link reopenRefusal} and
 * {@link retireConflict}) before any is reopened, and one refusal leaves the
 * whole batch untouched.
 */
export function reopenProposals(stashDir, config, ids, options = {}, ctx) {
    return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
        const proposals = [...new Set(ids)].map((id) => requireProposal(db, stashDir, id));
        const held = new Map();
        for (const pending of listStateProposals(db, { stashDir, status: "pending" })) {
            if (isRetireProposal(pending))
                for (const ref of retireHeldRefs(pending))
                    held.set(ref, pending.id);
        }
        const refusals = proposals.flatMap((proposal) => {
            const refusal = reopenRefusal(config, proposal, options.queueTarget);
            const conflict = refusal === undefined ? retireConflict(proposal, held) : undefined;
            const reason = refusal ?? conflict;
            return reason === undefined
                ? []
                : [{ proposal: `${proposal.id} (${proposal.ref})`, reason, isConflict: conflict !== undefined }];
        });
        if (refusals.length > 0) {
            // A retire conflict is not about the target changing, so it gets its own hint.
            const hints = [
                ...(refusals.some((refusal) => !refusal.isConflict) ? [REOPEN_REFUSED_HINT] : []),
                ...(refusals.some((refusal) => refusal.isConflict) ? [REOPEN_RETIRE_CONFLICT_HINT] : []),
            ];
            throw new UsageError(proposals.length === 1
                ? `Proposal ${refusals[0]?.proposal} cannot be reopened: ${refusals[0]?.reason}`
                : `Cannot reopen ${refusals.length} of ${proposals.length} proposals; none were reopened:\n${refusals
                    .map((refusal) => `  - ${refusal.proposal}: ${refusal.reason}`)
                    .join("\n")}`, "INVALID_FLAG_VALUE", hints.join(" "));
        }
        const decidedAt = nowIso(ctx);
        return proposals.map((existing) => {
            const reopened = {
                ...existing,
                status: "pending",
                updatedAt: decidedAt,
                review: undefined,
                // A reopened proposal is adjudicated afresh: a `staged` verdict would
                // let the drain accept it unseen, and another gate's `auto-rejected`
                // would have the drain skip it. A `deferred` one is the quality
                // gate's hand-off to a person, which the drain must keep honouring
                // (drainProposals leaves it alone), so it stays.
                gateDecision: existing.gateDecision?.outcome === "deferred" ? existing.gateDecision : undefined,
                reviewHistory: [
                    ...(existing.reviewHistory ?? []),
                    {
                        ...(existing.review !== undefined ? { review: existing.review } : {}),
                        ...(existing.gateDecision !== undefined ? { gateDecision: existing.gateDecision } : {}),
                        reopenedAt: decidedAt,
                        ...(options.reason !== undefined ? { reopenReason: options.reason } : {}),
                    },
                ],
            };
            upsertProposal(db, reopened, stashDir);
            if (isRetireProposal(existing)) {
                // A retire mint writes no ledger row, so the one its rejection wrote goes.
                forgetImproveLedgerDecision(db, stashDir, existing.id);
            }
            else {
                reopenImproveLedgerDecision(db, {
                    proposalId: existing.id,
                    stashDir,
                    source: existing.source,
                    at: decidedAt,
                    detail: options.reason !== undefined ? `reopened: ${options.reason}` : "reopened",
                });
            }
            insertEventOnce(db, {
                eventType: "proposal_reopened",
                ts: decidedAt,
                ref: reopened.ref,
                metadata: {
                    proposalId: reopened.id,
                    source: reopened.source,
                    ...(reopened.sourceRun !== undefined ? { sourceRun: reopened.sourceRun } : {}),
                    ...(options.reason !== undefined ? { reason: options.reason } : {}),
                },
                idempotencyKey: `${reopened.id}:reopened:${decidedAt}`,
            });
            return reopened;
        });
    }));
}
/** The proposal against the asset its accept would overwrite (same target resolution as accept). */
export function diffProposal(stashDir, config, id, options = {}, ctx) {
    const proposal = getProposal(stashDir, id, ctx);
    const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
    const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
    const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
    if (isRetireProposal(proposal)) {
        // A retire proposal's primary change deletes its target rather than
        // writing content (proposalContent() has nothing to read for a delete):
        // accept archives the file, it never replaces it with a blank one, so the
        // diff shows the file leaving — not a "proposed" side (#997).
        return {
            existing,
            proposed: "",
            unified: formatRetireDiff(proposal.ref, existing, proposal.retirement?.successorRef),
            isNew: false,
            ...(targetPath ? { targetPath } : {}),
        };
    }
    const proposed = proposalContent(proposal);
    return {
        existing,
        proposed,
        unified: existing === null
            ? formatNewAssetDiff(proposal.ref, proposed)
            : formatUnifiedDiff(existing, proposed, proposal.ref),
        isNew: existing === null,
        ...(targetPath ? { targetPath } : {}),
    };
}
function resolveAssetFilePathSafe(source, ref) {
    const typeDir = stashDirFor(ref.type);
    if (!typeDir)
        return undefined;
    try {
        return assetPathForName(ref.type, path.join(source.path, typeDir), ref.name);
    }
    catch {
        return undefined;
    }
}