instar 1.3.1674 → 1.3.1676

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 (142) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +55 -6
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/CodexLifecycleProductionComposition.d.ts +1 -1
  5. package/dist/core/CodexLifecycleProductionComposition.d.ts.map +1 -1
  6. package/dist/core/EvolutionArchive.d.ts +79 -0
  7. package/dist/core/EvolutionArchive.d.ts.map +1 -0
  8. package/dist/core/EvolutionArchive.js +309 -0
  9. package/dist/core/EvolutionArchive.js.map +1 -0
  10. package/dist/core/EvolutionArchiveSync.d.ts +26 -0
  11. package/dist/core/EvolutionArchiveSync.d.ts.map +1 -0
  12. package/dist/core/EvolutionArchiveSync.js +109 -0
  13. package/dist/core/EvolutionArchiveSync.js.map +1 -0
  14. package/dist/core/EvolutionManager.d.ts +37 -6
  15. package/dist/core/EvolutionManager.d.ts.map +1 -1
  16. package/dist/core/EvolutionManager.js +210 -149
  17. package/dist/core/EvolutionManager.js.map +1 -1
  18. package/dist/core/FileClassifier.d.ts.map +1 -1
  19. package/dist/core/FileClassifier.js +3 -0
  20. package/dist/core/FileClassifier.js.map +1 -1
  21. package/dist/core/JobCoverage.d.ts +53 -0
  22. package/dist/core/JobCoverage.d.ts.map +1 -0
  23. package/dist/core/JobCoverage.js +194 -0
  24. package/dist/core/JobCoverage.js.map +1 -0
  25. package/dist/core/JobCoverageMonitor.d.ts +84 -0
  26. package/dist/core/JobCoverageMonitor.d.ts.map +1 -0
  27. package/dist/core/JobCoverageMonitor.js +364 -0
  28. package/dist/core/JobCoverageMonitor.js.map +1 -0
  29. package/dist/core/JobDependency.d.ts +4 -0
  30. package/dist/core/JobDependency.d.ts.map +1 -0
  31. package/dist/core/JobDependency.js +30 -0
  32. package/dist/core/JobDependency.js.map +1 -0
  33. package/dist/core/JobPortableBundle.d.ts +61 -0
  34. package/dist/core/JobPortableBundle.d.ts.map +1 -0
  35. package/dist/core/JobPortableBundle.js +302 -0
  36. package/dist/core/JobPortableBundle.js.map +1 -0
  37. package/dist/core/JobPortableFs.d.ts +18 -0
  38. package/dist/core/JobPortableFs.d.ts.map +1 -0
  39. package/dist/core/JobPortableFs.js +65 -0
  40. package/dist/core/JobPortableFs.js.map +1 -0
  41. package/dist/core/JobPortableIntegrity.d.ts +4 -0
  42. package/dist/core/JobPortableIntegrity.d.ts.map +1 -0
  43. package/dist/core/JobPortableIntegrity.js +86 -0
  44. package/dist/core/JobPortableIntegrity.js.map +1 -0
  45. package/dist/core/JobPortableSync.d.ts +79 -0
  46. package/dist/core/JobPortableSync.d.ts.map +1 -0
  47. package/dist/core/JobPortableSync.js +513 -0
  48. package/dist/core/JobPortableSync.js.map +1 -0
  49. package/dist/core/LeaseCoordinator.d.ts.map +1 -1
  50. package/dist/core/LeaseCoordinator.js +4 -3
  51. package/dist/core/LeaseCoordinator.js.map +1 -1
  52. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  53. package/dist/core/PostUpdateMigrator.js +38 -3
  54. package/dist/core/PostUpdateMigrator.js.map +1 -1
  55. package/dist/core/ReplicatedStoreReconcilerWiring.d.ts +2 -0
  56. package/dist/core/ReplicatedStoreReconcilerWiring.d.ts.map +1 -1
  57. package/dist/core/ReplicatedStoreReconcilerWiring.js +4 -4
  58. package/dist/core/ReplicatedStoreReconcilerWiring.js.map +1 -1
  59. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  60. package/dist/core/WriteDomainRegistry.js +13 -0
  61. package/dist/core/WriteDomainRegistry.js.map +1 -1
  62. package/dist/core/jobMeshAwareness.d.ts +12 -0
  63. package/dist/core/jobMeshAwareness.d.ts.map +1 -0
  64. package/dist/core/jobMeshAwareness.js +37 -0
  65. package/dist/core/jobMeshAwareness.js.map +1 -0
  66. package/dist/core/roleFollowRestart.d.ts +16 -0
  67. package/dist/core/roleFollowRestart.d.ts.map +1 -1
  68. package/dist/core/roleFollowRestart.js +32 -1
  69. package/dist/core/roleFollowRestart.js.map +1 -1
  70. package/dist/core/types.d.ts +24 -4
  71. package/dist/core/types.d.ts.map +1 -1
  72. package/dist/core/types.js.map +1 -1
  73. package/dist/data/standards-guard-index.json +1 -1
  74. package/dist/data/standards-guard-index.meta.json +2 -2
  75. package/dist/data/standards-registry.meta.json +1 -1
  76. package/dist/localVerify/FailedFileDiagnosisPeerService.d.ts.map +1 -1
  77. package/dist/localVerify/FailedFileDiagnosisPeerService.js +3 -1
  78. package/dist/localVerify/FailedFileDiagnosisPeerService.js.map +1 -1
  79. package/dist/messaging/TelegramAdapter.d.ts +4 -0
  80. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  81. package/dist/messaging/TelegramAdapter.js +24 -0
  82. package/dist/messaging/TelegramAdapter.js.map +1 -1
  83. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  84. package/dist/monitoring/guardManifest.js +6 -0
  85. package/dist/monitoring/guardManifest.js.map +1 -1
  86. package/dist/native/InstarWedgeCanary.app/Contents/MacOS/InstarWedgeCanary +0 -0
  87. package/dist/native/instar-desktop-host +0 -0
  88. package/dist/scaffold/templates.d.ts.map +1 -1
  89. package/dist/scaffold/templates.js +11 -0
  90. package/dist/scaffold/templates.js.map +1 -1
  91. package/dist/scheduler/AgentMdJobLoader.d.ts +4 -15
  92. package/dist/scheduler/AgentMdJobLoader.d.ts.map +1 -1
  93. package/dist/scheduler/AgentMdJobLoader.js +69 -1
  94. package/dist/scheduler/AgentMdJobLoader.js.map +1 -1
  95. package/dist/scheduler/JobCatalogReconcile.d.ts +2 -0
  96. package/dist/scheduler/JobCatalogReconcile.d.ts.map +1 -0
  97. package/dist/scheduler/JobCatalogReconcile.js +27 -0
  98. package/dist/scheduler/JobCatalogReconcile.js.map +1 -0
  99. package/dist/scheduler/JobLoader.d.ts.map +1 -1
  100. package/dist/scheduler/JobLoader.js +26 -1
  101. package/dist/scheduler/JobLoader.js.map +1 -1
  102. package/dist/scheduler/JobScheduler.d.ts +14 -9
  103. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  104. package/dist/scheduler/JobScheduler.js +182 -9
  105. package/dist/scheduler/JobScheduler.js.map +1 -1
  106. package/dist/scheduler/PromotionCatchUpStore.d.ts +20 -0
  107. package/dist/scheduler/PromotionCatchUpStore.d.ts.map +1 -0
  108. package/dist/scheduler/PromotionCatchUpStore.js +60 -0
  109. package/dist/scheduler/PromotionCatchUpStore.js.map +1 -0
  110. package/dist/server/AgentServer.d.ts.map +1 -1
  111. package/dist/server/AgentServer.js +1 -0
  112. package/dist/server/AgentServer.js.map +1 -1
  113. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  114. package/dist/server/CapabilityIndex.js +2 -1
  115. package/dist/server/CapabilityIndex.js.map +1 -1
  116. package/dist/server/leaseRenewalDependencies.d.ts +4 -0
  117. package/dist/server/leaseRenewalDependencies.d.ts.map +1 -1
  118. package/dist/server/leaseRenewalDependencies.js +2 -0
  119. package/dist/server/leaseRenewalDependencies.js.map +1 -1
  120. package/dist/server/routes.d.ts +2 -0
  121. package/dist/server/routes.d.ts.map +1 -1
  122. package/dist/server/routes.js +265 -0
  123. package/dist/server/routes.js.map +1 -1
  124. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  125. package/dist/testing/selfActionRegistry.js +92 -0
  126. package/dist/testing/selfActionRegistry.js.map +1 -1
  127. package/package.json +1 -1
  128. package/scripts/job-portable-fs.py +254 -0
  129. package/scripts/job-portable-sync-rollback.mjs +66 -0
  130. package/src/data/builtin-manifest.json +66 -66
  131. package/src/data/feature-ladders.json +2 -0
  132. package/src/data/standards-guard-index.json +1 -1
  133. package/src/data/standards-guard-index.meta.json +2 -2
  134. package/src/data/standards-registry.meta.json +1 -1
  135. package/src/scaffold/templates.ts +11 -0
  136. package/upgrades/1.3.1675.md +48 -0
  137. package/upgrades/1.3.1676.md +18 -0
  138. package/upgrades/side-effects/evolution-retention-cap-saturation.md +172 -0
  139. package/upgrades/side-effects/lease-job-coverage-and-catchup.md +86 -0
  140. package/upgrades/side-effects/lease-job-definition-replication.md +94 -0
  141. package/upgrades/side-effects/takeover-keeps-lease.md +17 -0
  142. package/upgrades/takeover-keeps-lease.eli16.md +22 -0
@@ -17,6 +17,9 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { createHash } from 'node:crypto';
20
+ import { EvolutionArchive, isEvolutionRecordId } from './EvolutionArchive.js';
21
+ import { deriveLearningRecordKey } from './LearningsReplicatedStore.js';
22
+ import { deriveEvolutionActionRecordKey } from './EvolutionActionsReplicatedStore.js';
20
23
  import { SafeFsExecutor } from './SafeFsExecutor.js';
21
24
  import { AutoExpiryLedger } from './AutoExpiryLedger.js';
22
25
  import { TaskFlowError } from '../tasks/task-flow-types.js';
@@ -61,6 +64,8 @@ function ownerKeyForProposal(proposalId) {
61
64
  export class EvolutionManager {
62
65
  stateDir;
63
66
  config;
67
+ archive;
68
+ retentionWarnings = new Map();
64
69
  trustElevationTracker = null;
65
70
  autonomousEvolution = null;
66
71
  autonomyManager = null;
@@ -131,6 +136,7 @@ export class EvolutionManager {
131
136
  constructor(config) {
132
137
  this.config = config;
133
138
  this.stateDir = config.stateDir;
139
+ this.archive = new EvolutionArchive(config.stateDir, config.archiveOrigin);
134
140
  this.actionAutoExpiryLedger = new AutoExpiryLedger({ stateDir: config.stateDir, feature: 'evolution-actions' });
135
141
  if (config.autoExpiry?.enabled === true) {
136
142
  const intervalMs = Math.max(60_000, config.autoExpiry.sweepIntervalMs ?? 21_600_000);
@@ -637,13 +643,24 @@ export class EvolutionManager {
637
643
  const fp = this.filePath(name);
638
644
  if (!fs.existsSync(fp))
639
645
  return defaultValue;
640
- try {
641
- return JSON.parse(fs.readFileSync(fp, 'utf-8'));
642
- }
643
- catch {
644
- console.warn(`[EvolutionManager] Corrupted file: ${fp}`);
645
- return defaultValue;
646
+ const parsed = JSON.parse(fs.readFileSync(fp, 'utf-8'));
647
+ const names = { 'evolution-queue': 'proposals', 'learning-registry': 'learnings',
648
+ 'capability-gaps': 'gaps', 'action-queue': 'actions' };
649
+ const kind = names[name];
650
+ const rows = parsed?.[kind];
651
+ const statuses = {
652
+ proposals: ['proposed', 'approved', 'in_progress', 'implemented', 'rejected', 'deferred'],
653
+ actions: ['pending', 'in_progress', 'completed', 'cancelled'],
654
+ gaps: ['identified', 'addressed', 'wont_fix'],
655
+ };
656
+ if (!Array.isArray(rows) || rows.some((r) => !r || !isEvolutionRecordId(r.id)
657
+ || typeof r.title !== 'string' || (kind === 'learnings' ? typeof r.applied !== 'boolean'
658
+ : !statuses[kind]?.includes(r.status)))) {
659
+ throw new Error(`Invalid evolution store: ${fp}`);
646
660
  }
661
+ if (new Set(rows.map((row) => row.id)).size !== rows.length)
662
+ throw new Error(`Duplicate evolution IDs: ${fp}`);
663
+ return parsed;
647
664
  }
648
665
  writeFile(name, data) {
649
666
  const fp = this.filePath(name);
@@ -665,14 +682,104 @@ export class EvolutionManager {
665
682
  now() {
666
683
  return new Date().toISOString();
667
684
  }
685
+ target(kind) {
686
+ const aliases = { proposals: 'maxProposals', learnings: 'maxLearnings', gaps: 'maxGaps', actions: 'maxActions' };
687
+ const defaults = { proposals: 200, learnings: 500, gaps: 200, actions: 300 };
688
+ const supplied = this.config.retentionTargets?.[kind];
689
+ const legacy = this.config[aliases[kind]];
690
+ const value = supplied !== undefined ? supplied : legacy !== undefined ? legacy : defaults[kind];
691
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
692
+ }
693
+ terminal(kind, row) {
694
+ switch (kind) {
695
+ case 'proposals': return ['implemented', 'rejected'].includes(row.status);
696
+ case 'learnings': return row.applied === true;
697
+ case 'gaps': return row.status !== 'identified';
698
+ case 'actions': return !row.autoExpiryHold && ['completed', 'cancelled'].includes(row.status);
699
+ }
700
+ }
701
+ retentionStats(kind, rows) {
702
+ const active = rows.filter(row => !this.terminal(kind, row)).length;
703
+ const target = this.target(kind);
704
+ return { total: rows.length, active, terminalRetained: rows.length - active, target,
705
+ terminalAllowance: target === null ? null : Math.max(0, target - active),
706
+ overflow: target === null ? null : Math.max(0, rows.length - target) };
707
+ }
708
+ warnRetention(kind, detail) {
709
+ const signature = JSON.stringify(detail);
710
+ if (this.retentionWarnings.get(kind) === signature)
711
+ return;
712
+ this.retentionWarnings.set(kind, signature);
713
+ console.warn(`[EvolutionManager] retention-pressure ${JSON.stringify({ store: kind, ...detail })}`);
714
+ }
715
+ retain(kind, rows) {
716
+ const target = this.target(kind);
717
+ let retained = rows;
718
+ let preservationError;
719
+ if (target !== null && rows.length > target) {
720
+ const active = rows.filter(row => !this.terminal(kind, row));
721
+ const terminal = rows.filter(row => this.terminal(kind, row));
722
+ const allowance = Math.max(0, target - active.length);
723
+ const keep = allowance > 0 ? terminal.slice(-allowance) : [];
724
+ const removed = terminal.slice(0, terminal.length - keep.length);
725
+ if (removed.length) {
726
+ try {
727
+ this.archive.preserve(kind, removed);
728
+ retained = [...active, ...keep];
729
+ }
730
+ catch (error) {
731
+ // @silent-fallback-ok: preservation failed; every source record remains hot and pressure is observable.
732
+ preservationError = String(error);
733
+ }
734
+ }
735
+ }
736
+ const stats = this.retentionStats(kind, retained);
737
+ if (preservationError)
738
+ this.warnRetention(kind, { ...stats, reason: 'archive-unavailable', error: preservationError });
739
+ else if (target === null || (stats.overflow ?? 0) > 0)
740
+ this.warnRetention(kind, stats);
741
+ else
742
+ this.retentionWarnings.delete(kind);
743
+ return retained;
744
+ }
745
+ getArchivePage(options = {}) {
746
+ return this.archive.page(options);
747
+ }
748
+ getRetentionReport() {
749
+ const loaders = { proposals: () => this.loadEvolution().proposals, learnings: () => this.loadLearnings().learnings,
750
+ gaps: () => this.loadGaps().gaps, actions: () => this.loadActions().actions };
751
+ return Object.fromEntries(Object.entries(loaders).map(([kind, load]) => {
752
+ try {
753
+ return [kind, { status: 'known', ...this.retentionStats(kind, load()) }];
754
+ }
755
+ catch (error) {
756
+ return [kind, { status: 'unknown', error: String(error) }];
757
+ }
758
+ }));
759
+ }
760
+ nextRetainedId(kind, prefix, live) {
761
+ const records = [...live, ...this.archive.localEntries(kind)];
762
+ const max = records.reduce((n, record) => {
763
+ const suffix = record.id.startsWith(prefix + '-') ? record.id.slice(prefix.length + 1) : '';
764
+ if (!/^[0-9]+$/.test(suffix))
765
+ return n;
766
+ const value = Number(suffix);
767
+ if (!Number.isSafeInteger(value))
768
+ throw new Error(`Evolution ${kind} IDs exhausted`);
769
+ return Math.max(n, value);
770
+ }, 0);
771
+ if (!Number.isSafeInteger(max + 1))
772
+ throw new Error(`Evolution ${kind} IDs exhausted`);
773
+ return `${prefix}-${String(max + 1).padStart(3, '0')}`;
774
+ }
668
775
  // ── Evolution Queue ─────────────────────────────────────────────
669
776
  loadEvolution() {
670
777
  return this.readFile('evolution-queue', {
671
778
  proposals: [],
672
- stats: { totalProposals: 0, byStatus: {}, byType: {}, lastUpdated: this.now() },
779
+ stats: { ...this.retentionStats('proposals', []), totalProposals: 0, byStatus: {}, byType: {}, lastUpdated: this.now() },
673
780
  });
674
781
  }
675
- saveEvolution(state) {
782
+ computeEvolutionStateStats(state) {
676
783
  // Recompute stats
677
784
  const statusCounts = {};
678
785
  const typeCounts = {};
@@ -680,29 +787,21 @@ export class EvolutionManager {
680
787
  statusCounts[p.status] = (statusCounts[p.status] || 0) + 1;
681
788
  typeCounts[p.type] = (typeCounts[p.type] || 0) + 1;
682
789
  }
683
- state.stats = {
790
+ return {
791
+ ...this.retentionStats('proposals', state.proposals),
684
792
  totalProposals: state.proposals.length,
685
793
  byStatus: statusCounts,
686
794
  byType: typeCounts,
687
795
  lastUpdated: this.now(),
688
796
  };
689
- // Archive old implemented/rejected proposals if over limit
690
- const max = this.config.maxProposals || 200;
691
- if (state.proposals.length > max) {
692
- const active = state.proposals.filter(p => !['implemented', 'rejected'].includes(p.status));
693
- const archived = state.proposals.filter(p => ['implemented', 'rejected'].includes(p.status));
694
- // Keep most recent archived
695
- const keep = archived.slice(-Math.max(0, max - active.length));
696
- state.proposals = [...active, ...keep];
697
- }
797
+ }
798
+ saveEvolution(state) {
799
+ state.proposals = this.retain('proposals', state.proposals);
800
+ state.stats = this.computeEvolutionStateStats(state);
698
801
  this.writeFile('evolution-queue', state);
699
802
  }
700
803
  nextProposalId(state) {
701
- const existing = new Set(state.proposals.map(p => p.id));
702
- let num = 1;
703
- while (existing.has(`EVO-${String(num).padStart(3, '0')}`))
704
- num++;
705
- return `EVO-${String(num).padStart(3, '0')}`;
804
+ return this.nextRetainedId('proposals', 'EVO', state.proposals);
706
805
  }
707
806
  addProposal(opts) {
708
807
  const state = this.loadEvolution();
@@ -801,16 +900,16 @@ export class EvolutionManager {
801
900
  return proposals;
802
901
  }
803
902
  getEvolutionStats() {
804
- return this.loadEvolution().stats;
903
+ return this.computeEvolutionStateStats(this.loadEvolution());
805
904
  }
806
905
  // ── Learning Registry ───────────────────────────────────────────
807
906
  loadLearnings() {
808
907
  return this.readFile('learning-registry', {
809
908
  learnings: [],
810
- stats: { totalLearnings: 0, applied: 0, pending: 0, byCategory: {}, lastUpdated: this.now() },
909
+ stats: { ...this.retentionStats('learnings', []), totalLearnings: 0, applied: 0, pending: 0, byCategory: {}, lastUpdated: this.now() },
811
910
  });
812
911
  }
813
- saveLearnings(state) {
912
+ computeLearningStateStats(state) {
814
913
  const categoryCounts = {};
815
914
  let applied = 0;
816
915
  for (const l of state.learnings) {
@@ -818,50 +917,29 @@ export class EvolutionManager {
818
917
  if (l.applied)
819
918
  applied++;
820
919
  }
821
- state.stats = {
920
+ return {
921
+ ...this.retentionStats('learnings', state.learnings),
822
922
  totalLearnings: state.learnings.length,
823
923
  applied,
824
924
  pending: state.learnings.length - applied,
825
925
  byCategory: categoryCounts,
826
926
  lastUpdated: this.now(),
827
927
  };
828
- const max = this.config.maxLearnings || 500;
928
+ }
929
+ saveLearnings(state) {
829
930
  const beforePrune = state.learnings;
830
- if (state.learnings.length > max) {
831
- const unapplied = state.learnings.filter(l => !l.applied);
832
- const appliedEntries = state.learnings.filter(l => l.applied);
833
- const keep = appliedEntries.slice(-Math.max(0, max - unapplied.length));
834
- state.learnings = [...unapplied, ...keep];
835
- }
931
+ state.learnings = this.retain('learnings', state.learnings);
932
+ state.stats = this.computeLearningStateStats(state);
836
933
  this.writeFile('learning-registry', state);
837
- // WS2.2 — best-effort replication emission (dark by default; the emitter is only
838
- // injected when multiMachine.stateSync.learnings.enabled is true). The emitter
839
- // swallows its own errors, but we wrap defensively so a replication fault can NEVER
840
- // break a local learning write. CRITICAL (tombstone resurrection guard): a learning
841
- // that was PRUNED over maxLearnings emits an op:delete tombstone, else a peer
842
- // re-replicates the locally-pruned learning forever.
934
+ // Preserve the final full-content put for evicted records. The archive-inclusive
935
+ // reconciler retries failures; operational eviction is never a memory deletion.
843
936
  const emitter = this.learningReplication;
844
937
  if (emitter) {
845
- const survivors = new Set(state.learnings.map(l => l.id));
846
- const deletedAt = this.now();
847
- for (const pruned of beforePrune) {
848
- if (survivors.has(pruned.id))
849
- continue;
850
- try {
851
- emitter.emitDelete(pruned.title, pruned.category, pruned.source, deletedAt);
852
- }
853
- catch {
854
- // @silent-fallback-ok: a replication emit fault must never break or roll back
855
- // a local learning write — the durable on-disk state is already persisted
856
- // above. The emitter counts its own failures internally; this guard only
857
- // ensures a throw from the seam cannot propagate into the local write path.
858
- }
859
- this.lastEmittedLearningFp.delete(pruned.id);
860
- }
938
+ // Hot-list eviction preserves memory; it never emits deletion authority.
861
939
  // Standby-write-wedge fix (see saveActions): re-emit ONLY changed learnings. The old
862
940
  // "re-emit every survivor" made each write O(records × journalBytes) synchronous
863
941
  // journal scans; a status/applied change alters the fingerprint so it still re-emits.
864
- for (const l of state.learnings) {
942
+ for (const l of beforePrune) {
865
943
  const fp = this.emitFingerprint(l);
866
944
  if (this.lastEmittedLearningFp.get(l.id) === fp)
867
945
  continue; // unchanged ⇒ skip re-emit
@@ -870,7 +948,7 @@ export class EvolutionManager {
870
948
  this.lastEmittedLearningFp.set(l.id, fp);
871
949
  }
872
950
  catch {
873
- // @silent-fallback-ok: see the emitDelete guard above — replication is
951
+ // @silent-fallback-ok: replication remains best-effort — replication is
874
952
  // best-effort and must never break the local write. The fingerprint is NOT
875
953
  // recorded on a throw so the next save retries this record.
876
954
  }
@@ -878,11 +956,7 @@ export class EvolutionManager {
878
956
  }
879
957
  }
880
958
  nextLearningId(state) {
881
- const existing = new Set(state.learnings.map(l => l.id));
882
- let num = 1;
883
- while (existing.has(`LRN-${String(num).padStart(3, '0')}`))
884
- num++;
885
- return `LRN-${String(num).padStart(3, '0')}`;
959
+ return this.nextRetainedId('learnings', 'LRN', state.learnings);
886
960
  }
887
961
  addLearning(opts) {
888
962
  const state = this.loadLearnings();
@@ -920,17 +994,49 @@ export class EvolutionManager {
920
994
  learnings = learnings.filter(l => l.applied === filter.applied);
921
995
  return learnings;
922
996
  }
997
+ /** Archive-inclusive current projection for puts-only repair. Live records win by replication identity. */
998
+ listLearningsForReplication() {
999
+ const records = new Map();
1000
+ for (const row of this.archive.localEntries('learnings')) {
1001
+ const key = deriveLearningRecordKey(row.title, row.category, row.source);
1002
+ if (key)
1003
+ records.set(key, row);
1004
+ }
1005
+ for (const row of this.listLearnings()) {
1006
+ const key = deriveLearningRecordKey(row.title, row.category, row.source);
1007
+ if (key)
1008
+ records.set(key, row);
1009
+ }
1010
+ return [...records.values()];
1011
+ }
1012
+ listActionsForReplication() {
1013
+ const records = new Map();
1014
+ for (const entry of this.loadActionArchive()) {
1015
+ const row = entry.class === 'retention' || entry.restoredAt ? entry.action : {
1016
+ ...entry.action, status: 'cancelled', resolution: 'auto-archived after long inactivity; restorable',
1017
+ };
1018
+ const key = deriveEvolutionActionRecordKey(row.title, row.commitTo, row.createdAt);
1019
+ if (key)
1020
+ records.set(key, row);
1021
+ }
1022
+ for (const row of this.listActions()) {
1023
+ const key = deriveEvolutionActionRecordKey(row.title, row.commitTo, row.createdAt);
1024
+ if (key)
1025
+ records.set(key, row);
1026
+ }
1027
+ return [...records.values()];
1028
+ }
923
1029
  getLearningStats() {
924
- return this.loadLearnings().stats;
1030
+ return this.computeLearningStateStats(this.loadLearnings());
925
1031
  }
926
1032
  // ── Capability Gap Tracker ──────────────────────────────────────
927
1033
  loadGaps() {
928
1034
  return this.readFile('capability-gaps', {
929
1035
  gaps: [],
930
- stats: { totalGaps: 0, bySeverity: {}, byCategory: {}, addressed: 0, lastUpdated: this.now() },
1036
+ stats: { ...this.retentionStats('gaps', []), totalGaps: 0, bySeverity: {}, byCategory: {}, addressed: 0, lastUpdated: this.now() },
931
1037
  });
932
1038
  }
933
- saveGaps(state) {
1039
+ computeGapStateStats(state) {
934
1040
  const severityCounts = {};
935
1041
  const categoryCounts = {};
936
1042
  let addressed = 0;
@@ -940,28 +1046,22 @@ export class EvolutionManager {
940
1046
  if (g.status === 'addressed')
941
1047
  addressed++;
942
1048
  }
943
- state.stats = {
1049
+ return {
1050
+ ...this.retentionStats('gaps', state.gaps),
944
1051
  totalGaps: state.gaps.length,
945
1052
  bySeverity: severityCounts,
946
1053
  byCategory: categoryCounts,
947
1054
  addressed,
948
1055
  lastUpdated: this.now(),
949
1056
  };
950
- const max = this.config.maxGaps || 200;
951
- if (state.gaps.length > max) {
952
- const open = state.gaps.filter(g => g.status === 'identified');
953
- const closed = state.gaps.filter(g => g.status !== 'identified');
954
- const keep = closed.slice(-Math.max(0, max - open.length));
955
- state.gaps = [...open, ...keep];
956
- }
1057
+ }
1058
+ saveGaps(state) {
1059
+ state.gaps = this.retain('gaps', state.gaps);
1060
+ state.stats = this.computeGapStateStats(state);
957
1061
  this.writeFile('capability-gaps', state);
958
1062
  }
959
1063
  nextGapId(state) {
960
- const existing = new Set(state.gaps.map(g => g.id));
961
- let num = 1;
962
- while (existing.has(`GAP-${String(num).padStart(3, '0')}`))
963
- num++;
964
- return `GAP-${String(num).padStart(3, '0')}`;
1064
+ return this.nextRetainedId('gaps', 'GAP', state.gaps);
965
1065
  }
966
1066
  addGap(opts) {
967
1067
  const state = this.loadGaps();
@@ -1009,13 +1109,13 @@ export class EvolutionManager {
1009
1109
  return gaps;
1010
1110
  }
1011
1111
  getGapStats() {
1012
- return this.loadGaps().stats;
1112
+ return this.computeGapStateStats(this.loadGaps());
1013
1113
  }
1014
1114
  // ── Action Queue ────────────────────────────────────────────────
1015
1115
  loadActions() {
1016
1116
  return this.readFile('action-queue', {
1017
1117
  actions: [],
1018
- stats: { totalActions: 0, pending: 0, completed: 0, overdue: 0, lastUpdated: this.now() },
1118
+ stats: { ...this.retentionStats('actions', []), totalActions: 0, pending: 0, completed: 0, overdue: 0, lastUpdated: this.now() },
1019
1119
  });
1020
1120
  }
1021
1121
  /**
@@ -1098,23 +1198,18 @@ export class EvolutionManager {
1098
1198
  * `readFile` would hand back an empty archive and the next write would erase it.
1099
1199
  */
1100
1200
  loadActionArchive() {
1101
- const fp = this.filePath('action-archive');
1102
- if (!fs.existsSync(fp))
1103
- return [];
1104
- const parsed = JSON.parse(fs.readFileSync(fp, 'utf-8'));
1105
- if (!Array.isArray(parsed.entries))
1106
- throw new Error(`no entries array in ${fp}`);
1107
- return parsed.entries;
1201
+ return this.archive.read().entries;
1108
1202
  }
1109
1203
  /** Returns false, having changed nothing, when the archive cannot be written. */
1110
1204
  archiveActions(actions, items) {
1111
1205
  try {
1112
1206
  const archivedAt = this.now();
1113
1207
  const byId = new Map(items.map((item) => [item.id, item]));
1114
- const ids = new Set(actions.map((action) => action.id));
1115
- // An unrestored entry for the same id means a crash between archive and queue save.
1116
- const entries = this.loadActionArchive().filter((entry) => entry.restoredAt || !ids.has(entry.action.id));
1208
+ // Preserve every version; identical retry snapshots are idempotent.
1209
+ const entries = this.loadActionArchive();
1117
1210
  for (const action of actions) {
1211
+ if (entries.some(entry => !entry.restoredAt && this.emitFingerprint(entry.action) === this.emitFingerprint(action)))
1212
+ continue;
1118
1213
  const item = byId.get(action.id);
1119
1214
  entries.push({
1120
1215
  action,
@@ -1124,7 +1219,7 @@ export class EvolutionManager {
1124
1219
  ...(item?.reason ? { reason: item.reason } : {}),
1125
1220
  });
1126
1221
  }
1127
- this.writeFile('action-archive', { entries });
1222
+ this.archive.write({ ...this.archive.read(), entries });
1128
1223
  return true;
1129
1224
  }
1130
1225
  catch (err) {
@@ -1156,7 +1251,7 @@ export class EvolutionManager {
1156
1251
  */
1157
1252
  restoreArchivedAction(id) {
1158
1253
  const entries = this.loadActionArchive();
1159
- const entry = entries.find((candidate) => candidate.action.id === id && !candidate.restoredAt);
1254
+ const entry = [...entries].reverse().find((candidate) => candidate.action.id === id && !candidate.restoredAt);
1160
1255
  if (!entry)
1161
1256
  return 'not-archived';
1162
1257
  const state = this.loadActions();
@@ -1166,11 +1261,17 @@ export class EvolutionManager {
1166
1261
  const action = { ...entry.action, autoExpiryHold: { reason: 'restored from the auto-expiry archive', setAt: restoredAt } };
1167
1262
  state.actions.push(action);
1168
1263
  this.saveActions(state);
1169
- entry.restoredAt = restoredAt;
1170
- this.writeFile('action-archive', { entries });
1264
+ // Retention may have archived another terminal action while saving the restored
1265
+ // row. Merge the marker into the fresh document; never replace its new history.
1266
+ const latest = this.archive.read();
1267
+ const restoredEntry = [...latest.entries].reverse().find(candidate => candidate.archivedAt === entry.archivedAt && this.emitFingerprint(candidate.action) === this.emitFingerprint(entry.action));
1268
+ if (!restoredEntry)
1269
+ throw new Error('restored archive snapshot unavailable');
1270
+ restoredEntry.restoredAt = restoredAt;
1271
+ this.archive.write(latest);
1171
1272
  return action;
1172
1273
  }
1173
- saveActions(state) {
1274
+ computeActionStateStats(state) {
1174
1275
  let pending = 0, completed = 0, overdue = 0;
1175
1276
  const now = new Date();
1176
1277
  for (const a of state.actions) {
@@ -1182,57 +1283,31 @@ export class EvolutionManager {
1182
1283
  overdue++;
1183
1284
  }
1184
1285
  }
1185
- state.stats = {
1286
+ return {
1287
+ ...this.retentionStats('actions', state.actions),
1186
1288
  totalActions: state.actions.length,
1187
1289
  pending,
1188
1290
  completed,
1189
1291
  overdue,
1190
1292
  lastUpdated: this.now(),
1191
1293
  };
1192
- const max = this.config.maxActions || 300;
1294
+ }
1295
+ saveActions(state) {
1193
1296
  const beforePrune = state.actions;
1194
- if (state.actions.length > max) {
1195
- const active = state.actions.filter(a => !['completed', 'cancelled'].includes(a.status));
1196
- const done = state.actions.filter(a => ['completed', 'cancelled'].includes(a.status));
1197
- const keep = done.slice(-Math.max(0, max - active.length));
1198
- state.actions = [...active, ...keep];
1199
- }
1297
+ state.actions = this.retain('actions', state.actions);
1298
+ state.stats = this.computeActionStateStats(state);
1200
1299
  this.writeFile('action-queue', state);
1201
- // WS2.5 — best-effort replication emission (dark by default; the emitter is only
1202
- // injected when multiMachine.stateSync.evolutionActions.enabled is true). The emitter
1203
- // swallows its own errors, but we wrap defensively so a replication fault can NEVER
1204
- // break a local action write. CRITICAL (fork #2): a STATUS CHANGE must re-emit — both
1205
- // addAction and updateAction route through saveActions, so re-emitting every survivor on
1206
- // every save makes a peer SEE an action's latest status (completed/in_progress) so it does
1207
- // not redo it. CRITICAL (resurrection guard): an action that was actually REMOVED from the
1208
- // queue (prune-over-maxActions) emits an op:delete tombstone — a terminal completed/
1209
- // cancelled action that is RETAINED is NOT tombstoned, only an actual queue-removal is.
1300
+ // Preserve the final put (including status changes) without deletion tombstones.
1301
+ // Full-content custody and puts-only repair remain available from the archive.
1210
1302
  const emitter = this.actionReplication;
1211
1303
  if (emitter) {
1212
- const survivors = new Set(state.actions.map(a => a.id));
1213
- const deletedAt = this.now();
1214
- for (const pruned of beforePrune) {
1215
- if (survivors.has(pruned.id))
1216
- continue;
1217
- try {
1218
- emitter.emitDelete(pruned.title, pruned.commitTo, pruned.createdAt, deletedAt);
1219
- }
1220
- catch {
1221
- // @silent-fallback-ok: a replication emit fault must never break or roll back a
1222
- // local action write — the durable on-disk state is already persisted above. The
1223
- // emitter counts its own failures internally; this guard only ensures a throw from
1224
- // the seam cannot propagate into the local write path.
1225
- }
1226
- // A pruned id is gone from the queue; forget its fingerprint so the map does not
1227
- // grow unbounded and a future id-reuse re-emits.
1228
- this.lastEmittedActionFp.delete(pruned.id);
1229
- }
1304
+ // Hot-list eviction preserves memory; it never emits deletion authority.
1230
1305
  // Standby-write-wedge fix: re-emit ONLY records whose content changed since their last
1231
1306
  // emit. Re-emitting every unchanged survivor (the old behavior) turned each write into
1232
1307
  // O(records × journalBytes) synchronous journal scans (loadWitness materializes the
1233
1308
  // whole store per emit) — the event-loop wedge. A status change alters the fingerprint,
1234
1309
  // so the load-bearing "a peer sees the latest status" property is preserved.
1235
- for (const a of state.actions) {
1310
+ for (const a of beforePrune) {
1236
1311
  const fp = this.emitFingerprint(a);
1237
1312
  if (this.lastEmittedActionFp.get(a.id) === fp)
1238
1313
  continue; // unchanged ⇒ skip re-emit
@@ -1241,7 +1316,7 @@ export class EvolutionManager {
1241
1316
  this.lastEmittedActionFp.set(a.id, fp); // record only after a successful emit
1242
1317
  }
1243
1318
  catch {
1244
- // @silent-fallback-ok: see the emitDelete guard above — replication is best-effort
1319
+ // @silent-fallback-ok: replication remains best-effort — replication is best-effort
1245
1320
  // and must never break the local write. The fingerprint is NOT recorded on a throw
1246
1321
  // so the next save retries this record.
1247
1322
  }
@@ -1249,21 +1324,7 @@ export class EvolutionManager {
1249
1324
  }
1250
1325
  }
1251
1326
  nextActionId(state) {
1252
- // An archived id stays reserved so a restore does not collide with new work. A
1253
- // damaged archive must not stop new actions: restore refuses a colliding id anyway.
1254
- let archivedIds = [];
1255
- try {
1256
- archivedIds = this.loadActionArchive().map(e => e.action.id);
1257
- }
1258
- catch (err) {
1259
- // @silent-fallback-ok: the sweep refuses on the same damage; id reservation is a courtesy to restore.
1260
- console.warn(`[EvolutionManager] Action archive unreadable, ids not reserved: ${err instanceof Error ? err.message : String(err)}`);
1261
- }
1262
- const existing = new Set([...state.actions.map(a => a.id), ...archivedIds]);
1263
- let num = 1;
1264
- while (existing.has(`ACT-${String(num).padStart(3, '0')}`))
1265
- num++;
1266
- return `ACT-${String(num).padStart(3, '0')}`;
1327
+ return this.nextRetainedId('actions', 'ACT', state.actions);
1267
1328
  }
1268
1329
  addAction(opts) {
1269
1330
  const state = this.loadActions();
@@ -1320,7 +1381,7 @@ export class EvolutionManager {
1320
1381
  a.dueBy && new Date(a.dueBy) < now);
1321
1382
  }
1322
1383
  getActionStats() {
1323
- return this.loadActions().stats;
1384
+ return this.computeActionStateStats(this.loadActions());
1324
1385
  }
1325
1386
  // ── Cross-System Queries ────────────────────────────────────────
1326
1387
  /**