akm-cli 0.9.0-beta.26 → 0.9.0-beta.28

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.
@@ -683,6 +683,11 @@ export async function akmImprove(options = {}) {
683
683
  const ensureIndexFn = options.ensureIndexFn ?? ensureIndex;
684
684
  const reindexFn = options.reindexFn ?? akmIndex;
685
685
  const drainProposalsFn = options.drainProposalsFn ?? drainProposals;
686
+ // #616 multi-cycle test seams. Default to the real module-local fns.
687
+ const collectEligibleRefsImpl = options.collectEligibleRefsFn ?? collectEligibleRefs;
688
+ const runImprovePreparationStageImpl = options.runImprovePreparationStageFn ?? runImprovePreparationStage;
689
+ const runImproveLoopStageImpl = options.runImproveLoopStageFn ?? runImproveLoopStage;
690
+ const runImprovePostLoopStageImpl = options.runImprovePostLoopStageFn ?? runImprovePostLoopStage;
686
691
  // Resolve the improve profile for this run. Profile drives type filtering,
687
692
  // process gating, and default autoAccept/limit values.
688
693
  const _earlyConfig = options.config ?? loadConfig();
@@ -697,6 +702,9 @@ export async function akmImprove(options = {}) {
697
702
  // CLI --limit takes precedence over both.
698
703
  limit: options.limit ?? improveProfile?.processes?.reflect?.limit ?? improveProfile.limit,
699
704
  };
705
+ // #616 — bounded multi-cycle phasing. CLI/programmatic override wins over
706
+ // profile.maxCycles; default 1 => single pass (byte-identical to pre-#616).
707
+ const maxCycles = Math.max(1, Math.trunc(options.maxCycles ?? improveProfile.maxCycles ?? 1));
700
708
  let primaryStashDir;
701
709
  try {
702
710
  primaryStashDir = resolveSourceEntries(options.stashDir)[0]?.path;
@@ -741,70 +749,23 @@ export async function akmImprove(options = {}) {
741
749
  // Lock base directory — same `.akm/` under the primary stash dir.
742
750
  const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
743
751
  const preEnsureCleanupWarnings = [];
744
- let plannedRefs;
745
- let memorySummary;
746
- let profileFilteredRefs;
752
+ // #616: assigned by runIndexAndCollect() (closure) so TS cannot prove definite
753
+ // assignment — seed with empty values; the first runIndexAndCollect() call
754
+ // (cycle 1, in the first try) always overwrites them before any read.
755
+ let plannedRefs = [];
756
+ let memorySummary = { eligible: 0, derived: 0 };
757
+ let profileFilteredRefs = [];
747
758
  let memoryCleanupPlan;
748
759
  let guidance;
749
760
  let triageDrain;
750
- try {
751
- // #607: Per-process lock acquisition. Each process acquires only the lock(s)
752
- // it needs. The dry-run branch produces plannedRefs/memorySummary WITHOUT any
753
- // locks (decision: dry-run never mutates the queue).
754
- if (!options.dryRun) {
755
- // Backstop release on process.exit() (signal handler / budget watchdog),
756
- // which skips the finally below. Removed in that finally on the normal path.
757
- const releaseAllOnExit = () => {
758
- for (const p of heldProcessLocks) {
759
- releaseLockIfOwned(p, process.pid);
760
- }
761
- };
762
- process.on("exit", releaseAllOnExit);
763
- // #607 triage pre-pass: acquire triage.lock, drain the standing pending
764
- // backlog BEFORE ensureIndex so improve generates fresh proposals against
765
- // a cleared queue (no `duplicate_pending` collisions) and ensureIndex
766
- // absorbs triage's promotions for free. Release immediately after —
767
- // triage.lock is not needed again until the next improve run.
768
- if (primaryStashDir && resolveProcessEnabled("triage", improveProfile)) {
769
- if (scope.mode === "ref") {
770
- warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
771
- }
772
- else {
773
- const triageLPath = processLockPath(lockBaseDir, "triage");
774
- const triageResult = tryAcquireProcessLock(triageLPath, PROCESS_LOCK_DEFS.triage.staleAfterMs, options.skipIfLocked, "triage");
775
- if (triageResult === "skipped") {
776
- triageDrain = undefined;
777
- }
778
- else {
779
- try {
780
- const triageConfig = improveProfile.processes?.triage;
781
- const policy = resolveDrainPolicy(triageConfig?.policy);
782
- const applyMode = triageConfig?.applyMode ?? "queue";
783
- const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
784
- const judgment = triageConfig?.judgment
785
- ? resolveTriageJudgmentRunner(triageConfig.judgment, _earlyConfig)
786
- : null;
787
- triageDrain = await drainProposalsFn({
788
- stashDir: primaryStashDir,
789
- policy,
790
- applyMode,
791
- maxAccepts,
792
- dryRun: false,
793
- excludeIds: new Set(),
794
- ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
795
- judgment,
796
- });
797
- }
798
- catch (err) {
799
- warn(`[improve] triage pre-pass failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
800
- }
801
- finally {
802
- releaseProcessLock(triageLPath);
803
- }
804
- }
805
- }
806
- }
807
- }
761
+ // #616 — ensureIndex + collectEligibleRefs + memory-cleanup recompute, lifted
762
+ // into a helper so the SAME sequence runs once for cycle 1 (below, in the
763
+ // first try) and is re-run at the top of each subsequent multi-cycle cycle.
764
+ // Re-running ensureIndex between cycles makes cycle N's gate-promoted
765
+ // proposals visible to cycle N+1's collectEligibleRefs. Mutates the
766
+ // outer-scope plannedRefs/memorySummary/profileFilteredRefs/memoryCleanupPlan/
767
+ // guidance so for maxCycles:1 the body is byte-identical to pre-#616.
768
+ const runIndexAndCollect = async () => {
808
769
  // #339 fix: ensureIndex MUST run BEFORE collectEligibleRefs. The eligible-ref
809
770
  // query reads the `entries` table; if a DB version upgrade just dropped that
810
771
  // table (or the index is otherwise empty), the prior run order silently
@@ -862,7 +823,7 @@ export async function akmImprove(options = {}) {
862
823
  }
863
824
  }
864
825
  }
865
- ({ plannedRefs, memorySummary, profileFilteredRefs } = await collectEligibleRefs(scope, options.stashDir, improveProfile));
826
+ ({ plannedRefs, memorySummary, profileFilteredRefs } = await collectEligibleRefsImpl(scope, options.stashDir, improveProfile));
866
827
  const cleanupParentRef = memoryCleanupParentRef(scope, options.stashDir);
867
828
  // M-1 (#367): Run contradiction-detection BEFORE analyzeMemoryCleanup so
868
829
  // the SCC resolver in resolveFamilyContradictions has edges to work on.
@@ -884,6 +845,69 @@ export async function akmImprove(options = {}) {
884
845
  memorySummary.eligible > 0
885
846
  ? "Improve folds memory cleanup into the same proposal queue: speculative promotions still go through reflect/distill proposals, while high-confidence redundant derived memories are moved into a recoverable cleanup archive instead of being left active in the stash."
886
847
  : undefined;
848
+ };
849
+ try {
850
+ // #607: Per-process lock acquisition. Each process acquires only the lock(s)
851
+ // it needs. The dry-run branch produces plannedRefs/memorySummary WITHOUT any
852
+ // locks (decision: dry-run never mutates the queue).
853
+ if (!options.dryRun) {
854
+ // Backstop release on process.exit() (signal handler / budget watchdog),
855
+ // which skips the finally below. Removed in that finally on the normal path.
856
+ const releaseAllOnExit = () => {
857
+ for (const p of heldProcessLocks) {
858
+ releaseLockIfOwned(p, process.pid);
859
+ }
860
+ };
861
+ process.on("exit", releaseAllOnExit);
862
+ // #607 triage pre-pass: acquire triage.lock, drain the standing pending
863
+ // backlog BEFORE ensureIndex so improve generates fresh proposals against
864
+ // a cleared queue (no `duplicate_pending` collisions) and ensureIndex
865
+ // absorbs triage's promotions for free. Release immediately after —
866
+ // triage.lock is not needed again until the next improve run.
867
+ if (primaryStashDir && resolveProcessEnabled("triage", improveProfile)) {
868
+ if (scope.mode === "ref") {
869
+ warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
870
+ }
871
+ else {
872
+ const triageLPath = processLockPath(lockBaseDir, "triage");
873
+ const triageResult = tryAcquireProcessLock(triageLPath, PROCESS_LOCK_DEFS.triage.staleAfterMs, options.skipIfLocked, "triage");
874
+ if (triageResult === "skipped") {
875
+ triageDrain = undefined;
876
+ }
877
+ else {
878
+ try {
879
+ const triageConfig = improveProfile.processes?.triage;
880
+ const policy = resolveDrainPolicy(triageConfig?.policy);
881
+ const applyMode = triageConfig?.applyMode ?? "queue";
882
+ const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
883
+ const judgment = triageConfig?.judgment
884
+ ? resolveTriageJudgmentRunner(triageConfig.judgment, _earlyConfig)
885
+ : null;
886
+ triageDrain = await drainProposalsFn({
887
+ stashDir: primaryStashDir,
888
+ policy,
889
+ applyMode,
890
+ maxAccepts,
891
+ dryRun: false,
892
+ excludeIds: new Set(),
893
+ ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
894
+ judgment,
895
+ });
896
+ }
897
+ catch (err) {
898
+ warn(`[improve] triage pre-pass failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
899
+ }
900
+ finally {
901
+ releaseProcessLock(triageLPath);
902
+ }
903
+ }
904
+ }
905
+ }
906
+ }
907
+ // #339 fix: ensureIndex MUST run BEFORE collectEligibleRefs (now inside the
908
+ // helper). Cycle 1 runs it here; subsequent multi-cycle cycles re-run it via
909
+ // the same helper at the top of each cycle below.
910
+ await runIndexAndCollect();
887
911
  if (options.dryRun) {
888
912
  const result = {
889
913
  schemaVersion: 1,
@@ -981,89 +1005,194 @@ export async function akmImprove(options = {}) {
981
1005
  },
982
1006
  }, eventsCtx);
983
1007
  }
984
- // #607: acquire consolidate.lock for the preparation stage (consolidate,
985
- // ensureIndex, extract all write index.db). Released immediately after.
986
- const consolidateLPath = processLockPath(lockBaseDir, "consolidate");
987
- const consolidatePrepAcquired = tryAcquireProcessLock(consolidateLPath, PROCESS_LOCK_DEFS.consolidate.staleAfterMs, options.skipIfLocked, "consolidate") === "acquired";
988
- const preparation = await runImprovePreparationStage({
989
- scope,
990
- options,
991
- plannedRefs,
992
- memoryCleanupPlan,
993
- primaryStashDir,
994
- memorySummary,
995
- reindexFn,
996
- startMs,
997
- budgetMs,
998
- eventsCtx,
999
- initialCleanupWarnings: preEnsureCleanupWarnings,
1000
- improveProfile,
1001
- budgetSignal: budgetAbortController.signal,
1002
- });
1003
- if (consolidatePrepAcquired)
1004
- releaseProcessLock(consolidateLPath);
1005
- // D6: pre-load all proposal_rejected events from the last 30 days once,
1006
- // so the per-asset loop can use a Map lookup instead of N DB round trips.
1007
- const REJECTED_PROPOSAL_WINDOW_MS = daysToMs(30);
1008
- const rejectedProposalSince = new Date(Date.now() - REJECTED_PROPOSAL_WINDOW_MS).toISOString();
1009
- const allRejectedProposalEvents = readEvents({ type: "proposal_rejected", since: rejectedProposalSince }).events;
1010
- const rejectedProposalsByRef = new Map();
1011
- for (const e of allRejectedProposalEvents) {
1012
- if (e.ref && (!rejectedProposalsByRef.has(e.ref) || e.ts > (rejectedProposalsByRef.get(e.ref)?.ts ?? ""))) {
1013
- rejectedProposalsByRef.set(e.ref, e);
1008
+ // #616 bounded multi-cycle phasing. The prep->loop->post-loop sequence is
1009
+ // wrapped in an N-cycle loop. Each cycle re-runs ensureIndex +
1010
+ // collectEligibleRefs (via runIndexAndCollect) so gate-accepted output of
1011
+ // cycle N becomes selectable input to cycle N+1. The per-stage process locks
1012
+ // (consolidate / reflect-distill) are acquired+released INSIDE each cycle,
1013
+ // exactly as the single-pass path did. For maxCycles:1 the loop runs once and
1014
+ // every accumulator below collapses to the single-cycle value (sum-of-one,
1015
+ // concat-of-one, last==only) => BYTE-IDENTICAL to pre-#616.
1016
+ //
1017
+ // Accumulators (see CONSTRAINTS / aggregation plan in #616): SUM the count
1018
+ // fields and durations; CONCAT the array fields; LAST-WINS for point-in-time
1019
+ // objects (the final cycle's value reflects the converged state).
1020
+ let cyclesRun = 0;
1021
+ // Last-wins point-in-time values (assigned every cycle; the final cycle wins).
1022
+ let preparation;
1023
+ let memoryRefsForInference = new Set();
1024
+ let consolidation;
1025
+ let memoryInference;
1026
+ let graphExtraction;
1027
+ let stalenessDetection;
1028
+ let recombination;
1029
+ let proceduralCompilation;
1030
+ // Summed counters/durations.
1031
+ let prepGateCount = 0;
1032
+ let prepGateFailedCount = 0;
1033
+ let reflectsWithErrorContext = 0;
1034
+ let loopGateCount = 0;
1035
+ let loopGateFailedCount = 0;
1036
+ let postLoopGateCount = 0;
1037
+ let postLoopGateFailedCount = 0;
1038
+ let memoryInferenceDurationMs = 0;
1039
+ let graphExtractionDurationMs = 0;
1040
+ let orphansPurged;
1041
+ let proposalsExpired;
1042
+ // Concatenated arrays.
1043
+ const allWarnings = [];
1044
+ let deadUrls;
1045
+ const finalActions = [];
1046
+ for (let cycleIndex = 0; cycleIndex < maxCycles; cycleIndex++) {
1047
+ // #616 budget gate: never start a NEW cycle once the run's wall-clock
1048
+ // budget is exhausted (or the run was aborted). Cycle 0 ALWAYS runs so
1049
+ // maxCycles:1 is byte-identical regardless of budget.
1050
+ if (cycleIndex > 0) {
1051
+ const remaining = budgetAbortController.signal.remainingBudgetMs;
1052
+ if (budgetAbortController.signal.aborted || (remaining !== undefined && remaining <= 0)) {
1053
+ break;
1054
+ }
1055
+ }
1056
+ // Re-run ensureIndex + collectEligibleRefs + memory-cleanup recompute for
1057
+ // cycles 2+ (cycle 1 already ran them in the first try above). This makes
1058
+ // cycle N's gate-promoted proposals visible to this cycle's ref selection.
1059
+ if (cycleIndex > 0) {
1060
+ await runIndexAndCollect();
1061
+ // Re-emit the profile-filtered audit summary for this cycle's selection.
1062
+ if (profileFilteredRefs.length > 0) {
1063
+ appendEvent({
1064
+ eventType: "improve_skipped",
1065
+ ref: undefined,
1066
+ metadata: { reason: "profile_filtered_all_passes", count: profileFilteredRefs.length },
1067
+ }, eventsCtx);
1068
+ }
1069
+ }
1070
+ // #607: acquire consolidate.lock for the preparation stage (consolidate,
1071
+ // ensureIndex, extract all write index.db). Released immediately after.
1072
+ const consolidateLPath = processLockPath(lockBaseDir, "consolidate");
1073
+ const consolidatePrepAcquired = tryAcquireProcessLock(consolidateLPath, PROCESS_LOCK_DEFS.consolidate.staleAfterMs, options.skipIfLocked, "consolidate") === "acquired";
1074
+ preparation = await runImprovePreparationStageImpl({
1075
+ scope,
1076
+ options,
1077
+ plannedRefs,
1078
+ memoryCleanupPlan,
1079
+ primaryStashDir,
1080
+ memorySummary,
1081
+ reindexFn,
1082
+ startMs,
1083
+ budgetMs,
1084
+ eventsCtx,
1085
+ initialCleanupWarnings: preEnsureCleanupWarnings,
1086
+ improveProfile,
1087
+ budgetSignal: budgetAbortController.signal,
1088
+ });
1089
+ if (consolidatePrepAcquired)
1090
+ releaseProcessLock(consolidateLPath);
1091
+ prepGateCount += preparation.gateAutoAcceptedCount;
1092
+ prepGateFailedCount += preparation.gateAutoAcceptFailedCount;
1093
+ // D6: pre-load all proposal_rejected events from the last 30 days once,
1094
+ // so the per-asset loop can use a Map lookup instead of N DB round trips.
1095
+ const REJECTED_PROPOSAL_WINDOW_MS = daysToMs(30);
1096
+ const rejectedProposalSince = new Date(Date.now() - REJECTED_PROPOSAL_WINDOW_MS).toISOString();
1097
+ const allRejectedProposalEvents = readEvents({ type: "proposal_rejected", since: rejectedProposalSince }).events;
1098
+ const rejectedProposalsByRef = new Map();
1099
+ for (const e of allRejectedProposalEvents) {
1100
+ if (e.ref && (!rejectedProposalsByRef.has(e.ref) || e.ts > (rejectedProposalsByRef.get(e.ref)?.ts ?? ""))) {
1101
+ rejectedProposalsByRef.set(e.ref, e);
1102
+ }
1103
+ }
1104
+ // #607: acquire reflect-distill.lock for the loop stage (reflect + distill
1105
+ // both write proposals to state.db). Released immediately after.
1106
+ const reflectDistillLPath = processLockPath(lockBaseDir, "reflectDistill");
1107
+ const reflectDistillAcquired = tryAcquireProcessLock(reflectDistillLPath, PROCESS_LOCK_DEFS.reflectDistill.staleAfterMs, options.skipIfLocked, "reflect-distill") === "acquired";
1108
+ const loopResult = await runImproveLoopStageImpl({
1109
+ scope,
1110
+ options,
1111
+ primaryStashDir,
1112
+ reflectFn,
1113
+ distillFn,
1114
+ loopRefs: preparation.loopRefs,
1115
+ actions: preparation.actions,
1116
+ signalBearingSet: preparation.signalBearingSet,
1117
+ distillCooledRefs: preparation.distillCooledRefs,
1118
+ distillOnlyRefs: preparation.distillOnlyRefs,
1119
+ recentErrors: preparation.recentErrors,
1120
+ rejectedProposalsByRef,
1121
+ utilityMap: preparation.utilityMap,
1122
+ startMs,
1123
+ budgetMs,
1124
+ eventsCtx,
1125
+ improveProfile,
1126
+ budgetSignal: budgetAbortController.signal,
1127
+ });
1128
+ if (reflectDistillAcquired)
1129
+ releaseProcessLock(reflectDistillLPath);
1130
+ const loopGateCountThisCycle = loopResult.gateAutoAcceptedCount;
1131
+ reflectsWithErrorContext += loopResult.reflectsWithErrorContext;
1132
+ loopGateCount += loopResult.gateAutoAcceptedCount;
1133
+ loopGateFailedCount += loopResult.gateAutoAcceptFailedCount;
1134
+ memoryRefsForInference = loopResult.memoryRefsForInference;
1135
+ // #551: consolidation now runs in the preparation stage (before extract);
1136
+ // its result and run-flag are read from `preparation`, not the post-loop.
1137
+ consolidation = preparation.consolidation;
1138
+ // #607: acquire consolidate.lock for the post-loop stage (memoryInference +
1139
+ // graphExtraction both write index.db). Released immediately after.
1140
+ const consolidatePostLPath = processLockPath(lockBaseDir, "consolidate");
1141
+ const consolidatePostAcquired = tryAcquireProcessLock(consolidatePostLPath, PROCESS_LOCK_DEFS.consolidate.staleAfterMs, options.skipIfLocked, "consolidate") === "acquired";
1142
+ const postLoopResult = await runImprovePostLoopStageImpl({
1143
+ scope,
1144
+ options,
1145
+ primaryStashDir,
1146
+ actionableRefs: preparation.actionableRefs,
1147
+ appliedCleanup: preparation.appliedCleanup,
1148
+ cleanupWarnings: preparation.cleanupWarnings,
1149
+ memoryRefsForInference,
1150
+ reindexFn,
1151
+ eventsCtx,
1152
+ budgetSignal: budgetAbortController.signal,
1153
+ improveProfile,
1154
+ consolidationRan: preparation.consolidationRan,
1155
+ });
1156
+ if (consolidatePostAcquired)
1157
+ releaseProcessLock(consolidatePostLPath);
1158
+ const postLoopGateCountThisCycle = postLoopResult.gateAutoAcceptedCount;
1159
+ // Last-wins point-in-time objects.
1160
+ memoryInference = postLoopResult.memoryInference;
1161
+ graphExtraction = postLoopResult.graphExtraction;
1162
+ stalenessDetection = postLoopResult.stalenessDetection;
1163
+ recombination = postLoopResult.recombination;
1164
+ proceduralCompilation = postLoopResult.proceduralCompilation;
1165
+ // Summed counters/durations.
1166
+ postLoopGateCount += postLoopResult.gateAutoAcceptedCount;
1167
+ postLoopGateFailedCount += postLoopResult.gateAutoAcceptFailedCount;
1168
+ memoryInferenceDurationMs += postLoopResult.memoryInferenceDurationMs;
1169
+ graphExtractionDurationMs += postLoopResult.graphExtractionDurationMs;
1170
+ if (postLoopResult.orphansPurged !== undefined) {
1171
+ orphansPurged = (orphansPurged ?? 0) + postLoopResult.orphansPurged;
1172
+ }
1173
+ if (postLoopResult.proposalsExpired !== undefined) {
1174
+ proposalsExpired = (proposalsExpired ?? 0) + postLoopResult.proposalsExpired;
1014
1175
  }
1176
+ // Concatenated arrays.
1177
+ allWarnings.push(...postLoopResult.allWarnings);
1178
+ if (postLoopResult.deadUrls !== undefined) {
1179
+ deadUrls = [...(deadUrls ?? []), ...postLoopResult.deadUrls];
1180
+ }
1181
+ const maintenanceActions = postLoopResult.maintenanceActions;
1182
+ if (maintenanceActions && maintenanceActions.length > 0) {
1183
+ finalActions.push(...preparation.actions, ...maintenanceActions);
1184
+ }
1185
+ else {
1186
+ finalActions.push(...preparation.actions);
1187
+ }
1188
+ cyclesRun++;
1189
+ // #616 fixed-point stop: a cycle that produced ZERO gate-accepted proposals
1190
+ // (summed across prep + loop + post-loop) would feed cycle N+1 an identical
1191
+ // ref set, so end the loop here rather than spin a pointless next cycle.
1192
+ const gateAcceptedThisCycle = preparation.gateAutoAcceptedCount + loopGateCountThisCycle + postLoopGateCountThisCycle;
1193
+ if (gateAcceptedThisCycle === 0)
1194
+ break;
1015
1195
  }
1016
- // #607: acquire reflect-distill.lock for the loop stage (reflect + distill
1017
- // both write proposals to state.db). Released immediately after.
1018
- const reflectDistillLPath = processLockPath(lockBaseDir, "reflectDistill");
1019
- const reflectDistillAcquired = tryAcquireProcessLock(reflectDistillLPath, PROCESS_LOCK_DEFS.reflectDistill.staleAfterMs, options.skipIfLocked, "reflect-distill") === "acquired";
1020
- const { reflectsWithErrorContext, memoryRefsForInference, gateAutoAcceptedCount: loopGateCount, gateAutoAcceptFailedCount: loopGateFailedCount, } = await runImproveLoopStage({
1021
- scope,
1022
- options,
1023
- primaryStashDir,
1024
- reflectFn,
1025
- distillFn,
1026
- loopRefs: preparation.loopRefs,
1027
- actions: preparation.actions,
1028
- signalBearingSet: preparation.signalBearingSet,
1029
- distillCooledRefs: preparation.distillCooledRefs,
1030
- distillOnlyRefs: preparation.distillOnlyRefs,
1031
- recentErrors: preparation.recentErrors,
1032
- rejectedProposalsByRef,
1033
- utilityMap: preparation.utilityMap,
1034
- startMs,
1035
- budgetMs,
1036
- eventsCtx,
1037
- improveProfile,
1038
- });
1039
- if (reflectDistillAcquired)
1040
- releaseProcessLock(reflectDistillLPath);
1041
- // #551: consolidation now runs in the preparation stage (before extract);
1042
- // its result and run-flag are read from `preparation`, not the post-loop.
1043
- const consolidation = preparation.consolidation;
1044
- // #607: acquire consolidate.lock for the post-loop stage (memoryInference +
1045
- // graphExtraction both write index.db). Released immediately after.
1046
- const consolidatePostLPath = processLockPath(lockBaseDir, "consolidate");
1047
- const consolidatePostAcquired = tryAcquireProcessLock(consolidatePostLPath, PROCESS_LOCK_DEFS.consolidate.staleAfterMs, options.skipIfLocked, "consolidate") === "acquired";
1048
- const { allWarnings, deadUrls, memoryInference, graphExtraction, stalenessDetection, maintenanceActions, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, gateAutoAcceptedCount: postLoopGateCount, gateAutoAcceptFailedCount: postLoopGateFailedCount, recombination, proceduralCompilation, } = await runImprovePostLoopStage({
1049
- scope,
1050
- options,
1051
- primaryStashDir,
1052
- actionableRefs: preparation.actionableRefs,
1053
- appliedCleanup: preparation.appliedCleanup,
1054
- cleanupWarnings: preparation.cleanupWarnings,
1055
- memoryRefsForInference,
1056
- reindexFn,
1057
- eventsCtx,
1058
- budgetSignal: budgetAbortController.signal,
1059
- improveProfile,
1060
- consolidationRan: preparation.consolidationRan,
1061
- });
1062
- if (consolidatePostAcquired)
1063
- releaseProcessLock(consolidatePostLPath);
1064
- const finalActions = maintenanceActions && maintenanceActions.length > 0
1065
- ? [...preparation.actions, ...maintenanceActions]
1066
- : preparation.actions;
1067
1196
  const result = {
1068
1197
  schemaVersion: 1,
1069
1198
  ok: true,
@@ -1130,11 +1259,11 @@ export async function akmImprove(options = {}) {
1130
1259
  reflectSkippedActions: finalActions.filter((a) => a.mode === "reflect-skipped").length,
1131
1260
  reflectGuardRejectedActions: finalActions.filter((a) => a.mode === "reflect-guard-rejected").length,
1132
1261
  ...(() => {
1133
- const t = preparation.gateAutoAcceptedCount + loopGateCount + postLoopGateCount;
1262
+ const t = prepGateCount + loopGateCount + postLoopGateCount;
1134
1263
  return t > 0 ? { gateAutoAcceptedCount: t } : {};
1135
1264
  })(),
1136
1265
  ...(() => {
1137
- const f = preparation.gateAutoAcceptFailedCount + loopGateFailedCount + postLoopGateFailedCount;
1266
+ const f = prepGateFailedCount + loopGateFailedCount + postLoopGateFailedCount;
1138
1267
  return f > 0 ? { gateAutoAcceptFailedCount: f } : {};
1139
1268
  })(),
1140
1269
  ...(triageDrain
@@ -1148,6 +1277,9 @@ export async function akmImprove(options = {}) {
1148
1277
  }
1149
1278
  : {}),
1150
1279
  ...(preparation.proactiveMaintenance ? { proactiveMaintenance: preparation.proactiveMaintenance } : {}),
1280
+ // #616 — report cycles run only when >1 so the default single-pass
1281
+ // serialized envelope stays byte-identical to pre-#616 (AC1).
1282
+ ...(cyclesRun > 1 ? { cyclesRun } : {}),
1151
1283
  ...(options.runId !== undefined ? { runId: options.runId } : {}),
1152
1284
  };
1153
1285
  if (!result.dryRun)
@@ -3900,6 +4032,9 @@ export async function runImproveMaintenancePasses(args) {
3900
4032
  const graphEnabled = isProcessEnabled("index", "graph_extraction", config);
3901
4033
  const graphExtractionDisabledByProfile = improveProfile?.processes?.graphExtraction?.enabled === false;
3902
4034
  const graphExtractionFullScan = improveProfile?.processes?.graphExtraction?.fullScan === true;
4035
+ // #624 P2: optional incremental high-signal-first cap. Unset = process all
4036
+ // eligible (byte-identical to today; no ranking/slice).
4037
+ const graphExtractionTopN = improveProfile?.processes?.graphExtraction?.topN;
3903
4038
  // Build the set of refs actually touched this run.
3904
4039
  const touchedRefs = new Set();
3905
4040
  for (const r of args.actionableRefs)
@@ -3961,7 +4096,7 @@ export async function runImproveMaintenancePasses(args) {
3961
4096
  db,
3962
4097
  reEnrich: false,
3963
4098
  onProgress: progressHandler,
3964
- options: { candidatePaths },
4099
+ options: { candidatePaths, ...(graphExtractionTopN != null ? { topN: graphExtractionTopN } : {}) },
3965
4100
  }));
3966
4101
  graphExtractionDurationMs = Date.now() - extractionStart;
3967
4102
  actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
@@ -0,0 +1,39 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { BaseLinter } from "./base-linter.js";
5
+ /** Recommended `category` values for facts (see docs/design/fact-asset-type.md). */
6
+ const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
7
+ /**
8
+ * Linter for `facts/` assets.
9
+ *
10
+ * Extra check beyond base:
11
+ * - `missing-category`: a fact without a non-empty `category` frontmatter
12
+ * key. Category scopes the fact (personal/team/project/convention/meta)
13
+ * and drives how it is surfaced/injected, so it is expected. Reported as a
14
+ * non-fixable warning — we cannot infer the right scope automatically.
15
+ */
16
+ export class FactLinter extends BaseLinter {
17
+ types = ["facts"];
18
+ lint(ctx) {
19
+ const issues = this.runBaseChecks(ctx);
20
+ const category = typeof ctx.data.category === "string" ? ctx.data.category.trim() : "";
21
+ if (!category) {
22
+ issues.push({
23
+ file: ctx.relPath,
24
+ issue: "missing-category",
25
+ detail: "fact is missing a `category` (personal|team|project|convention|meta)",
26
+ fixed: false,
27
+ });
28
+ }
29
+ else if (!KNOWN_CATEGORIES.has(category)) {
30
+ issues.push({
31
+ file: ctx.relPath,
32
+ issue: "missing-category",
33
+ detail: `unrecognized category "${category}" (expected one of: ${[...KNOWN_CATEGORIES].join(", ")})`,
34
+ fixed: false,
35
+ });
36
+ }
37
+ return issues;
38
+ }
39
+ }
@@ -20,6 +20,7 @@ const STASH_SUBDIRS = [
20
20
  "lessons",
21
21
  "tasks",
22
22
  "knowledge",
23
+ "facts",
23
24
  ];
24
25
  // ── Helpers ───────────────────────────────────────────────────────────────────
25
26
  function collectYamlFiles(dir) {
@@ -4,6 +4,7 @@
4
4
  import { AgentLinter } from "./agent-linter.js";
5
5
  import { CommandLinter } from "./command-linter.js";
6
6
  import { DefaultLinter } from "./default-linter.js";
7
+ import { FactLinter } from "./fact-linter.js";
7
8
  import { KnowledgeLinter } from "./knowledge-linter.js";
8
9
  import { MemoryLinter } from "./memory-linter.js";
9
10
  import { SkillLinter } from "./skill-linter.js";
@@ -18,6 +19,7 @@ const LINTERS = [
18
19
  new KnowledgeLinter(),
19
20
  new SkillLinter(),
20
21
  new TaskLinter(),
22
+ new FactLinter(),
21
23
  ];
22
24
  // Single shared DefaultLinter instance — used both as the explicit "lessons"
23
25
  // handler and as the fallback for any unrecognised asset type.
@@ -13,12 +13,18 @@
13
13
  * helpers (`curateSearchResults`, `deriveCurateFallbackQueries`,
14
14
  * `mergeCurateSearchResponses`) by importing them directly.
15
15
  */
16
+ import fs from "node:fs";
16
17
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
18
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
19
+ import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
17
20
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
18
21
  import { appendEvent } from "../../core/events.js";
19
- import { closeDatabase, openExistingDatabase } from "../../indexer/db/db.js";
22
+ import { closeDatabase, computeBodyHash, openExistingDatabase } from "../../indexer/db/db.js";
23
+ import { enqueueGraphExtraction, hasGraphData } from "../../indexer/db/graph-db.js";
24
+ import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
20
25
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
21
26
  import { truncateDescription } from "../../output/shapes.js";
27
+ import { withIndexDb } from "../../storage/repositories/index-db.js";
22
28
  import { akmSearch, parseSearchSource } from "./search.js";
23
29
  import { akmShowUnified } from "./show.js";
24
30
  const CURATE_FALLBACK_FILTER_WORDS = new Set([
@@ -145,6 +151,11 @@ async function enrichCuratedStashHit(query, hit, supportRefs, selectedRefs) {
145
151
  catch {
146
152
  shown = undefined;
147
153
  }
154
+ // #624-P3: when lazy graph extraction is opted in, enqueue an ungraphed
155
+ // asset for a later pass to extract. Fire-and-forget, non-blocking, NO inline
156
+ // extraction and NO LLM call here. Default-off (flag unset) = byte-identical.
157
+ if (shown?.path)
158
+ maybeEnqueueLazyGraph(shown.path);
148
159
  const description = shown?.description ?? hit.description;
149
160
  const preview = buildCuratedPreview(shown, hit);
150
161
  const mergedSupportRefs = mergeCurateSupportRefs(supportRefs, shown?.related?.hits, selectedRefs, hit.ref);
@@ -164,6 +175,44 @@ async function enrichCuratedStashHit(query, hit, supportRefs, selectedRefs) {
164
175
  ...(hit.score !== undefined ? { score: hit.score } : {}),
165
176
  };
166
177
  }
178
+ /**
179
+ * #624-P3 — enqueue an ungraphed asset for lazy graph extraction when the
180
+ * `index.graph.lazyGraphExtraction` flag is on. Pure side-effect, fully
181
+ * best-effort: any failure (config, fs, db) is swallowed so curate never fails
182
+ * on it. NO LLM call and NO inline extraction — only a cheap queue insert.
183
+ * Default-off (flag unset) returns immediately = byte-identical behavior.
184
+ */
185
+ function maybeEnqueueLazyGraph(assetPath) {
186
+ try {
187
+ const config = loadConfig();
188
+ if (getIndexPassConfig(config.index, "graph")?.lazyGraphExtraction !== true)
189
+ return;
190
+ const sources = resolveSourceEntries();
191
+ const source = findSourceForPath(assetPath, sources);
192
+ const stashRoot = source?.path;
193
+ if (!stashRoot)
194
+ return;
195
+ let raw;
196
+ try {
197
+ raw = fs.readFileSync(assetPath, "utf8");
198
+ }
199
+ catch {
200
+ return;
201
+ }
202
+ const body = parseFrontmatter(raw).content.trim();
203
+ if (!body)
204
+ return;
205
+ const bodyHash = computeBodyHash(body);
206
+ withIndexDb((db) => {
207
+ if (!hasGraphData(db, stashRoot, assetPath)) {
208
+ enqueueGraphExtraction(db, stashRoot, assetPath, bodyHash, 0);
209
+ }
210
+ });
211
+ }
212
+ catch (err) {
213
+ rethrowIfTestIsolationError(err);
214
+ }
215
+ }
167
216
  function buildCuratedRegistryItem(query, hit) {
168
217
  return {
169
218
  source: "registry",