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

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 (55) hide show
  1. package/CHANGELOG.md +12 -4
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/help/help-improve.md +3 -0
  8. package/dist/cli.js +25 -10
  9. package/dist/commands/health/html-report.js +23 -2
  10. package/dist/commands/health/improve-metrics.js +45 -6
  11. package/dist/commands/health/md-report.js +10 -1
  12. package/dist/commands/health/windows.js +1 -1
  13. package/dist/commands/health.js +1 -1
  14. package/dist/commands/improve/distill/promote-memory.js +6 -2
  15. package/dist/commands/improve/distill/quality-gate.js +94 -31
  16. package/dist/commands/improve/distill.js +7 -2
  17. package/dist/commands/improve/extract.js +5 -2
  18. package/dist/commands/improve/improve-auto-accept.js +2 -2
  19. package/dist/commands/improve/improve.js +158 -176
  20. package/dist/commands/improve/locks.js +22 -94
  21. package/dist/commands/improve/loop-stages.js +15 -10
  22. package/dist/commands/improve/preparation.js +21 -17
  23. package/dist/commands/improve/proactive-maintenance.js +4 -6
  24. package/dist/commands/improve/reflect.js +40 -67
  25. package/dist/commands/tasks/default-tasks.js +5 -5
  26. package/dist/commands/tasks/tasks-cli.js +7 -3
  27. package/dist/commands/tasks/tasks.js +261 -68
  28. package/dist/core/extra-params.js +1 -0
  29. package/dist/core/improve-result.js +87 -7
  30. package/dist/core/maintenance-barrier.js +16 -0
  31. package/dist/core/state-db.js +1 -0
  32. package/dist/core/write-source.js +1 -0
  33. package/dist/indexer/ensure-index.js +6 -4
  34. package/dist/llm/client.js +7 -5
  35. package/dist/schemas/akm-task.json +2 -2
  36. package/dist/schemas/akm-workflow.json +2 -2
  37. package/dist/scripts/migrate-storage.js +23 -4
  38. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +91 -9
  39. package/dist/storage/repositories/task-history-repository.js +30 -3
  40. package/dist/tasks/backends/cron.js +71 -38
  41. package/dist/tasks/backends/exec-utils.js +55 -3
  42. package/dist/tasks/backends/launchd.js +241 -50
  43. package/dist/tasks/backends/schtasks.js +434 -59
  44. package/dist/tasks/command-executable.js +93 -0
  45. package/dist/tasks/parser.js +142 -6
  46. package/dist/tasks/resolve-akm-bin.js +19 -4
  47. package/dist/tasks/runner.js +258 -93
  48. package/dist/tasks/schedule.js +108 -19
  49. package/dist/tasks/scheduler-invocation.js +86 -0
  50. package/dist/tasks/task-id.js +37 -0
  51. package/docs/migration/release-notes/0.9.0.md +4 -2
  52. package/docs/migration/v0.8-to-v0.9.md +64 -9
  53. package/package.json +2 -3
  54. package/schemas/akm-task.json +2 -2
  55. package/schemas/akm-workflow.json +2 -2
@@ -6,37 +6,22 @@ import path from "node:path";
6
6
  import { ConfigError } from "../../core/errors.js";
7
7
  import { appendEvent } from "../../core/events.js";
8
8
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../../core/file-lock.js";
9
- import { withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
9
+ import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
10
10
  import { warn } from "../../core/warn.js";
11
- // #607 Lock Decomposition: fine-grained per-process locks replace the single
12
- // `improve.lock`. Three independent locks allow concurrent improve runs when
13
- // they touch different subsystems (e.g. quick-shredder consolidate can run
14
- // alongside daily reflect+distill).
15
- //
16
- // consolidate.lock — protects consolidate + memoryInference (both write index.db)
17
- // reflect-distill.lock — protects reflect + distill (both write state.db proposals)
18
- // triage.lock — protects triage (writes proposal promotions)
19
- //
20
- // Stale timeouts are per-lock, tuned to the expected runtime of the protected
21
- // processes: consolidate is disk-bound (1h), reflect+distill is GPU-bound (2h),
22
- // triage is fast (30min).
23
- export const PROCESS_LOCK_DEFS = {
24
- consolidate: { fileName: "consolidate.lock", staleAfterMs: 60 * 60 * 1000 },
25
- reflectDistill: { fileName: "reflect-distill.lock", staleAfterMs: 2 * 60 * 60 * 1000 },
26
- triage: { fileName: "triage.lock", staleAfterMs: 30 * 60 * 1000 },
27
- };
28
- const heldProcessLocks = new Set();
29
- export function resetHeldProcessLocks() {
30
- heldProcessLocks.clear();
11
+ export const MIN_IMPROVE_LOCK_STALE_MS = 4 * 60 * 60 * 1000;
12
+ export function improveLockPath(lockBaseDir) {
13
+ return path.join(lockBaseDir, "improve.lock");
31
14
  }
32
- export function processLockPath(lockBaseDir, lockName) {
33
- return path.join(lockBaseDir, PROCESS_LOCK_DEFS[lockName].fileName);
34
- }
35
- export function tryAcquireProcessLock(lockPath, staleAfterMs, skipIfLocked, lockLabel) {
15
+ export function tryAcquireImproveLock(lockPath, staleAfterMs, skipIfLocked) {
36
16
  let recoveryEvent;
37
- const result = withMaintenanceStartBarrier(() => tryAcquireProcessLockUnlocked(lockPath, staleAfterMs, skipIfLocked, lockLabel, (event) => {
17
+ const acquire = () => tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, (event) => {
38
18
  recoveryEvent = event;
39
- }));
19
+ });
20
+ const result = skipIfLocked ? tryWithMaintenanceStartBarrier(acquire) : withMaintenanceStartBarrier(acquire);
21
+ if (!result) {
22
+ warn("[improve] maintenance barrier held; skipping (--skip-if-locked)");
23
+ return { state: "skipped" };
24
+ }
40
25
  if (recoveryEvent) {
41
26
  try {
42
27
  appendEvent(recoveryEvent);
@@ -47,12 +32,11 @@ export function tryAcquireProcessLock(lockPath, staleAfterMs, skipIfLocked, lock
47
32
  }
48
33
  return result;
49
34
  }
50
- function tryAcquireProcessLockUnlocked(lockPath, staleAfterMs, skipIfLocked, lockLabel, onRecovered) {
35
+ function tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, onRecovered) {
51
36
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
52
37
  const lockPayload = () => createLockPayload({ startedAt: new Date().toISOString() });
53
38
  let ownership = tryAcquireLockSync(lockPath, lockPayload());
54
39
  if (ownership) {
55
- heldProcessLocks.add(ownership);
56
40
  return { state: "acquired", ownership };
57
41
  }
58
42
  const probe = probeLock(lockPath, { staleAfterMs });
@@ -64,7 +48,6 @@ function tryAcquireProcessLockUnlocked(lockPath, staleAfterMs, skipIfLocked, loc
64
48
  if (probe.state === "absent") {
65
49
  ownership = tryAcquireLockSync(lockPath, lockPayload());
66
50
  if (ownership) {
67
- heldProcessLocks.add(ownership);
68
51
  return { state: "acquired", ownership };
69
52
  }
70
53
  // Re-grabbed by another racer in the window — fall through and treat as held.
@@ -83,15 +66,15 @@ function tryAcquireProcessLockUnlocked(lockPath, staleAfterMs, skipIfLocked, loc
83
66
  if (probe.state === "stale") {
84
67
  if (!reclaimStaleLock(lockPath, probe)) {
85
68
  if (skipIfLocked) {
86
- warn(`[improve] ${lockLabel} lock changed ownership during stale recovery; skipping (--skip-if-locked)`);
69
+ warn("[improve] lock changed ownership during stale recovery; skipping (--skip-if-locked)");
87
70
  return { state: "skipped" };
88
71
  }
89
- throw new ConfigError(`akm improve ${lockLabel} is already running. Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
72
+ throw new ConfigError(`akm improve is already running. Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
90
73
  }
91
74
  onRecovered({
92
75
  eventType: "improve_lock_recovered",
93
76
  metadata: {
94
- lockName: lockLabel,
77
+ lockName: "improve",
95
78
  stalePid: lock?.pid ?? null,
96
79
  lockedAt: lock?.startedAt ?? null,
97
80
  recoveredAt: new Date().toISOString(),
@@ -101,75 +84,20 @@ function tryAcquireProcessLockUnlocked(lockPath, staleAfterMs, skipIfLocked, loc
101
84
  });
102
85
  ownership = tryAcquireLockSync(lockPath, lockPayload());
103
86
  if (ownership) {
104
- heldProcessLocks.add(ownership);
105
87
  return { state: "acquired", ownership };
106
88
  }
107
89
  if (skipIfLocked) {
108
- warn(`[improve] ${lockLabel} lock acquired by another run during stale recovery; skipping (--skip-if-locked)`);
90
+ warn("[improve] lock acquired by another run during stale recovery; skipping (--skip-if-locked)");
109
91
  return { state: "skipped" };
110
92
  }
111
- throw new ConfigError(`akm improve ${lockLabel} is already running. Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
93
+ throw new ConfigError(`akm improve is already running. Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
112
94
  }
113
95
  if (skipIfLocked) {
114
- warn(`[improve] ${lockLabel} lock held by another run (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
96
+ warn(`[improve] another improve run holds the lock (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
115
97
  return { state: "skipped" };
116
98
  }
117
- throw new ConfigError(`akm improve ${lockLabel} is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
118
- }
119
- export function releaseProcessLock(ownership) {
120
- if (!heldProcessLocks.has(ownership))
121
- return;
122
- try {
123
- releaseLock(ownership);
124
- }
125
- finally {
126
- heldProcessLocks.delete(ownership);
127
- }
128
- }
129
- export function releaseAllProcessLocks() {
130
- for (const ownership of heldProcessLocks) {
131
- try {
132
- releaseLock(ownership);
133
- }
134
- catch {
135
- // ignore
136
- }
137
- }
138
- heldProcessLocks.clear();
99
+ throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
139
100
  }
140
- /**
141
- * Ownership-safe release of every currently-held lock, for the `process.on("exit")`
142
- * backstop (signal handler / budget watchdog paths that skip the normal finally).
143
- * Uses exact ownership handles so a lock legitimately re-acquired after stale
144
- * recovery is never deleted. Does NOT clear the Set (the process is exiting).
145
- */
146
- export function releaseHeldLocksIfOwned() {
147
- for (const ownership of heldProcessLocks) {
148
- releaseLock(ownership);
149
- }
150
- }
151
- /**
152
- * RAII for the "best-effort stage" lock pattern: acquire the lock if available,
153
- * run `body` REGARDLESS of acquisition, and release the lock in a `finally` iff
154
- * we acquired it (on both the normal and the throw path). This makes
155
- * release-on-throw LOCAL instead of relying on a distant outer catch.
156
- *
157
- * Behaviour matches the hand-rolled `acquired = tryAcquire(...) === "acquired";
158
- * …run stage…; if (acquired) release` idiom it replaces:
159
- * - When the lock is held and `skipIfLocked` is set, `tryAcquireProcessLock`
160
- * returns `state: "skipped"` → the stage still runs (unlocked), nothing to release.
161
- * - When the lock is held and `skipIfLocked` is NOT set, `tryAcquireProcessLock`
162
- * throws (propagated here before `body` runs; nothing acquired, nothing released).
163
- * - The process-exit backstop (`releaseHeldLocksIfOwned`) still covers a
164
- * `process.exit` that skips this `finally`.
165
- */
166
- export async function withOptionalProcessLock(opts, body) {
167
- const acquisition = tryAcquireProcessLock(opts.lockPath, opts.staleAfterMs, opts.skipIfLocked, opts.label);
168
- try {
169
- return await body();
170
- }
171
- finally {
172
- if (acquisition.state === "acquired")
173
- releaseProcessLock(acquisition.ownership);
174
- }
101
+ export function releaseImproveLock(ownership) {
102
+ releaseLock(ownership);
175
103
  }
@@ -42,7 +42,7 @@ import { durableImproveRef } from "./source-identity.js";
42
42
  // ── improve loop / post-loop / maintenance stages ───────────────────
43
43
  // The cycle stages run by akmImprove, extracted from improve.ts.
44
44
  export async function runImproveLoopStage(args) {
45
- const { scope, options, primaryStashDir, reflectFn, distillFn, loopRefs, actions, signalBearingSet, distillCooledRefs, distillOnlyRefs, recentErrors, rejectedProposalsByRef, utilityMap, startMs, budgetMs, eventsCtx, improveProfile, resolvedPlan, } = args;
45
+ const { scope, options, primaryStashDir, reflectFn, distillFn, loopRefs, actions, signalBearingSet, distillCooledRefs, distillOnlyRefs, recentErrors, rejectedProposalsByRef, utilityMap, startMs, budgetMs, eventsCtx, improveProfile, resolvedPlan, budgetSignal, } = args;
46
46
  // O-1 (#364): compute remaining budget at call time so each sub-call
47
47
  // receives only its fair share of the wall-clock budget.
48
48
  const remainingBudgetMs = () => Math.max(0, budgetMs - (Date.now() - startMs));
@@ -136,6 +136,7 @@ export async function runImproveLoopStage(args) {
136
136
  stashDir: primaryStashDir,
137
137
  config: options.config ?? loadConfig(),
138
138
  eventsCtx,
139
+ targetSelector: options.target,
139
140
  stateDbPath: eventsCtx?.dbPath,
140
141
  // candidateCount drives the exploration budget. loopRefs is the per-phase
141
142
  // set for reflect/distill; pass it so exploration budget is proportional.
@@ -147,6 +148,7 @@ export async function runImproveLoopStage(args) {
147
148
  stashDir: primaryStashDir,
148
149
  config: options.config ?? loadConfig(),
149
150
  eventsCtx,
151
+ targetSelector: options.target,
150
152
  stateDbPath: eventsCtx?.dbPath,
151
153
  candidateCount: loopRefs.length,
152
154
  });
@@ -211,7 +213,7 @@ export async function runImproveLoopStage(args) {
211
213
  const reflectProfileRunner = resolvedPlan.processes.reflect.runner;
212
214
  const reflectCallArgs = {
213
215
  ref: planned.ref,
214
- ...(options.target ? { sourceName: options.target } : {}),
216
+ ...(options.sourceName ? { sourceName: options.sourceName } : {}),
215
217
  ...(options.legacyBareState ? { legacyBareState: true } : {}),
216
218
  task: options.task,
217
219
  // Active strategy supplies non-engine process tuning.
@@ -224,6 +226,7 @@ export async function runImproveLoopStage(args) {
224
226
  // (default off when unset), so the running strategy decides.
225
227
  lowValueFilter: improveProfile.processes?.reflect?.lowValueFilter?.enabled === true,
226
228
  ...(reflectBudgetMs > 0 ? { timeoutMs: reflectBudgetMs } : {}),
229
+ signal: budgetSignal,
227
230
  runner: reflectProfileRunner ?? null,
228
231
  // Attribution: carry the eligibility lane so reflect stamps it on
229
232
  // the reflect_invoked event and the persisted proposal.
@@ -350,7 +353,7 @@ export async function runImproveLoopStage(args) {
350
353
  // the asset changed; reset the counter so the dampener lifts.
351
354
  if (isNoChange && eventsCtx?.db) {
352
355
  try {
353
- recordNoOp(eventsCtx.db, durableImproveRef(planned.ref, options.target));
356
+ recordNoOp(eventsCtx.db, durableImproveRef(planned.ref, options.sourceName));
354
357
  }
355
358
  catch {
356
359
  // best-effort: plasticity counter failure never blocks the run
@@ -358,7 +361,7 @@ export async function runImproveLoopStage(args) {
358
361
  }
359
362
  else if (reflectResult.ok && eventsCtx?.db) {
360
363
  try {
361
- resetConsecutiveNoOps(eventsCtx.db, durableImproveRef(planned.ref, options.target));
364
+ resetConsecutiveNoOps(eventsCtx.db, durableImproveRef(planned.ref, options.sourceName));
362
365
  }
363
366
  catch {
364
367
  // best-effort
@@ -485,7 +488,7 @@ export async function runImproveLoopStage(args) {
485
488
  }
486
489
  const distillResult = await withLlmStage("distill", () => distillFn({
487
490
  ref: planned.ref,
488
- ...(options.target ? { sourceName: options.target } : {}),
491
+ ...(options.sourceName ? { sourceName: options.sourceName } : {}),
489
492
  ...(options.legacyBareState ? { legacyBareState: true } : {}),
490
493
  ...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
491
494
  ...(primaryStashDir ? { stashDir: primaryStashDir } : {}),
@@ -495,6 +498,7 @@ export async function runImproveLoopStage(args) {
495
498
  llmConfig: resolvedPlan.processes.distill.runner
496
499
  ? materializeLlmRunnerConnection(resolvedPlan.processes.distill.runner)
497
500
  : null,
501
+ signal: budgetSignal,
498
502
  // Attribution: carry the eligibility lane so distill stamps it on the
499
503
  // distill_invoked event and the persisted proposal.
500
504
  ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
@@ -517,10 +521,10 @@ export async function runImproveLoopStage(args) {
517
521
  if (eventsCtx?.db) {
518
522
  try {
519
523
  if (distillResult.outcome === "quality_rejected" || distillResult.outcome === "skipped") {
520
- recordNoOp(eventsCtx.db, durableImproveRef(planned.ref, options.target));
524
+ recordNoOp(eventsCtx.db, durableImproveRef(planned.ref, options.sourceName));
521
525
  }
522
526
  else if (distillResult.outcome === "queued") {
523
- resetConsecutiveNoOps(eventsCtx.db, durableImproveRef(planned.ref, options.target));
527
+ resetConsecutiveNoOps(eventsCtx.db, durableImproveRef(planned.ref, options.sourceName));
524
528
  }
525
529
  }
526
530
  catch {
@@ -652,8 +656,7 @@ export async function runImprovePostLoopStage(args) {
652
656
  }
653
657
  }
654
658
  // #609 — recombine / synthesize pass. Whole-corpus cross-episodic
655
- // generalization. Runs in the post-loop stage under consolidate.lock (it
656
- // reads the consolidated corpus and writes proposals). Opt-in: gated on the
659
+ // generalization. Runs in the post-loop stage under the whole-run lock. Opt-in: gated on the
657
660
  // `recombine` process being enabled, whole-stash / type scope (never `ref`),
658
661
  // and not a dry run. Mirrors the proactiveMaintenance opt-in wiring.
659
662
  let recombination;
@@ -773,6 +776,8 @@ export async function runImproveMaintenancePasses(args) {
773
776
  const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, resolvedPlan, } = args;
774
777
  if (!primaryStashDir)
775
778
  return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
779
+ if (budgetSignal?.aborted)
780
+ return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
776
781
  const config = options.config ?? loadConfig();
777
782
  const sources = resolveSourceEntries(options.stashDir, config);
778
783
  const memoryInferenceFn = options.memoryInferenceFn ?? runMemoryInferencePass;
@@ -799,7 +804,7 @@ export async function runImproveMaintenancePasses(args) {
799
804
  db = undefined;
800
805
  }
801
806
  try {
802
- await reindexFn({ stashDir });
807
+ await reindexFn({ stashDir, signal: budgetSignal });
803
808
  }
804
809
  finally {
805
810
  db = openIndexDb();
@@ -283,6 +283,7 @@ export async function runConsolidationPass(args) {
283
283
  stashDir: primaryStashDir,
284
284
  config: consolidationConfig,
285
285
  eventsCtx,
286
+ targetSelector: options.target,
286
287
  stateDbPath: eventsCtx?.dbPath,
287
288
  }, { minimumThreshold: 95 });
288
289
  if (consolidateDisabledByProfile) {
@@ -413,7 +414,7 @@ export async function runConsolidationPass(args) {
413
414
  * consolidation pass and accumulated here.
414
415
  */
415
416
  async function runSessionExtractPass(args) {
416
- const { options, primaryStashDir, improveProfile, resolvedPlan, eventsCtx, seedGateAccepted, seedGateFailed } = args;
417
+ const { options, primaryStashDir, improveProfile, resolvedPlan, eventsCtx, budgetSignal, seedGateAccepted, seedGateFailed, } = args;
417
418
  const warnings = [];
418
419
  // Phase 0.4 — session-extract pass.
419
420
  //
@@ -446,6 +447,7 @@ async function runSessionExtractPass(args) {
446
447
  stashDir: primaryStashDir,
447
448
  config: extractConfig,
448
449
  eventsCtx,
450
+ targetSelector: options.target,
449
451
  stateDbPath: eventsCtx?.dbPath,
450
452
  });
451
453
  // #554 minNewSessions gate: skip the entire extract pass (ensureIndex was
@@ -525,6 +527,7 @@ async function runSessionExtractPass(args) {
525
527
  config: extractConfig,
526
528
  resolvedPlan: extractPlan,
527
529
  dryRun: options.dryRun ?? false,
530
+ signal: budgetSignal,
528
531
  ...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
529
532
  // C2: pin extract's skip-tracking state.db open to the boundary path.
530
533
  ...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
@@ -699,6 +702,7 @@ export async function runImprovePreparationStage(args) {
699
702
  improveProfile,
700
703
  resolvedPlan,
701
704
  eventsCtx,
705
+ budgetSignal,
702
706
  seedGateAccepted: consolidationPass.gateAutoAcceptedCount,
703
707
  seedGateFailed: consolidationPass.gateAutoAcceptFailedCount,
704
708
  });
@@ -746,7 +750,7 @@ export async function runImprovePreparationStage(args) {
746
750
  }
747
751
  if ((appliedCleanup.archived.length > 0 || appliedCleanup.beliefStateTransitions.length > 0) && primaryStashDir) {
748
752
  try {
749
- await reindexFn({ stashDir: primaryStashDir });
753
+ await reindexFn({ stashDir: primaryStashDir, signal: budgetSignal });
750
754
  }
751
755
  catch (err) {
752
756
  cleanupWarnings.push(`reindex after cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -826,9 +830,9 @@ export async function runImprovePreparationStage(args) {
826
830
  // Per-ref queries would be N+1 and the planner is already the hottest path
827
831
  // in `akm improve`.
828
832
  const candidateRefs = postCleanupRefs.filter((r) => !validationFailureRefs.has(r.ref)).map((r) => r.ref);
829
- const latestFeedbackTs = buildLatestFeedbackTsMap(candidateRefs, feedbackSinceCutoff, options.target, options.legacyBareState);
830
- const lastReflectProposalTs = buildLatestProposalTsMap(candidateRefs, "reflect", options.target, options.legacyBareState);
831
- const lastDistillProposalTs = buildLatestProposalTsMap(candidateRefs, "distill", options.target, options.legacyBareState);
833
+ const latestFeedbackTs = buildLatestFeedbackTsMap(candidateRefs, feedbackSinceCutoff, options.sourceName, options.legacyBareState);
834
+ const lastReflectProposalTs = buildLatestProposalTsMap(candidateRefs, "reflect", options.sourceName, options.legacyBareState);
835
+ const lastDistillProposalTs = buildLatestProposalTsMap(candidateRefs, "distill", options.sourceName, options.legacyBareState);
832
836
  // Refs the distill signal-delta gate rejected at planning time. The main
833
837
  // loop reads this to skip distill for these refs without re-checking
834
838
  // eligibility per iteration.
@@ -965,7 +969,7 @@ export async function runImprovePreparationStage(args) {
965
969
  {
966
970
  const feedbackCandidateRefs = [...processableRefs, ...noFeedbackPool].map((r) => r.ref);
967
971
  const feedbackCandidateSet = new Set(feedbackCandidateRefs);
968
- const feedbackRefByDurableKey = new Map(feedbackCandidateRefs.flatMap((ref) => improveStateReadRefs(ref, options.target, options.legacyBareState).map((key) => [key, ref])));
972
+ const feedbackRefByDurableKey = new Map(feedbackCandidateRefs.flatMap((ref) => improveStateReadRefs(ref, options.sourceName, options.legacyBareState).map((key) => [key, ref])));
969
973
  if (feedbackCandidateSet.size > 0) {
970
974
  // Fetch ALL feedback events in one query (no ref filter, no since filter =
971
975
  // single full table scan). Filtering per-ref in memory avoids N sequential
@@ -1036,9 +1040,9 @@ export async function runImprovePreparationStage(args) {
1036
1040
  // Fix (WS-1 blocker 3): union the feedback pool into the lookup.
1037
1041
  const allCandidateRefs = [...new Set([...signalFiltered, ...noFeedbackCandidates].map((r) => r.ref))];
1038
1042
  retrievalCounts = getRetrievalCounts(dbForRetrieval, allCandidateRefs, {
1039
- sourceName: options.target,
1043
+ sourceName: options.sourceName,
1040
1044
  stashDir: primaryStashDir,
1041
- includeLegacyBare: options.legacyBareState || !options.target,
1045
+ includeLegacyBare: options.legacyBareState,
1042
1046
  });
1043
1047
  lastUseMsForProactive = getLastUseMsByRef(dbForRetrieval, noFeedbackCandidates.map((r) => r.ref), primaryStashDir);
1044
1048
  // High-retrieval signal-delta (simplified rule, 0.8.0): a no-feedback
@@ -1178,7 +1182,7 @@ export async function runImprovePreparationStage(args) {
1178
1182
  // found later in the scan lost its slot to an earlier lower-scoring one.
1179
1183
  const qualifying = [];
1180
1184
  for (const r of candidates) {
1181
- const row = readAssetSalienceForImproveRef(dbForHighSalience, r.ref, options.target, options.legacyBareState);
1185
+ const row = readAssetSalienceForImproveRef(dbForHighSalience, r.ref, options.sourceName, options.legacyBareState);
1182
1186
  if (row &&
1183
1187
  isContentEncodingRow(row, parseAssetRef(r.ref).type) &&
1184
1188
  row.encoding_salience >= salienceThreshold &&
@@ -1457,7 +1461,7 @@ export async function runImprovePreparationStage(args) {
1457
1461
  withStateDb((dbForStoredEncoding) => {
1458
1462
  for (const r of mergedRefs) {
1459
1463
  const type = r.ref.includes(":") ? r.ref.slice(0, r.ref.indexOf(":")) : "";
1460
- const row = readAssetSalienceForImproveRef(dbForStoredEncoding, r.ref, options.target, options.legacyBareState);
1464
+ const row = readAssetSalienceForImproveRef(dbForStoredEncoding, r.ref, options.sourceName, options.legacyBareState);
1461
1465
  if (row && isContentEncodingRow(row, type)) {
1462
1466
  storedEncodingByRef.set(r.ref, row.encoding_salience);
1463
1467
  }
@@ -1551,10 +1555,10 @@ export async function runImprovePreparationStage(args) {
1551
1555
  //
1552
1556
  // Load ALL existing rows so rank positions are stash-relative, not pool-relative.
1553
1557
  const allStoredScores = getAllRankScores(stateDb);
1554
- const existingAllScores = options.target
1558
+ const existingAllScores = options.sourceName
1555
1559
  ? new Map([...allStoredScores].flatMap(([ref, score]) => {
1556
1560
  const parsed = parseAssetRef(ref);
1557
- return parsed.origin === options.target || (options.legacyBareState && !parsed.origin)
1561
+ return parsed.origin === options.sourceName || (options.legacyBareState && !parsed.origin)
1558
1562
  ? [[bareImproveRef(ref), score]]
1559
1563
  : [];
1560
1564
  }))
@@ -1655,7 +1659,7 @@ export async function runImprovePreparationStage(args) {
1655
1659
  }, eventsCtx);
1656
1660
  }
1657
1661
  for (const [ref, vector] of salienceMap) {
1658
- upsertAssetSalience(stateDb, durableImproveRef(ref, options.target), vector, nowForSalience);
1662
+ upsertAssetSalience(stateDb, durableImproveRef(ref, options.sourceName), vector, nowForSalience);
1659
1663
  }
1660
1664
  }, { path: eventsCtx?.dbPath });
1661
1665
  }
@@ -1742,10 +1746,10 @@ export async function runImprovePreparationStage(args) {
1742
1746
  withStateDb((replayDb) => {
1743
1747
  const alreadyInPool = new Set(mergedRefs.map((r) => r.ref));
1744
1748
  const storedRankScores = getAllRankScores(replayDb);
1745
- const allRankScores = options.target
1749
+ const allRankScores = options.sourceName
1746
1750
  ? new Map([...storedRankScores].flatMap(([ref, score]) => {
1747
1751
  const parsed = parseAssetRef(ref);
1748
- return parsed.origin === options.target || (options.legacyBareState && !parsed.origin)
1752
+ return parsed.origin === options.sourceName || (options.legacyBareState && !parsed.origin)
1749
1753
  ? [[bareImproveRef(ref), score]]
1750
1754
  : [];
1751
1755
  }))
@@ -1759,7 +1763,7 @@ export async function runImprovePreparationStage(args) {
1759
1763
  for (const [ref, rankScore] of allRankScores) {
1760
1764
  if (alreadyInPool.has(ref))
1761
1765
  continue;
1762
- const noOps = readConsecutiveNoOpsForImproveRef(replayDb, ref, options.target, options.legacyBareState);
1766
+ const noOps = readConsecutiveNoOpsForImproveRef(replayDb, ref, options.sourceName, options.legacyBareState);
1763
1767
  if (noOps >= SALIENCE_NO_OP_DAMPEN_THRESHOLD) {
1764
1768
  convergedSkipped++;
1765
1769
  continue;
@@ -1836,7 +1840,7 @@ export async function runImprovePreparationStage(args) {
1836
1840
  const ownsNoOpDb = !eventsCtx?.db;
1837
1841
  try {
1838
1842
  for (const r of mergedRefs) {
1839
- noOpMap.set(r.ref, readConsecutiveNoOpsForImproveRef(noOpDb, r.ref, options.target, options.legacyBareState));
1843
+ noOpMap.set(r.ref, readConsecutiveNoOpsForImproveRef(noOpDb, r.ref, options.sourceName, options.legacyBareState));
1840
1844
  }
1841
1845
  }
1842
1846
  finally {
@@ -86,13 +86,11 @@ export function selectProactiveMaintenanceRefs(params) {
86
86
  return { selected, dueTotal, neverReflected, scored };
87
87
  }
88
88
  /**
89
- * Post-lock re-filter for proactive refs.
89
+ * Pre-execution re-filter for proactive refs.
90
90
  *
91
- * Called INSIDE the reflect-distill.lock window with freshly-read timestamp
92
- * maps so that any `reflect_invoked` events committed by a concurrent run
93
- * while this run was waiting for the lock are visible. Refs that became
94
- * non-due (staleDays ≤ dueDays) since planning time are dropped before
95
- * execution, closing the SELECT-time cooldown race.
91
+ * Called under the whole-run lock with freshly-read timestamp maps. Refs that
92
+ * became non-due before this run acquired the lock are dropped before execution,
93
+ * closing the SELECT-time cooldown race.
96
94
  *
97
95
  * The logic mirrors the DUE gate in `selectProactiveMaintenanceRefs` — an
98
96
  * asset is due only when never touched OR last touched more than `dueDays`
@@ -45,7 +45,8 @@ import { chatCompletion } from "../../llm/client.js";
45
45
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
46
46
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/repository.js";
47
47
  import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
48
- import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
48
+ import { deriveLessonRef } from "./distill.js";
49
+ import { runReflectQualityJudge } from "./distill/quality-gate.js";
49
50
  import { findAssetFilePath } from "./eligibility.js";
50
51
  import { classifyReflectChange } from "./reflect-noise.js";
51
52
  import { bareImproveRef, durableImproveRef } from "./source-identity.js";
@@ -663,8 +664,12 @@ export async function runReflectViaLlm(opts) {
663
664
  try {
664
665
  const stdout = await (opts.chat ?? chatCompletion)(opts.connection, messages, {
665
666
  ...(Object.hasOwn(opts, "timeoutMs") ? { timeoutMs: opts.timeoutMs } : {}),
667
+ ...(opts.signal ? { signal: opts.signal } : {}),
666
668
  ...(opts.responseSchema !== undefined ? { responseSchema: opts.responseSchema } : {}),
667
669
  ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
670
+ // Reflect requires a machine-readable payload. Visible chain-of-thought
671
+ // can consume the output cap before the model reaches the JSON object.
672
+ enableThinking: false,
668
673
  });
669
674
  return {
670
675
  ok: true,
@@ -931,6 +936,7 @@ export async function akmReflect(options = {}) {
931
936
  prompt,
932
937
  connection: spec.connection,
933
938
  ...(Object.hasOwn(opts, "timeoutMs") ? { timeoutMs: opts.timeoutMs } : {}),
939
+ ...(options.signal ? { signal: options.signal } : {}),
934
940
  priorDraft,
935
941
  iteration: iter,
936
942
  responseSchema: REFLECT_JSON_SCHEMA,
@@ -1100,72 +1106,7 @@ export async function akmReflect(options = {}) {
1100
1106
  // caught downstream by createProposal; allow it to surface naturally.
1101
1107
  }
1102
1108
  }
1103
- // 7. R-5 / #374: Apply the proposal quality gate when enabled.
1104
- // Mirrors the lesson quality gate on distill proposals. The gate uses
1105
- // `runLessonQualityJudge` from distill.ts and is gated behind either
1106
- // `processes.reflect.qualityGate.enabled` or
1107
- // `processes.distill.qualityGate.enabled` on the selected strategy.
1108
- // Fail-CLOSED (07 P0-2): a judge error / no-LLM /
1109
- // parse failure rejects the proposal rather than passing it through.
1110
- // G-Eval (arXiv:2303.16634) — quality judgment before admission.
1111
- const runtimeConfig = options.config ??
1112
- (() => {
1113
- try {
1114
- return loadConfig();
1115
- }
1116
- catch {
1117
- return undefined;
1118
- }
1119
- })();
1120
- const chatFn = options.chat ?? chatCompletion;
1121
- const qualityGateEnabled = (activeStrategy?.processes?.reflect?.qualityGate?.enabled ?? false) ||
1122
- (activeStrategy?.processes?.distill?.qualityGate?.enabled ?? true);
1123
- if (qualityGateEnabled && runtimeConfig) {
1124
- const assetContent = (() => {
1125
- if (!options.ref)
1126
- return null;
1127
- try {
1128
- const refParsed = parseAssetRef(options.ref);
1129
- const candidates = [
1130
- path.join(stash, `${refParsed.type}s`, `${refParsed.name}.md`),
1131
- path.join(stash, `${refParsed.type}s`, refParsed.name, "index.md"),
1132
- ];
1133
- for (const p of candidates) {
1134
- if (fs.existsSync(p))
1135
- return fs.readFileSync(p, "utf8");
1136
- }
1137
- return null;
1138
- }
1139
- catch {
1140
- return null;
1141
- }
1142
- })();
1143
- const judgeResult = await runLessonQualityJudge(runtimeConfig, payload.content, assetContent ?? "", chatFn, undefined, runnerIsLlm(runnerSpec) ? materializeLlmRunnerConnection(runnerSpec) : undefined);
1144
- if (!judgeResult.pass) {
1145
- // Quality gate rejected the proposal — surface as parse_error so the
1146
- // improve orchestrator can log it and move on without crashing.
1147
- appendEvent({
1148
- eventType: "reflect_completed",
1149
- ref: payload.ref,
1150
- metadata: {
1151
- source: "reflect",
1152
- qualityRejected: true,
1153
- qualityScore: judgeResult.score,
1154
- qualityReason: judgeResult.reason,
1155
- },
1156
- });
1157
- return {
1158
- schemaVersion: 2,
1159
- ok: false,
1160
- reason: "parse_error",
1161
- error: `Reflect proposal quality gate rejected: score=${judgeResult.score}, reason="${judgeResult.reason}"`,
1162
- ...(options.ref ? { ref: options.ref } : {}),
1163
- engine: engineName,
1164
- exitCode: result.exitCode,
1165
- };
1166
- }
1167
- }
1168
- // 7b. Reflect content-preservation rails:
1109
+ // 7. Reflect content-preservation rails:
1169
1110
  // - Restore source frontmatter so reflect can never strip indexable
1170
1111
  // fields (`description`, `when_to_use`, `tags`, ...).
1171
1112
  // - Reset protected identity fields (`name`, `ref`, `id`, `slug`,
@@ -1241,6 +1182,38 @@ export async function akmReflect(options = {}) {
1241
1182
  };
1242
1183
  }
1243
1184
  }
1185
+ // 7c. Judge the exact sanitized content that can be persisted. Fail closed
1186
+ // on cancellation, transport failure, malformed output, or an invalid score.
1187
+ const qualityGateEnabled = (activeStrategy?.processes?.reflect?.qualityGate?.enabled ?? false) ||
1188
+ (activeStrategy?.processes?.distill?.qualityGate?.enabled ?? true);
1189
+ if (qualityGateEnabled) {
1190
+ const judgeResult = await runReflectQualityJudge(config, payload.content, assetContent ?? "", feedback, options.chat ?? chatCompletion, {
1191
+ ...(runnerIsLlm(runnerSpec) ? { llmConfig: materializeLlmRunnerConnection(runnerSpec) } : {}),
1192
+ ...(Object.hasOwn(options, "timeoutMs") ? { timeoutMs: options.timeoutMs } : {}),
1193
+ ...(options.signal ? { signal: options.signal } : {}),
1194
+ });
1195
+ if (!judgeResult.pass) {
1196
+ appendEvent({
1197
+ eventType: "reflect_completed",
1198
+ ref: payload.ref,
1199
+ metadata: {
1200
+ source: "reflect",
1201
+ qualityRejected: true,
1202
+ qualityScore: judgeResult.score,
1203
+ qualityReason: judgeResult.reason,
1204
+ },
1205
+ });
1206
+ return {
1207
+ schemaVersion: 2,
1208
+ ok: false,
1209
+ reason: "parse_error",
1210
+ error: `Reflect proposal quality gate rejected: score=${judgeResult.score}, reason="${judgeResult.reason}"`,
1211
+ ...(options.ref ? { ref: options.ref } : {}),
1212
+ engine: engineName,
1213
+ exitCode: result.exitCode,
1214
+ };
1215
+ }
1216
+ }
1244
1217
  // 8. Create the proposal. The proposal queue is the ONLY thing reflect
1245
1218
  // writes — promotion to a real asset is gated by `akm proposal accept`.
1246
1219
  //
@@ -67,12 +67,12 @@ export const DEFAULT_IMPROVE_TASKS = [
67
67
  },
68
68
  ];
69
69
  /**
70
- * A schedule for the manual catch-up task. The scheduler requires a valid
71
- * cron expression even when the task is registered disabled, so we give the
72
- * unscheduled task a nominal far-future-ish cadence and leave it disabled
73
- * the documented entry point is `akm tasks run akm-improve-catchup`.
70
+ * A schedule for the manual catch-up task. The scheduler requires a portable
71
+ * expression even when the task is registered disabled, so we give it a
72
+ * nominal daily cadence and leave it disabled. The documented entry point is
73
+ * `akm tasks run akm-improve-catchup`.
74
74
  */
75
- const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 1 1 *"; // 04:00 on Jan 1 — never effectively fires
75
+ const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 * * *";
76
76
  const DEFAULT_DEPS = {
77
77
  list: akmTasksList,
78
78
  add: akmTasksAdd,
@@ -136,11 +136,15 @@ const tasksRunCommand = defineCommand({
136
136
  name: "run",
137
137
  description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
138
138
  },
139
- args: { id: { type: "positional", description: "Task id", required: true } },
139
+ args: {
140
+ id: { type: "positional", description: "Task id", required: true },
141
+ scheduled: { type: "boolean", description: "Internal marker for scheduler-generated runs", default: false },
142
+ },
140
143
  async run({ args }) {
141
144
  await runWithJsonErrors(async () => {
142
- const { id } = parseTaskRef(args.id);
143
- const envelope = await akmTasksRun(id);
145
+ const envelope = await akmTasksRun(args.id, {
146
+ scheduled: args.scheduled === true,
147
+ });
144
148
  output("tasks-run", envelope);
145
149
  if (envelope.exitCode !== 0)
146
150
  process.exit(envelope.exitCode);