akm-cli 0.9.0-rc.2 → 0.9.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +34 -8
  2. package/dist/assets/improve-strategies/catchup.json +3 -1
  3. package/dist/assets/improve-strategies/consolidate.json +3 -1
  4. package/dist/assets/improve-strategies/frequent.json +3 -1
  5. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  6. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  8. package/dist/assets/improve-strategies/quick.json +3 -1
  9. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  11. package/dist/assets/improve-strategies/synthesize.json +2 -0
  12. package/dist/assets/tasks/core/backup.yml +2 -2
  13. package/dist/cli/config-migrate.js +554 -26
  14. package/dist/cli.js +3 -1
  15. package/dist/commands/backup-cli.js +6 -4
  16. package/dist/commands/config-cli.js +10 -3
  17. package/dist/commands/feedback-cli.js +24 -7
  18. package/dist/commands/health/checks.js +94 -15
  19. package/dist/commands/health/surfaces.js +2 -1
  20. package/dist/commands/improve/anti-collapse.js +3 -3
  21. package/dist/commands/improve/collapse-detector.js +5 -5
  22. package/dist/commands/improve/consolidate.js +123 -66
  23. package/dist/commands/improve/distill/promote-memory.js +8 -6
  24. package/dist/commands/improve/distill/quality-gate.js +1 -1
  25. package/dist/commands/improve/distill-guards.js +1 -1
  26. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  27. package/dist/commands/improve/distill.js +52 -36
  28. package/dist/commands/improve/eligibility.js +9 -8
  29. package/dist/commands/improve/extract-prompt.js +2 -2
  30. package/dist/commands/improve/extract.js +43 -11
  31. package/dist/commands/improve/improve-auto-accept.js +14 -28
  32. package/dist/commands/improve/improve-cli.js +4 -2
  33. package/dist/commands/improve/improve-strategies.js +2 -1
  34. package/dist/commands/improve/improve.js +49 -8
  35. package/dist/commands/improve/loop-stages.js +11 -6
  36. package/dist/commands/improve/preparation.js +51 -17
  37. package/dist/commands/improve/procedural.js +10 -6
  38. package/dist/commands/improve/recombine.js +31 -7
  39. package/dist/commands/improve/reflect.js +24 -14
  40. package/dist/commands/improve/salience.js +5 -1
  41. package/dist/commands/improve/source-identity.js +43 -0
  42. package/dist/commands/migrate-cli.js +36 -0
  43. package/dist/commands/mv-cli.js +647 -258
  44. package/dist/commands/proposal/drain.js +16 -4
  45. package/dist/commands/proposal/proposal-cli.js +3 -2
  46. package/dist/commands/proposal/proposal.js +16 -55
  47. package/dist/commands/proposal/repository.js +664 -58
  48. package/dist/commands/read/curate.js +4 -2
  49. package/dist/commands/read/knowledge.js +4 -1
  50. package/dist/commands/read/search.js +6 -2
  51. package/dist/commands/read/show.js +8 -7
  52. package/dist/commands/sources/self-update.js +156 -112
  53. package/dist/commands/sources/sources-cli.js +7 -2
  54. package/dist/core/common.js +36 -3
  55. package/dist/core/config/config-io.js +2 -2
  56. package/dist/core/config/config-schema.js +15 -3
  57. package/dist/core/config/config.js +14 -13
  58. package/dist/core/file-lock.js +2 -2
  59. package/dist/core/migration-backup.js +816 -171
  60. package/dist/core/migration-operation.js +44 -0
  61. package/dist/core/state/migrations.js +4 -7
  62. package/dist/core/state-db.js +22 -7
  63. package/dist/core/write-source.js +86 -18
  64. package/dist/indexer/db/db.js +109 -53
  65. package/dist/indexer/index-writer-lock.js +38 -37
  66. package/dist/indexer/index-written-assets.js +73 -56
  67. package/dist/indexer/indexer.js +5 -1
  68. package/dist/indexer/search/search-source.js +2 -2
  69. package/dist/indexer/usage/usage-events.js +8 -2
  70. package/dist/integrations/agent/engine-resolution.js +14 -7
  71. package/dist/scripts/migrate-storage.js +867 -990
  72. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
  73. package/dist/setup/detected-engines.js +7 -13
  74. package/dist/setup/engine-config.js +14 -4
  75. package/dist/setup/setup.js +1 -1
  76. package/dist/setup/steps/connection.js +1 -1
  77. package/dist/setup/steps/tasks.js +1 -1
  78. package/dist/sources/providers/git-stash.js +58 -21
  79. package/dist/sources/providers/git.js +1 -1
  80. package/dist/storage/engines/sqlite-migrations.js +138 -3
  81. package/dist/storage/repositories/events-repository.js +24 -0
  82. package/dist/storage/repositories/proposals-repository.js +14 -0
  83. package/dist/tasks/embedded.js +2 -0
  84. package/dist/workflows/db.js +24 -13
  85. package/dist/workflows/validator.js +24 -2
  86. package/docs/migration/release-notes/0.9.0.md +24 -7
  87. package/docs/migration/v0.8-to-v0.9.md +124 -19
  88. package/package.json +1 -1
@@ -45,12 +45,18 @@ import fs from "node:fs";
45
45
  import path from "node:path";
46
46
  import { makeAssetRef, parseAssetRef } from "../../core/asset/asset-ref.js";
47
47
  import { resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
48
+ import { isWithin } from "../../core/common.js";
48
49
  import { NotFoundError, UsageError } from "../../core/errors.js";
49
50
  import { appendEvent } from "../../core/events.js";
51
+ import { getDataDir } from "../../core/paths.js";
50
52
  import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
51
53
  import { warn } from "../../core/warn.js";
52
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
54
+ import { commitWriteTargetBoundary, resolveWritableTargets, resolveWriteTarget, } from "../../core/write-source.js";
55
+ import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
56
+ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
57
+ import { insertEventOnce } from "../../storage/repositories/events-repository.js";
53
58
  import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
59
+ import { recoverInterruptedMoveTransactions } from "../mv-cli.js";
54
60
  import { importLegacyProposalFiles } from "./legacy-import.js";
55
61
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
56
62
  // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
@@ -604,6 +610,492 @@ export function expireStaleProposals(stashDir, config, ctx) {
604
610
  expiredProposals,
605
611
  };
606
612
  }
613
+ let proposalMutationHookForTests;
614
+ /** TEST-ONLY crash-window hook used by subprocess recovery tests. */
615
+ export function _setProposalMutationHookForTests(hook) {
616
+ proposalMutationHookForTests = hook;
617
+ }
618
+ function proposalHash(content) {
619
+ return createHash("sha256").update(content).digest("hex");
620
+ }
621
+ function proposalTransactionRoot(stashDir, targetRoot) {
622
+ const namespace = proposalHash(`${path.resolve(stashDir)}\0${path.resolve(targetRoot)}`).slice(0, 24);
623
+ return path.join(getDataDir(), "proposal-transactions", namespace);
624
+ }
625
+ function proposalFileHash(filePath) {
626
+ return proposalHash(fs.readFileSync(filePath));
627
+ }
628
+ function sameProposalFile(left, right) {
629
+ try {
630
+ const leftStat = fs.statSync(left);
631
+ const rightStat = fs.statSync(right);
632
+ return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
633
+ }
634
+ catch {
635
+ return false;
636
+ }
637
+ }
638
+ function fsyncProposalFile(filePath) {
639
+ const fd = fs.openSync(filePath, "r");
640
+ try {
641
+ fs.fsyncSync(fd);
642
+ }
643
+ finally {
644
+ fs.closeSync(fd);
645
+ }
646
+ }
647
+ function fsyncProposalDir(dirPath) {
648
+ try {
649
+ fsyncProposalFile(dirPath);
650
+ }
651
+ catch {
652
+ // Directory fsync is unavailable on some platforms.
653
+ }
654
+ }
655
+ function writeProposalJournal(transaction, phase) {
656
+ const next = { ...transaction.journal, phase };
657
+ const tempPath = `${transaction.journalPath}.tmp`;
658
+ fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
659
+ fsyncProposalFile(tempPath);
660
+ fs.renameSync(tempPath, transaction.journalPath);
661
+ fsyncProposalDir(transaction.transactionDir);
662
+ transaction.journal.phase = phase;
663
+ }
664
+ function cleanupProposalTransaction(transactionDir) {
665
+ try {
666
+ fs.rmSync(transactionDir, { recursive: true, force: true });
667
+ try {
668
+ fs.rmdirSync(path.dirname(transactionDir));
669
+ }
670
+ catch {
671
+ // Other proposal transactions may still exist.
672
+ }
673
+ }
674
+ catch (error) {
675
+ warn(`[proposals] transaction committed but cleanup failed at ${transactionDir}: ${error instanceof Error ? error.message : String(error)}`);
676
+ }
677
+ }
678
+ function cleanupProposalPublication(journal) {
679
+ for (const filePath of [journal.publishPath, journal.displacedPath]) {
680
+ try {
681
+ fs.rmSync(filePath, { force: true });
682
+ }
683
+ catch (error) {
684
+ warn(`[proposals] transaction publication cleanup failed at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
685
+ }
686
+ }
687
+ fsyncProposalDir(path.dirname(journal.assetPath));
688
+ }
689
+ function rollbackPreparedProposalTransaction(transaction) {
690
+ const { journal } = transaction;
691
+ const currentHash = fs.existsSync(journal.assetPath) ? proposalFileHash(journal.assetPath) : null;
692
+ if (!fs.existsSync(journal.displacedPath)) {
693
+ if (journal.originalHash === null) {
694
+ if (currentHash === journal.publishedHash && sameProposalFile(journal.assetPath, journal.publishPath)) {
695
+ fs.unlinkSync(journal.assetPath);
696
+ }
697
+ else if (currentHash !== null) {
698
+ throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
699
+ }
700
+ }
701
+ else if (currentHash !== journal.originalHash) {
702
+ throw new Error(`Cannot roll back proposal transaction: ${journal.assetPath} diverged.`);
703
+ }
704
+ cleanupProposalPublication(journal);
705
+ return;
706
+ }
707
+ if (currentHash === journal.publishedHash)
708
+ fs.unlinkSync(journal.assetPath);
709
+ else if (currentHash !== null && currentHash !== journal.originalHash) {
710
+ throw new Error(`Cannot roll back proposal transaction: ${journal.assetPath} diverged.`);
711
+ }
712
+ if (fs.existsSync(journal.displacedPath)) {
713
+ if (fs.existsSync(journal.assetPath)) {
714
+ throw new Error(`Cannot restore proposal backup: ${journal.assetPath} is occupied.`);
715
+ }
716
+ fs.linkSync(journal.displacedPath, journal.assetPath);
717
+ }
718
+ cleanupProposalPublication(journal);
719
+ }
720
+ function validatePublishedProposal(journal) {
721
+ if (!fs.existsSync(journal.assetPath) || proposalFileHash(journal.assetPath) !== journal.publishedHash) {
722
+ throw new Error(`Cannot recover proposal ${journal.proposalId}: published asset diverged.`);
723
+ }
724
+ }
725
+ function persistProposalTransactionState(transaction, proposal, ctx) {
726
+ const { journal } = transaction;
727
+ const backupContent = journal.backupPath ? fs.readFileSync(journal.backupPath, "utf8") : undefined;
728
+ const publishedContent = fs.readFileSync(journal.contentPath, "utf8");
729
+ return withProposalsDb(journal.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
730
+ const current = requireProposal(db, journal.stashDir, journal.proposalId);
731
+ if (journal.operation === "accept") {
732
+ if (current.status === "accepted") {
733
+ if (current.acceptedContentHash !== journal.publishedHash) {
734
+ throw new Error(`Accepted proposal ${journal.proposalId} does not match its recovery journal.`);
735
+ }
736
+ return current;
737
+ }
738
+ if (current.status !== "pending") {
739
+ throw new Error(`Proposal ${journal.proposalId} changed status during acceptance (${current.status}).`);
740
+ }
741
+ const accepted = {
742
+ ...proposal,
743
+ payload: { ...proposal.payload, content: publishedContent },
744
+ status: "accepted",
745
+ updatedAt: journal.decidedAt,
746
+ review: { outcome: "accepted", decidedAt: journal.decidedAt },
747
+ acceptedContentHash: journal.publishedHash,
748
+ acceptedTarget: {
749
+ source: journal.targetSource,
750
+ root: journal.targetRoot,
751
+ path: journal.assetPath,
752
+ contentHash: journal.publishedHash,
753
+ },
754
+ ...(backupContent !== undefined ? { backupContent } : {}),
755
+ };
756
+ upsertProposal(db, accepted, journal.stashDir);
757
+ return accepted;
758
+ }
759
+ if (current.status === "reverted")
760
+ return current;
761
+ if (current.status !== "accepted") {
762
+ throw new Error(`Proposal ${journal.proposalId} changed status during reversion (${current.status}).`);
763
+ }
764
+ const reverted = {
765
+ ...current,
766
+ status: "reverted",
767
+ updatedAt: journal.decidedAt,
768
+ review: {
769
+ outcome: "rejected",
770
+ reason: "reverted: prior content restored from backup",
771
+ decidedAt: journal.decidedAt,
772
+ },
773
+ };
774
+ upsertProposal(db, reverted, journal.stashDir);
775
+ return reverted;
776
+ }));
777
+ }
778
+ function persistProposalEvent(transaction, proposal, ctx) {
779
+ const { journal } = transaction;
780
+ withProposalsDb(journal.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
781
+ const metadata = {
782
+ proposalId: proposal.id,
783
+ source: proposal.source,
784
+ ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
785
+ assetPath: journal.assetPath,
786
+ ...(proposal.eligibilitySource !== undefined ? { eligibilitySource: proposal.eligibilitySource } : {}),
787
+ ...(journal.eventMetadata ?? {}),
788
+ proposalTransactionId: journal.transactionId,
789
+ };
790
+ insertEventOnce(db, {
791
+ eventType: journal.operation === "accept" ? "promoted" : "proposal_reverted",
792
+ ts: journal.decidedAt,
793
+ ref: journal.ref,
794
+ metadata,
795
+ idempotencyKey: journal.transactionId,
796
+ });
797
+ }));
798
+ }
799
+ async function finalizeProposalTransaction(transaction, target, proposal, ctx) {
800
+ const { journal } = transaction;
801
+ validatePublishedProposal(journal);
802
+ cleanupProposalPublication(journal);
803
+ if (journal.phase === "asset-published") {
804
+ const commitRoot = target.source.repoPath ?? target.source.path;
805
+ const commitPath = path.relative(commitRoot, journal.assetPath).replaceAll(path.sep, "/");
806
+ commitWriteTargetBoundary(target, `${journal.operation === "accept" ? "Update" : "Revert"} ${journal.ref}`, {
807
+ paths: [commitPath],
808
+ });
809
+ persistProposalTransactionState(transaction, proposal, ctx);
810
+ writeProposalJournal(transaction, "proposal-persisted");
811
+ }
812
+ let accepted = getProposal(journal.stashDir, journal.proposalId, ctx);
813
+ if (journal.phase === "proposal-persisted") {
814
+ if (!(await indexWrittenAssets(journal.targetRoot, [journal.assetPath]))) {
815
+ throw new Error(`Proposal ${journal.proposalId} index finalization failed.`);
816
+ }
817
+ writeProposalJournal(transaction, "index-finalized");
818
+ }
819
+ if (journal.phase === "index-finalized") {
820
+ accepted = getProposal(journal.stashDir, journal.proposalId, ctx);
821
+ persistProposalEvent(transaction, accepted, ctx);
822
+ proposalMutationHookForTests?.("event-persisted");
823
+ writeProposalJournal(transaction, "event-finalized");
824
+ }
825
+ if (journal.phase === "event-finalized")
826
+ writeProposalJournal(transaction, "committed");
827
+ return accepted;
828
+ }
829
+ async function recoverProposalTransactions(target, stashDir, ctx) {
830
+ const completed = new Map();
831
+ const root = proposalTransactionRoot(stashDir, target.source.path);
832
+ if (!fs.existsSync(root))
833
+ return completed;
834
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
835
+ if (!entry.isDirectory())
836
+ continue;
837
+ const transactionDir = path.join(root, entry.name);
838
+ const journalPath = path.join(transactionDir, "journal.json");
839
+ if (!fs.existsSync(journalPath)) {
840
+ cleanupProposalTransaction(transactionDir);
841
+ continue;
842
+ }
843
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
844
+ if (journal.version !== 1 ||
845
+ !["accept", "revert"].includes(journal.operation) ||
846
+ path.resolve(journal.targetRoot) !== path.resolve(target.source.path) ||
847
+ path.resolve(journal.stashDir) !== path.resolve(stashDir) ||
848
+ !isWithin(journal.assetPath, target.source.path) ||
849
+ ![journal.contentPath, journal.backupPath]
850
+ .filter((candidate) => candidate !== null)
851
+ .every((candidate) => isWithin(candidate, transactionDir)) ||
852
+ ![journal.publishPath, journal.displacedPath].every((candidate) => isWithin(candidate, journal.targetRoot) && path.dirname(candidate) === path.dirname(journal.assetPath))) {
853
+ throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
854
+ }
855
+ const transaction = { journal, journalPath, transactionDir };
856
+ if (journal.phase === "prepared")
857
+ rollbackPreparedProposalTransaction(transaction);
858
+ else if (journal.phase !== "committed") {
859
+ const proposal = getProposal(stashDir, journal.proposalId, ctx);
860
+ completed.set(journal.proposalId, await finalizeProposalTransaction(transaction, target, proposal, ctx));
861
+ }
862
+ else {
863
+ completed.set(journal.proposalId, getProposal(stashDir, journal.proposalId, ctx));
864
+ }
865
+ cleanupProposalPublication(journal);
866
+ cleanupProposalTransaction(transactionDir);
867
+ }
868
+ return completed;
869
+ }
870
+ export async function recoverProposalTransactionsForStash(stashDir, config, ctx, proposalId) {
871
+ const completed = new Map();
872
+ const root = path.join(getDataDir(), "proposal-transactions");
873
+ if (!fs.existsSync(root))
874
+ return completed;
875
+ const matches = [];
876
+ for (const namespace of fs.readdirSync(root, { withFileTypes: true })) {
877
+ if (!namespace.isDirectory())
878
+ continue;
879
+ const namespaceDir = path.join(root, namespace.name);
880
+ for (const entry of fs.readdirSync(namespaceDir, { withFileTypes: true })) {
881
+ if (!entry.isDirectory())
882
+ continue;
883
+ const journalPath = path.join(namespaceDir, entry.name, "journal.json");
884
+ if (!fs.existsSync(journalPath))
885
+ continue;
886
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
887
+ if (path.resolve(journal.stashDir) !== path.resolve(stashDir))
888
+ continue;
889
+ if (proposalId !== undefined && journal.proposalId !== proposalId)
890
+ continue;
891
+ matches.push(journal);
892
+ }
893
+ }
894
+ const irreversible = matches.filter((journal) => journal.phase !== "prepared" && journal.phase !== "committed");
895
+ if (proposalId !== undefined && irreversible.length > 1) {
896
+ throw new Error(`Conflicting durable proposal transactions exist for ${proposalId}; refusing recovery.`);
897
+ }
898
+ const recoveredRoots = new Set();
899
+ for (const journal of matches) {
900
+ let target;
901
+ try {
902
+ target = resolveWriteTarget(config, journal.targetSource);
903
+ }
904
+ catch {
905
+ target = resolveWriteTarget(config);
906
+ }
907
+ if (path.resolve(target.source.path) !== path.resolve(journal.targetRoot)) {
908
+ throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
909
+ }
910
+ const key = path.resolve(target.source.path);
911
+ if (recoveredRoots.has(key))
912
+ continue;
913
+ await recoverInterruptedMoveTransactions(target.source.path);
914
+ const recovered = await recoverProposalTransactions(target, stashDir, ctx);
915
+ for (const [id, proposal] of recovered)
916
+ completed.set(id, proposal);
917
+ recoveredRoots.add(key);
918
+ }
919
+ return completed;
920
+ }
921
+ function rejectTransactionRoot(stashDir) {
922
+ return path.join(getDataDir(), "proposal-rejections", proposalHash(path.resolve(stashDir)).slice(0, 24));
923
+ }
924
+ function writeRejectJournal(journalPath, journal) {
925
+ const tempPath = `${journalPath}.tmp`;
926
+ fs.writeFileSync(tempPath, `${JSON.stringify(journal, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
927
+ fsyncProposalFile(tempPath);
928
+ fs.renameSync(tempPath, journalPath);
929
+ fsyncProposalDir(path.dirname(journalPath));
930
+ }
931
+ function setRejectPhase(journalPath, journal, phase) {
932
+ journal.phase = phase;
933
+ writeRejectJournal(journalPath, journal);
934
+ }
935
+ function finalizeRejectTransaction(journalPath, journal, ctx) {
936
+ let proposal = getProposal(journal.stashDir, journal.proposalId, ctx);
937
+ if (journal.phase === "prepared") {
938
+ if (proposal.status === "pending") {
939
+ proposal = archiveProposal(journal.stashDir, journal.proposalId, "rejected", journal.reason, {
940
+ ...ctx,
941
+ now: () => Date.parse(journal.decidedAt),
942
+ });
943
+ }
944
+ else if (proposal.status !== "rejected") {
945
+ throw new Error(`Proposal ${journal.proposalId} changed status during rejection (${proposal.status}).`);
946
+ }
947
+ setRejectPhase(journalPath, journal, "state-persisted");
948
+ proposalMutationHookForTests?.("reject-state-persisted");
949
+ }
950
+ if (journal.phase === "state-persisted") {
951
+ proposal = getProposal(journal.stashDir, journal.proposalId, ctx);
952
+ withProposalsDb(journal.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
953
+ insertEventOnce(db, {
954
+ eventType: "rejected",
955
+ ts: journal.decidedAt,
956
+ ref: proposal.ref,
957
+ metadata: {
958
+ proposalId: proposal.id,
959
+ source: proposal.source,
960
+ ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
961
+ ...(journal.reason !== undefined ? { reason: journal.reason } : {}),
962
+ proposalTransactionId: journal.transactionId,
963
+ },
964
+ idempotencyKey: journal.transactionId,
965
+ });
966
+ }));
967
+ proposalMutationHookForTests?.("reject-event-persisted");
968
+ setRejectPhase(journalPath, journal, "event-finalized");
969
+ }
970
+ if (journal.phase === "event-finalized")
971
+ setRejectPhase(journalPath, journal, "committed");
972
+ return proposal;
973
+ }
974
+ function recoverRejectTransaction(stashDir, proposalId, ctx) {
975
+ const root = rejectTransactionRoot(stashDir);
976
+ if (!fs.existsSync(root))
977
+ return undefined;
978
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
979
+ if (!entry.isDirectory())
980
+ continue;
981
+ const transactionDir = path.join(root, entry.name);
982
+ const journalPath = path.join(transactionDir, "journal.json");
983
+ if (!fs.existsSync(journalPath))
984
+ continue;
985
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
986
+ if (journal.proposalId !== proposalId)
987
+ continue;
988
+ if (journal.version !== 1 || path.resolve(journal.stashDir) !== path.resolve(stashDir)) {
989
+ throw new Error(`Refusing unsafe proposal rejection journal at ${journalPath}.`);
990
+ }
991
+ const proposal = finalizeRejectTransaction(journalPath, journal, ctx);
992
+ cleanupProposalTransaction(transactionDir);
993
+ return proposal;
994
+ }
995
+ return undefined;
996
+ }
997
+ export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
998
+ const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
999
+ if (recovered)
1000
+ return recovered;
1001
+ const proposal = getProposal(stashDir, proposalId, ctx);
1002
+ if (proposal.status !== "pending") {
1003
+ throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
1004
+ }
1005
+ const transactionId = randomUUID();
1006
+ const transactionDir = path.join(rejectTransactionRoot(stashDir), transactionId);
1007
+ fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
1008
+ const journalPath = path.join(transactionDir, "journal.json");
1009
+ const journal = {
1010
+ version: 1,
1011
+ transactionId,
1012
+ phase: "prepared",
1013
+ stashDir,
1014
+ proposalId,
1015
+ ...(reason !== undefined ? { reason } : {}),
1016
+ decidedAt: nowIso(ctx),
1017
+ };
1018
+ writeRejectJournal(journalPath, journal);
1019
+ const rejected = finalizeRejectTransaction(journalPath, journal, ctx);
1020
+ cleanupProposalTransaction(transactionDir);
1021
+ return rejected;
1022
+ }
1023
+ function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
1024
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1025
+ if (!assetPath)
1026
+ throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
1027
+ fs.mkdirSync(path.dirname(assetPath), { recursive: true });
1028
+ const transactionId = randomUUID();
1029
+ const transactionDir = path.join(proposalTransactionRoot(stashDir, target.source.path), transactionId);
1030
+ fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
1031
+ const contentPath = path.join(transactionDir, "published-content");
1032
+ const publishPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.publish`);
1033
+ const displacedPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.displaced`);
1034
+ const backupPath = path.join(transactionDir, "backup-content");
1035
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
1036
+ fs.writeFileSync(contentPath, normalized, { encoding: "utf8", mode: 0o600 });
1037
+ fsyncProposalFile(contentPath);
1038
+ let persistedBackupPath = null;
1039
+ if (options.backup) {
1040
+ fs.writeFileSync(backupPath, options.backup, { mode: 0o600 });
1041
+ fsyncProposalFile(backupPath);
1042
+ persistedBackupPath = backupPath;
1043
+ }
1044
+ const journal = {
1045
+ version: 1,
1046
+ operation: options.operation,
1047
+ phase: "prepared",
1048
+ transactionId,
1049
+ proposalId: proposal.id,
1050
+ stashDir,
1051
+ targetRoot: target.source.path,
1052
+ targetSource: target.source.name,
1053
+ assetPath,
1054
+ ref: proposal.ref,
1055
+ contentPath,
1056
+ publishPath,
1057
+ displacedPath,
1058
+ backupPath: persistedBackupPath,
1059
+ originalHash: options.originalHash,
1060
+ publishedHash: proposalHash(normalized),
1061
+ decidedAt: nowIso(ctx),
1062
+ ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1063
+ };
1064
+ const transaction = { journal, journalPath: path.join(transactionDir, "journal.json"), transactionDir };
1065
+ writeProposalJournal(transaction, "prepared");
1066
+ try {
1067
+ const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
1068
+ fs.writeFileSync(publishPath, normalized, { encoding: "utf8", flag: "wx", mode });
1069
+ fsyncProposalFile(publishPath);
1070
+ fsyncProposalDir(path.dirname(assetPath));
1071
+ }
1072
+ catch (error) {
1073
+ rollbackPreparedProposalTransaction(transaction);
1074
+ cleanupProposalTransaction(transactionDir);
1075
+ throw error;
1076
+ }
1077
+ return transaction;
1078
+ }
1079
+ function publishProposalAsset(transaction) {
1080
+ const { journal } = transaction;
1081
+ try {
1082
+ if (journal.originalHash !== null) {
1083
+ fs.renameSync(journal.assetPath, journal.displacedPath);
1084
+ if (proposalFileHash(journal.displacedPath) !== journal.originalHash) {
1085
+ fs.renameSync(journal.displacedPath, journal.assetPath);
1086
+ throw new Error(`Proposal target changed while its backup was being acquired.`);
1087
+ }
1088
+ }
1089
+ fs.linkSync(journal.publishPath, journal.assetPath);
1090
+ fsyncProposalDir(path.dirname(journal.assetPath));
1091
+ writeProposalJournal(transaction, "asset-published");
1092
+ }
1093
+ catch (error) {
1094
+ rollbackPreparedProposalTransaction(transaction);
1095
+ cleanupProposalTransaction(transaction.transactionDir);
1096
+ throw error;
1097
+ }
1098
+ }
607
1099
  /**
608
1100
  * Validate a proposal, then promote it through the canonical
609
1101
  * {@link writeAssetToSource} dispatch (the single place that branches on
@@ -617,10 +1109,10 @@ export function expireStaleProposals(stashDir, config, ctx) {
617
1109
  * Genuinely-new assets carry no backup.
618
1110
  */
619
1111
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
620
- const proposal = getProposal(stashDir, id, ctx);
621
- if (proposal.status !== "pending") {
622
- throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
623
- }
1112
+ return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
1113
+ }
1114
+ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1115
+ let proposal = getProposal(stashDir, id, ctx);
624
1116
  // Attempt bounded auto-repair of mechanically-fixable structural defects
625
1117
  // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
626
1118
  // BEFORE running validation. If the repair produces valid content, we
@@ -649,37 +1141,49 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
649
1141
  if (!TYPE_DIRS[ref.type]) {
650
1142
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
651
1143
  }
1144
+ await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1145
+ proposal = getProposal(stashDir, id, ctx);
652
1146
  const target = resolveWriteTarget(config, options.target);
653
- // Phase 6C: capture the prior content (if any) BEFORE writing the new
654
- // asset. We use the resolved write target to compute the exact path the
655
- // asset would land at — same resolver `writeAssetToSource` uses — so the
656
- // backup always mirrors what would be overwritten.
657
- let backupContent;
658
- try {
659
- const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
660
- if (targetFilePath && fs.existsSync(targetFilePath)) {
661
- backupContent = fs.readFileSync(targetFilePath, "utf8");
1147
+ await recoverInterruptedMoveTransactions(target.source.path);
1148
+ if (proposal.status === "accepted" && proposal.acceptedContentHash) {
1149
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1150
+ if (proposal.acceptedTarget &&
1151
+ (proposal.acceptedTarget.source !== target.source.name ||
1152
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1153
+ !assetPath ||
1154
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath))) {
1155
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1156
+ }
1157
+ if (!assetPath || !fs.existsSync(assetPath) || proposalFileHash(assetPath) !== proposal.acceptedContentHash) {
1158
+ throw new Error(`Accepted proposal ${id} does not match the current asset content.`);
662
1159
  }
1160
+ return { proposal, assetPath, ref: proposal.ref };
663
1161
  }
664
- catch (err) {
665
- // Backup capture is best-effort. A failure here must not block promotion
666
- // (the user explicitly asked to accept); we surface a warning so the
667
- // missing-revert path is visible.
668
- warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
669
- }
670
- const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
671
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
672
- // targets. No-op for filesystem/primary-stash targets.
673
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
674
- const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
675
- // Persist the backup content on the archived proposal record so the revert
676
- // flow can restore the prior asset state.
677
- if (backupContent !== undefined) {
678
- const withBackup = { ...archived, backupContent };
679
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
680
- return { proposal: withBackup, assetPath: written.path, ref: written.ref };
681
- }
682
- return { proposal: archived, assetPath: written.path, ref: written.ref };
1162
+ if (proposal.status !== "pending") {
1163
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1164
+ }
1165
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1166
+ if (!assetPath)
1167
+ throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
1168
+ let backup;
1169
+ if (fs.existsSync(assetPath)) {
1170
+ try {
1171
+ backup = fs.readFileSync(assetPath);
1172
+ }
1173
+ catch (error) {
1174
+ throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
1175
+ }
1176
+ }
1177
+ const transaction = prepareProposalTransaction(stashDir, target, proposalToValidate, ref, repairedContent, {
1178
+ operation: "accept",
1179
+ originalHash: backup ? proposalHash(backup) : null,
1180
+ backup,
1181
+ eventMetadata: options.eventMetadata,
1182
+ }, ctx);
1183
+ publishProposalAsset(transaction);
1184
+ const accepted = await finalizeProposalTransaction(transaction, target, proposalToValidate, ctx);
1185
+ cleanupProposalTransaction(transaction.transactionDir);
1186
+ return { proposal: accepted, assetPath: transaction.journal.assetPath, ref: proposal.ref };
683
1187
  }
684
1188
  /**
685
1189
  * Restore the prior content of an accepted proposal from the backup captured
@@ -702,37 +1206,139 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
702
1206
  * wrapper.
703
1207
  */
704
1208
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
705
- const proposal = getProposal(stashDir, id, ctx);
1209
+ return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
1210
+ }
1211
+ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1212
+ let proposal = getProposal(stashDir, id, ctx);
1213
+ const ref = parseAssetRef(proposal.ref);
1214
+ if (!TYPE_DIRS[ref.type]) {
1215
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1216
+ }
1217
+ await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1218
+ proposal = getProposal(stashDir, id, ctx);
1219
+ if (proposal.status === "reverted") {
1220
+ if (options.target)
1221
+ resolveWriteTarget(config, options.target);
1222
+ const target = proposal.legacyAcceptedTargetDerived
1223
+ ? resolveWritableTargets(config).find((candidate) => candidate.source.name === proposal.acceptedTarget?.source &&
1224
+ path.resolve(candidate.source.path) === path.resolve(proposal.acceptedTarget?.root ?? ""))
1225
+ : resolveWriteTarget(config, options.target);
1226
+ const requestedAssetPath = target ? resolveAssetFilePathSafe(target.source, ref) : undefined;
1227
+ if (!target ||
1228
+ !requestedAssetPath ||
1229
+ (proposal.acceptedTarget &&
1230
+ (proposal.acceptedTarget.source !== target.source.name ||
1231
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1232
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)))) {
1233
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1234
+ }
1235
+ return {
1236
+ proposal,
1237
+ assetPath: requestedAssetPath,
1238
+ ref: proposal.ref,
1239
+ };
1240
+ }
706
1241
  if (proposal.status !== "accepted") {
707
1242
  throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
708
1243
  }
709
- if (proposal.backupContent === undefined) {
1244
+ const backupContent = proposal.backupContent;
1245
+ if (backupContent === undefined) {
710
1246
  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.");
711
1247
  }
712
- const ref = parseAssetRef(proposal.ref);
713
- if (!TYPE_DIRS[ref.type]) {
714
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1248
+ const legacyAccepted = proposal.payload.content.endsWith("\n")
1249
+ ? proposal.payload.content
1250
+ : `${proposal.payload.content}\n`;
1251
+ let acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? proposalHash(legacyAccepted);
1252
+ const writableTargets = resolveWritableTargets(config);
1253
+ let target;
1254
+ let assetPath;
1255
+ if (!proposal.acceptedTarget) {
1256
+ if (options.target)
1257
+ resolveWriteTarget(config, options.target);
1258
+ const candidates = writableTargets.flatMap((candidate) => {
1259
+ const candidatePath = resolveAssetFilePathSafe(candidate.source, ref);
1260
+ return candidatePath ? [{ target: candidate, assetPath: candidatePath }] : [];
1261
+ });
1262
+ const matching = candidates.filter((candidate) => fs.existsSync(candidate.assetPath) && proposalFileHash(candidate.assetPath) === acceptedHash);
1263
+ if (matching.length > 1) {
1264
+ throw new UsageError(`legacy proposal ${id} has ambiguous accepted content in multiple writable targets; refusing revert`, "INVALID_FLAG_VALUE");
1265
+ }
1266
+ const existing = candidates.filter((candidate) => fs.existsSync(candidate.assetPath));
1267
+ if (matching.length === 0 && existing.length > 0) {
1268
+ throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1269
+ }
1270
+ if (matching.length === 0 && candidates.length !== 1) {
1271
+ throw new UsageError(`legacy proposal ${id} has no accepted asset and ${candidates.length} writable targets can own the ref; ownership is ambiguous`, "INVALID_FLAG_VALUE");
1272
+ }
1273
+ const owner = matching[0] ?? candidates[0];
1274
+ if (!owner) {
1275
+ throw new UsageError(`legacy proposal ${id} has no writable target that can own ${proposal.ref}`, "INVALID_FLAG_VALUE");
1276
+ }
1277
+ const acceptedAssetWasAbsent = matching.length === 0;
1278
+ proposal = withProposalsDb(stashDir, ctx, (db) => withImmediateTransaction(db, () => {
1279
+ const current = requireProposal(db, stashDir, id);
1280
+ if (current.status !== "accepted" || current.acceptedTarget) {
1281
+ throw new Error(`Proposal ${id} changed while deriving its legacy revert target.`);
1282
+ }
1283
+ const bound = {
1284
+ ...current,
1285
+ acceptedContentHash: acceptedHash,
1286
+ acceptedTarget: {
1287
+ source: owner.target.source.name,
1288
+ root: owner.target.source.path,
1289
+ path: owner.assetPath,
1290
+ contentHash: acceptedHash,
1291
+ },
1292
+ legacyAcceptedTargetDerived: true,
1293
+ ...(acceptedAssetWasAbsent ? { legacyAcceptedAssetWasAbsent: true } : {}),
1294
+ };
1295
+ upsertProposal(db, bound, stashDir);
1296
+ return bound;
1297
+ }));
1298
+ proposalMutationHookForTests?.("legacy-target-derived");
1299
+ target = owner.target;
1300
+ assetPath = owner.assetPath;
715
1301
  }
716
- const target = resolveWriteTarget(config, options.target);
717
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
718
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
719
- // targets. No-op for filesystem/primary-stash targets.
720
- commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
721
- // Update the proposal record to status: "reverted" and bump updatedAt +
722
- // review so the audit trail reflects the second decision.
723
- const now = nowIso(ctx);
724
- const reverted = {
725
- ...proposal,
726
- status: "reverted",
727
- updatedAt: now,
728
- review: {
729
- outcome: "rejected",
730
- reason: "reverted: prior content restored from backup",
731
- decidedAt: now,
732
- },
733
- };
734
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
735
- return { proposal: reverted, assetPath: written.path, ref: written.ref };
1302
+ else if (proposal.legacyAcceptedTargetDerived) {
1303
+ const acceptedTarget = proposal.acceptedTarget;
1304
+ if (!acceptedTarget)
1305
+ throw new Error(`Legacy proposal ${id} lost its derived accepted target.`);
1306
+ const bound = writableTargets.find((candidate) => {
1307
+ const candidatePath = resolveAssetFilePathSafe(candidate.source, ref);
1308
+ return (candidate.source.name === acceptedTarget.source &&
1309
+ path.resolve(candidate.source.path) === path.resolve(acceptedTarget.root) &&
1310
+ candidatePath !== undefined &&
1311
+ path.resolve(candidatePath) === path.resolve(acceptedTarget.path));
1312
+ });
1313
+ if (!bound) {
1314
+ throw new UsageError(`legacy proposal ${id} is bound to a writable target that is no longer configured`, "INVALID_FLAG_VALUE");
1315
+ }
1316
+ target = bound;
1317
+ assetPath = acceptedTarget.path;
1318
+ }
1319
+ else {
1320
+ target = resolveWriteTarget(config, options.target);
1321
+ const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1322
+ if (proposal.acceptedTarget.source !== target.source.name ||
1323
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1324
+ !requestedAssetPath ||
1325
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1326
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1327
+ }
1328
+ assetPath = requestedAssetPath;
1329
+ }
1330
+ await recoverInterruptedMoveTransactions(target.source.path);
1331
+ acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? acceptedHash;
1332
+ const acceptedAssetExists = fs.existsSync(assetPath);
1333
+ if ((fs.existsSync(assetPath) && proposalFileHash(assetPath) !== acceptedHash) ||
1334
+ (!fs.existsSync(assetPath) && !proposal.legacyAcceptedAssetWasAbsent)) {
1335
+ throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1336
+ }
1337
+ const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedAssetExists ? acceptedHash : null }, ctx);
1338
+ publishProposalAsset(transaction);
1339
+ const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
1340
+ cleanupProposalTransaction(transaction.transactionDir);
1341
+ return { proposal: reverted, assetPath, ref: proposal.ref };
736
1342
  }
737
1343
  /**
738
1344
  * Compute a diff between a proposal payload and the existing on-disk asset.