akm-cli 0.9.4 → 0.9.6

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 (123) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/dist/commands/env/env-cli.js +1 -2
  3. package/dist/commands/env/secret-cli.js +1 -5
  4. package/dist/commands/feedback-cli.js +0 -4
  5. package/dist/commands/health/checks.js +0 -32
  6. package/dist/commands/health/surfaces.js +2 -2
  7. package/dist/commands/health.js +6 -15
  8. package/dist/commands/improve/anti-collapse.js +4 -91
  9. package/dist/commands/improve/autonomy-gate.js +1 -1
  10. package/dist/commands/improve/consolidate.js +25 -26
  11. package/dist/commands/improve/distill.js +2 -2
  12. package/dist/commands/improve/extract.js +8 -1
  13. package/dist/commands/improve/improve.js +3 -5
  14. package/dist/commands/improve/locks.js +13 -5
  15. package/dist/commands/improve/loop-stages.js +8 -9
  16. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  17. package/dist/commands/improve/outcome-loop.js +0 -6
  18. package/dist/commands/improve/reflect.js +20 -9
  19. package/dist/commands/lint/index.js +4 -6
  20. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  21. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  22. package/dist/commands/read/search.js +14 -24
  23. package/dist/commands/sources/bundle-config-ops.js +9 -4
  24. package/dist/commands/sources/info.js +14 -10
  25. package/dist/commands/tasks/tasks-cli.js +56 -3
  26. package/dist/commands/tasks/tasks.js +101 -18
  27. package/dist/commands/workflow-cli.js +2 -2
  28. package/dist/core/adapter/adapters/akm-adapter.js +2 -0
  29. package/dist/core/common.js +61 -24
  30. package/dist/core/config/config-io.js +2 -2
  31. package/dist/core/config/config-sources.js +32 -2
  32. package/dist/core/config/config-version-shim.js +101 -0
  33. package/dist/core/config/config.js +7 -7
  34. package/dist/core/config/schema/engines.js +9 -7
  35. package/dist/core/config/schema/primitives.js +0 -5
  36. package/dist/core/config/schema/search.js +2 -1
  37. package/dist/core/file-lock.js +2 -1
  38. package/dist/core/improve-result.js +35 -14
  39. package/dist/core/maintenance-barrier.js +2 -14
  40. package/dist/core/paths.js +0 -3
  41. package/dist/core/redaction.js +2 -2
  42. package/dist/core/spawn-env.js +8 -12
  43. package/dist/core/state/migrations.js +1 -12
  44. package/dist/core/state-db.js +9 -27
  45. package/dist/core/write-source.js +12 -19
  46. package/dist/execution/directory-identity.js +36 -10
  47. package/dist/execution/guarded-source.js +0 -10
  48. package/dist/indexer/graph/graph-boost.js +0 -4
  49. package/dist/indexer/index-writer-lock.js +43 -24
  50. package/dist/indexer/index-written-assets.js +5 -6
  51. package/dist/indexer/indexer.js +2 -39
  52. package/dist/indexer/materialize-embeddings.js +85 -41
  53. package/dist/indexer/passes/metadata.js +12 -4
  54. package/dist/indexer/scan/doc-to-entry.js +2 -0
  55. package/dist/indexer/search/db-search.js +15 -48
  56. package/dist/indexer/search/ranking-contributors.js +0 -25
  57. package/dist/indexer/search/ranking.js +3 -13
  58. package/dist/indexer/search/search-fields.js +16 -1
  59. package/dist/integrations/agent/builder-shared.js +0 -25
  60. package/dist/integrations/agent/model-map.js +2 -60
  61. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  62. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  63. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  64. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  65. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  66. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  67. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  68. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  69. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  70. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  71. package/dist/integrations/session-logs/index.js +0 -9
  72. package/dist/llm/client.js +75 -42
  73. package/dist/llm/embedder.js +7 -3
  74. package/dist/llm/embedders/remote.js +141 -42
  75. package/dist/output/shapes/passthrough.js +17 -5
  76. package/dist/registry/network.js +5 -37
  77. package/dist/runtime.js +2 -10
  78. package/dist/scripts/akm-migrate-node.js +180 -238
  79. package/dist/scripts/akm-migrate.js +180 -238
  80. package/dist/setup/engine-config.js +2 -5
  81. package/dist/setup/registry-stash-loader.js +0 -8
  82. package/dist/setup/setup.js +9 -46
  83. package/dist/setup/steps/connection-shared.js +10 -13
  84. package/dist/sources/providers/git-install.js +1 -1
  85. package/dist/storage/engines/sqlite-migrations.js +20 -1
  86. package/dist/storage/repositories/index-entries-repository.js +0 -15
  87. package/dist/storage/repositories/proposals-repository.js +32 -6
  88. package/dist/tasks/backends/launchd.js +15 -20
  89. package/dist/tasks/backends/schtasks.js +18 -8
  90. package/dist/tasks/run/run-native-task.js +8 -6
  91. package/dist/tasks/scheduler-binding.js +15 -5
  92. package/dist/tasks/scheduler-sync-preview.js +4 -2
  93. package/dist/tasks/scheduler-sync.js +77 -42
  94. package/dist/tasks/source/bounded-document.js +2 -4
  95. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  96. package/dist/tasks/source/task-source-v4.js +5 -10
  97. package/dist/tasks/source/task-to-v3.js +29 -22
  98. package/dist/tasks/source/task-to-v4.js +1 -14
  99. package/dist/tasks/source-v3.js +6 -6
  100. package/dist/workflows/exec/native-executor.js +21 -31
  101. package/dist/workflows/exec/run-workflow.js +5 -6
  102. package/dist/workflows/exec/scheduler.js +3 -19
  103. package/dist/workflows/exec/step-work.js +1 -4
  104. package/dist/workflows/exec/unit-dispatch.js +2 -2
  105. package/dist/workflows/exec/worktree.js +1 -13
  106. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  107. package/dist/workflows/ir/plan-hash.js +4 -6
  108. package/dist/workflows/ir/schema-v4.js +0 -12
  109. package/dist/workflows/ir/schema.js +20 -31
  110. package/dist/workflows/parser.js +11 -52
  111. package/dist/workflows/renderer.js +2 -3
  112. package/dist/workflows/resource-limits.js +11 -41
  113. package/dist/workflows/runtime/runs.js +3 -4
  114. package/dist/workflows/source-ir/schema.js +14 -30
  115. package/dist/workflows/validator.js +1 -7
  116. package/docs/reference/cli.md +25 -3
  117. package/docs/reference/configuration.md +28 -7
  118. package/docs/reference/tasks.md +10 -0
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +4 -23
  121. package/schemas/akm-task.json +1 -2
  122. package/schemas/akm-workflow.json +1 -13
  123. package/dist/indexer/search/semantic-status.js +0 -142
@@ -73,7 +73,7 @@ import { stripFrontmatterBody as stripBodyForFidelity } from "./content-hash.js"
73
73
  import { autoRepairLessonFrontmatter, autoSwapDescriptionWhenToUse, collectLessonQualityFindings, repairLessonDescriptionTruncation, } from "./distill/content-repair.js";
74
74
  import { memoryKnowledgePromotionRequiresDispatch, planMemoryKnowledgePromotion, promoteMemoryToKnowledge, } from "./distill/promote-memory.js";
75
75
  import { fetchTopSimilarLessons, persistOutputEncodingSalience, runLessonQualityJudge, writeQualityRejection, } from "./distill/quality-gate.js";
76
- import { buildClsContext, checkDistillFidelity } from "./distill-guards.js";
76
+ import { buildClsContext, checkDistillFidelity, DEFAULT_CLS_ADJACENT_COUNT } from "./distill-guards.js";
77
77
  import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
78
78
  import { buildRefVocabulary, scoreEncodingSalience } from "./encoding-salience.js";
79
79
  import { resolveImproveLlmExecution } from "./execution.js";
@@ -1239,7 +1239,7 @@ async function buildDistillMessages(args) {
1239
1239
  let clsContext = "";
1240
1240
  if (clsConfig.enabled) {
1241
1241
  try {
1242
- const adjacentCount = clsConfig.adjacentCount ?? 3;
1242
+ const adjacentCount = clsConfig.adjacentCount ?? DEFAULT_CLS_ADJACENT_COUNT;
1243
1243
  // Use the asset content or input ref as the query for adjacent retrieval.
1244
1244
  const clsQuery = assetContent ? assetContent.slice(0, 500) : inputRef;
1245
1245
  const adjacentItems = await fetchSimilarLessonsFn(clsQuery, adjacentCount);
@@ -44,6 +44,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
44
44
  import { disposeLoweredExecutionDispatchLease, } from "../../integrations/agent/execution-lowering.js";
45
45
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
46
46
  import { preFilterSession } from "../../integrations/session-logs/pre-filter.js";
47
+ import { isJsonSchemaKnownUnsupported } from "../../llm/client.js";
47
48
  import { callStructured, preflightStructuredLlmRunner } from "../../llm/structured-call.js";
48
49
  import { sha256Hex } from "../../runtime.js";
49
50
  import { getExtractedSessionsMap, getLastExtractRunAt, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../storage/repositories/extract-sessions-repository.js";
@@ -563,7 +564,13 @@ async function runSessionExtractionLlmCall(args) {
563
564
  return payload.parseFailure ? undefined : payload;
564
565
  },
565
566
  validate: (payload) => ({ ok: true, value: payload }),
566
- maxAttempts: llmRunner.connection.supportsJsonSchema === true ? 1 : 2,
567
+ // One attempt when structured output is expected to work (not explicitly
568
+ // disabled, and this connection hasn't already proven otherwise this
569
+ // process — see `isJsonSchemaKnownUnsupported`); two when it's known
570
+ // unsupported and extraction is relying on looser prompt-contract JSON.
571
+ maxAttempts: llmRunner.connection.supportsJsonSchema !== false && !isJsonSchemaKnownUnsupported(llmRunner.connection)
572
+ ? 1
573
+ : 2,
567
574
  buildFeedback: () => "Your previous response did not contain a valid extraction payload. Respond with ONLY a JSON object matching the requested schema, with a candidates array and no prose or code fences.",
568
575
  });
569
576
  if (result.ok)
@@ -35,7 +35,7 @@ import { akmDistill } from "./distill.js";
35
35
  import { buildLatestProposalTsMap, collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
36
36
  import { countEvalCases } from "./eval-cases.js";
37
37
  import { resolveImprovePlan, resolveImproveStrategy } from "./improve-strategies.js";
38
- import { improveLockPath, MIN_IMPROVE_LOCK_STALE_MS, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
38
+ import { improveLockPath, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
39
39
  // The cycle loop / post-loop / maintenance stages live in ./loop-stages.
40
40
  import { runImproveLoopStage, runImprovePostLoopStage } from "./loop-stages.js";
41
41
  import { analyzeMemoryCleanup } from "./memory/memory-improve.js";
@@ -115,7 +115,7 @@ function createRunWriteJournal() {
115
115
  export async function akmImprove(options = {}) {
116
116
  const setup = resolveImproveRunSetup(options);
117
117
  options = setup.options;
118
- const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, lockStaleAfterMs, } = setup;
118
+ const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, } = setup;
119
119
  let clearBudgetTimer = () => { };
120
120
  let initialGitPaths = new Set();
121
121
  const runJournal = createRunWriteJournal();
@@ -164,7 +164,7 @@ export async function akmImprove(options = {}) {
164
164
  }
165
165
  try {
166
166
  if (!options.dryRun) {
167
- const acquisition = tryAcquireImproveLock(resolvedLockPath, lockStaleAfterMs, options.skipIfLocked, {
167
+ const acquisition = tryAcquireImproveLock(resolvedLockPath, options.skipIfLocked, {
168
168
  // R25: C2 boundary-pinned path — the long-lived handle doesn't exist yet.
169
169
  dbPath: resolvedStateDbPath,
170
170
  });
@@ -484,7 +484,6 @@ function resolveImproveRunSetup(options) {
484
484
  // triage, indexing, proposal work, maintenance, and final stash sync.
485
485
  const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
486
486
  const resolvedLockPath = improveLockPath(lockBaseDir);
487
- const lockStaleAfterMs = Math.max(MIN_IMPROVE_LOCK_STALE_MS, budgetMs + 10 * 60 * 1000);
488
487
  const effectiveSync = { ...improveProfile.sync, ...options.sync };
489
488
  return {
490
489
  startMs,
@@ -514,7 +513,6 @@ function resolveImproveRunSetup(options) {
514
513
  syncRepoDir,
515
514
  resolvedStateDbPath,
516
515
  resolvedLockPath,
517
- lockStaleAfterMs,
518
516
  effectiveSync,
519
517
  };
520
518
  }
@@ -9,13 +9,12 @@ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquire
9
9
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
10
10
  import { describeInaccessiblePath } from "../../core/path-access.js";
11
11
  import { warn } from "../../core/warn.js";
12
- export const MIN_IMPROVE_LOCK_STALE_MS = 4 * 60 * 60 * 1000;
13
12
  export function improveLockPath(lockBaseDir) {
14
13
  return path.join(lockBaseDir, "improve.lock");
15
14
  }
16
- export function tryAcquireImproveLock(lockPath, staleAfterMs, skipIfLocked, eventsCtx) {
15
+ export function tryAcquireImproveLock(lockPath, skipIfLocked, eventsCtx) {
17
16
  let recoveryEvent;
18
- const acquire = () => tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, (event) => {
17
+ const acquire = () => tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, (event) => {
19
18
  recoveryEvent = event;
20
19
  });
21
20
  const result = skipIfLocked ? tryWithMaintenanceStartBarrier(acquire) : withMaintenanceStartBarrier(acquire);
@@ -37,14 +36,23 @@ export function tryAcquireImproveLock(lockPath, staleAfterMs, skipIfLocked, even
37
36
  }
38
37
  return result;
39
38
  }
40
- function tryAcquireImproveLockUnlocked(lockPath, staleAfterMs, skipIfLocked, onRecovered) {
39
+ function tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, onRecovered) {
41
40
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
42
41
  const lockPayload = () => createLockPayload({ startedAt: new Date().toISOString() });
43
42
  let ownership = tryAcquireLockSync(lockPath, lockPayload());
44
43
  if (ownership) {
45
44
  return { state: "acquired", ownership };
46
45
  }
47
- const probe = probeLock(lockPath, { staleAfterMs });
46
+ // No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. A
47
+ // wedged-but-alive `akm improve` (SQLite WAL + busy_timeout + BEGIN
48
+ // IMMEDIATE already serialize concurrent writes to state.db at the
49
+ // correctness layer, so this lock only avoids duplicate LOGICAL work) must
50
+ // not have its lease silently taken away purely because a clock elapsed —
51
+ // that was the #872-shaped hazard here: a live holder passed the
52
+ // PID-liveness check forever, so only a multi-hour age window could ever
53
+ // free it, stranding every `akm improve --skip-if-locked` invocation for
54
+ // up to that long while reporting success.
55
+ const probe = probeLock(lockPath);
48
56
  // Race: the holder released the lock between our failed `tryAcquireLockSync`
49
57
  // and this probe, so the probe sees no file (`absent`). Retry acquisition once
50
58
  // rather than falling through to the contended skip/throw below — otherwise we
@@ -13,7 +13,6 @@ import { getDbPath } from "../../core/paths.js";
13
13
  import { withStateDb } from "../../core/state-db.js";
14
14
  import { info } from "../../core/warn.js";
15
15
  import { DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES, runGraphExtractionPass, } from "../../indexer/graph/graph-extraction.js";
16
- import { withIndexWriterLease } from "../../indexer/index-writer-lock.js";
17
16
  import { deriveWritableBundleIds } from "../../indexer/installations.js";
18
17
  import { collectPendingMemories, runMemoryInferencePass, } from "../../indexer/passes/memory-inference.js";
19
18
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
@@ -679,13 +678,13 @@ export async function runImproveMaintenancePasses(args) {
679
678
  graphExtractionFn,
680
679
  reindexWithIndexDbReleased,
681
680
  };
682
- const collected = await withIndexWriterLease({ purpose: "improve-maintenance", signal: budgetSignal }, () => runMaintenancePassesUnderLease(ctx, dbCell, {
681
+ const collected = await runMaintenancePassesUnderLease(ctx, dbCell, {
683
682
  actionableRefs: args.actionableRefs,
684
683
  memoryRefsForInference,
685
684
  consolidationRan: args.consolidationRan,
686
685
  allWarnings,
687
686
  openIndexDb,
688
- }));
687
+ });
689
688
  return {
690
689
  ...(collected.memoryInference ? { memoryInference: collected.memoryInference } : {}),
691
690
  ...(collected.graphExtraction ? { graphExtraction: collected.graphExtraction } : {}),
@@ -697,12 +696,12 @@ export async function runImproveMaintenancePasses(args) {
697
696
  };
698
697
  }
699
698
  /**
700
- * The maintenance sequence run under the index-writer lease (formerly the
701
- * ~389-line anonymous `withIndexWriterLease` callback): memory inference
702
- * reindex-after-inference → graph extraction → proposal hygiene (orphan purge,
703
- * expiration) → retention purges. Each pass returns its results and warnings;
704
- * this orchestrator folds warnings into the caller's `allWarnings` sink at the
705
- * same points the inline code pushed them.
699
+ * The maintenance sequence (formerly the ~389-line anonymous
700
+ * `withIndexWriterLease` callback, before #872 removed the index-rebuild
701
+ * lease): memory inference → reindex-after-inference → graph extraction →
702
+ * proposal hygiene (orphan purge, expiration) → retention purges. Each pass
703
+ * returns its results and warnings; this orchestrator folds warnings into the
704
+ * caller's `allWarnings` sink at the same points the inline code pushed them.
706
705
  */
707
706
  async function runMaintenancePassesUnderLease(ctx, dbCell, args) {
708
707
  const { allWarnings } = args;
@@ -14,7 +14,7 @@
14
14
  * # Algorithm
15
15
  *
16
16
  * 1. Collect all derived memories grouped by `parentRef` family.
17
- * 2. For each family, enumerate candidate pairs (limited to MAX_FAMILY_SIZE).
17
+ * 2. For each family, enumerate candidate pairs.
18
18
  * 3. For each pair, call the LLM to judge whether the two memories are in
19
19
  * direct factual conflict.
20
20
  * 4. For confirmed contradictions, write `contradictedBy` edges directly to
@@ -43,16 +43,6 @@ import { callStructured, preflightStructuredLlmRunner } from "../../../llm/struc
43
43
  import { resolveImproveLlmExecution } from "../execution.js";
44
44
  import { isDerivedMemory, memoryIdentityRef, resolveParentRef } from "./derived-ref.js";
45
45
  // ── Constants ────────────────────────────────────────────────────────────────
46
- /**
47
- * Maximum family size for pairwise contradiction checking. Families larger
48
- * than this are skipped to bound the LLM call count (O(n²) pairs).
49
- */
50
- const MAX_FAMILY_SIZE = 8;
51
- /**
52
- * Maximum number of contradiction pairs to check per improve run, across all
53
- * families. Prevents runaway LLM usage on stashes with many memories.
54
- */
55
- const MAX_PAIRS_PER_RUN = 20;
56
46
  /**
57
47
  * Minimum confidence required to write a contradiction edge. Below this
58
48
  * threshold the LLM may be flagging topic-overlap rather than genuine logical
@@ -218,15 +208,9 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
218
208
  for (const [, family] of byParent) {
219
209
  if (family.length < 2)
220
210
  continue;
221
- if (family.length > MAX_FAMILY_SIZE) {
222
- result.warnings.push(`Skipping contradiction check for family of ${family.length} members (exceeds MAX_FAMILY_SIZE=${MAX_FAMILY_SIZE})`);
223
- continue;
224
- }
225
211
  result.familiesExamined++;
226
212
  for (let i = 0; i < family.length - 1; i++) {
227
213
  for (let j = i + 1; j < family.length; j++) {
228
- if (candidatePairs.length >= MAX_PAIRS_PER_RUN)
229
- break;
230
214
  const a = family[i];
231
215
  const b = family[j];
232
216
  if (!a || !b)
@@ -245,8 +229,6 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
245
229
  continue;
246
230
  candidatePairs.push({ a, b, loser, winnerRef });
247
231
  }
248
- if (candidatePairs.length >= MAX_PAIRS_PER_RUN)
249
- break;
250
232
  }
251
233
  }
252
234
  if (candidatePairs.length === 0) {
@@ -9,12 +9,6 @@ import { getAllAssetOutcomes, getAssetOutcome, getOutcomeScoresByRef, upsertAsse
9
9
  * At α = 0.3 the window is ≈ 3 cycles.
10
10
  */
11
11
  export const OUTCOME_EMA_ALPHA = 0.3;
12
- /**
13
- * Maximum K improve cycles for the eligibility-trace window. Retrievals older
14
- * than K cycles contribute via the EMA decay naturally (they are already baked
15
- * into `expected_retrieval_rate`).
16
- */
17
- export const OUTCOME_TRACE_CYCLES = 5;
18
12
  /**
19
13
  * Warm-start cap: the maximum `outcome_score` a brand-new row can be seeded with
20
14
  * (from the utility EMA). Prevents a `[0,1]`-range utility value from generating
@@ -44,7 +44,7 @@ import { prepareInlineExecution, prepareInlineExecutionWithRunner } from "../../
44
44
  import { buildReflectOutputRepairPrompt, buildReflectPrompt, extractDraftConfidence, parseAgentProposalPayload, } from "../../integrations/agent/prompts.js";
45
45
  import { runnerIsLlm, runnerSupportsFileWrite } from "../../integrations/agent/runner.js";
46
46
  import { collectDispatchSensitiveValues } from "../../integrations/agent/runner-dispatch.js";
47
- import { LlmCallError } from "../../llm/client.js";
47
+ import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js";
48
48
  import { callStructured } from "../../llm/structured-call.js";
49
49
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
50
50
  import { isProposalSkipped, listProposalsReadOnly, proposalContent, } from "../proposal/repository.js";
@@ -578,8 +578,9 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
578
578
  }
579
579
  /**
580
580
  * JSON Schema for structured reflect output. Passed to `chatCompletion` when
581
- * the connection has `supportsJsonSchema: true` so the model returns a strict
582
- * JSON object containing only the target-scoped fields AKM cannot derive.
581
+ * {@link wantsJsonSchemaOutput} selects `outputMode: "json_schema"`, so the
582
+ * model returns a strict JSON object containing only the target-scoped
583
+ * fields AKM cannot derive.
583
584
  */
584
585
  const REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA = {
585
586
  type: "object",
@@ -626,6 +627,17 @@ const REFLECT_UNSCOPED_JSON_SCHEMA = {
626
627
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
627
628
  },
628
629
  };
630
+ /**
631
+ * Whether to frame the reflect prompt for structured JSON output on this
632
+ * connection. Optimistic by default — `chatCompletion` attempts
633
+ * `response_format: json_schema` fresh on every call and falls back once on
634
+ * a 4xx, so there is no persisted verdict to consult here. `false` only when
635
+ * a human/workflow explicitly disabled it, or a real call already proved
636
+ * this connection rejects it earlier in the same process.
637
+ */
638
+ function wantsJsonSchemaOutput(connection) {
639
+ return connection.supportsJsonSchema !== false && !isJsonSchemaKnownUnsupported(connection);
640
+ }
629
641
  /** Critique prompt injected between prior draft and refinement request (Self-Refine loop). */
630
642
  const REFLECT_CRITIQUE_PROMPT = "Your previous proposal is shown above. Review it critically and provide an improved version that is more specific, actionable, and avoids any issues with the previous attempt. Return only the improved response using the output contract from the original prompt.";
631
643
  /**
@@ -1354,21 +1366,20 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1354
1366
  }
1355
1367
  /**
1356
1368
  * Run the agent with the optional Self-Refine loop (R-1 / #372): up to
1357
- * MAX_REFINE_ITERS invocations, each injecting the prior draft as self-critique
1369
+ * `maxRefineIters` invocations, each injecting the prior draft as self-critique
1358
1370
  * context and exiting early on a no-op refinement. Synthesizes per-iteration
1359
1371
  * draft paths into `draftPathsToCleanup` (mutated) and returns the final agent
1360
1372
  * result + last draft path. Extracted verbatim from `akmReflect`.
1361
1373
  */
1362
1374
  async function runReflectRefineIterations(args) {
1363
1375
  const { options, parsedRef, assetContent, feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices, } = args;
1364
- const MAX_REFINE_ITERS = 3;
1365
- const maxRefineIters = Math.min(Math.max(1, options.maxRefineIters ?? 1), MAX_REFINE_ITERS);
1376
+ const maxRefineIters = Math.max(1, options.maxRefineIters ?? 1);
1366
1377
  // Determine whether this dispatch can honour the file-write contract.
1367
1378
  // Agent CLI + OpenCode SDK runners both have filesystem access; the direct
1368
1379
  // LLM HTTP runner does NOT.
1369
1380
  const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1370
1381
  const outputMode = runnerIsLlm(runnerSpec)
1371
- ? runnerSpec.connection.supportsJsonSchema
1382
+ ? wantsJsonSchemaOutput(runnerSpec.connection)
1372
1383
  ? "json_schema"
1373
1384
  : "framed_markdown"
1374
1385
  : undefined;
@@ -1421,10 +1432,10 @@ async function runReflectRefineIterations(args) {
1421
1432
  ...(options.signal ? { signal: options.signal } : {}),
1422
1433
  priorDraft,
1423
1434
  iteration: iter,
1424
- ...(runnerSpec.connection.supportsJsonSchema
1435
+ ...(outputMode === "json_schema"
1425
1436
  ? { responseSchema: options.ref ? REFLECT_JSON_SCHEMA : REFLECT_UNSCOPED_JSON_SCHEMA }
1426
1437
  : {}),
1427
- outputMode: runnerSpec.connection.supportsJsonSchema ? "json_schema" : "framed_markdown",
1438
+ outputMode: outputMode ?? "framed_markdown",
1428
1439
  ...(options.ref ? { targetRef: options.ref } : {}),
1429
1440
  allowRepair: repairAttempts === 0,
1430
1441
  ...(options.chat ? { chat: options.chat } : {}),
@@ -92,7 +92,7 @@ function collectWorkflowFiles(dir) {
92
92
  continue;
93
93
  const full = path.join(dir, entry.name);
94
94
  if (entry.isDirectory()) {
95
- if (entry.name === ".cache" || entry.name === "registry")
95
+ if (isAkmRegistryCachePath(full))
96
96
  continue;
97
97
  results.push(...collectWorkflowFiles(full));
98
98
  continue;
@@ -185,11 +185,9 @@ function collectAdapterFiles(root, extensions) {
185
185
  walk(full);
186
186
  }
187
187
  else if (entry.isFile() && matchesAdapterExtension(entry.name, extensions)) {
188
- // Compare PATH SEGMENTS, not raw substrings: `path.join` yields `\` on
189
- // Windows so a `"/.cache/"` substring test never matches there, and a
190
- // substring test would also skip a legitimately-named `registry` file.
191
- const segments = path.relative(root, full).split(/[\\/]/);
192
- if (segments.includes(".cache") || segments.includes("registry"))
188
+ // Skip akm's OWN resolved registry-cache copies, not any user
189
+ // directory that happens to be named ".cache" or "registry".
190
+ if (isAkmRegistryCachePath(full))
193
191
  continue;
194
192
  results.push(full);
195
193
  }
@@ -196,13 +196,6 @@ export function hasHotCaptureMode(frontmatter) {
196
196
  * with 80-90% overlap legitimately compress to well under 50% of the larger.
197
197
  */
198
198
  export const MERGE_SHRINK_RATIO_MIN = 0.3;
199
- /**
200
- * Absolute floor (chars) for merged body. When sources are short (<~333 chars),
201
- * `MERGE_SHRINK_RATIO_MIN × largerBodyLen` falls below this and the absolute
202
- * floor dominates — prevents false positives on very terse memory pairs.
203
- * Matches the existing `promote_source_too_small` floor of 100 chars.
204
- */
205
- export const MERGE_ABSOLUTE_FLOOR_CHARS = 100;
206
199
  // ── Reflect size gate ────────────────────────────────────────────────────────
207
200
  /** Ratio lower-bound: proposed body must be at least this fraction of source. */
208
201
  export const REFLECT_SHRINK_RATIO_MIN = 0.5;
@@ -67,6 +67,18 @@ const canonicalProposalValidators = {
67
67
  const content = proposalContent(proposal);
68
68
  if (!content.trim())
69
69
  return [];
70
+ // #859: proposedTarget is absent on legacy archived rows, but this
71
+ // validator only ever runs on a proposal about to be minted or promoted
72
+ // (both always carry proposedTarget — see the Proposal.proposedTarget
73
+ // doc comment) — so hitting this is a genuine defect, not a legacy gap.
74
+ if (!proposal.proposedTarget) {
75
+ return [
76
+ {
77
+ kind: "invalid-workflow-structure",
78
+ message: `Workflow proposal ${proposal.id} (${proposal.ref}) is missing proposedTarget and cannot be validated.`,
79
+ },
80
+ ];
81
+ }
70
82
  const sourcePath = proposal.changes[0]?.path || proposal.ref;
71
83
  const result = compileWorkflowSource(content, {
72
84
  path: sourcePath,
@@ -14,13 +14,10 @@
14
14
  import { getSources, loadConfig } from "../../core/config/config.js";
15
15
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
16
16
  import { appendEvent } from "../../core/events.js";
17
- import { isTransientStashPath } from "../../core/paths.js";
18
17
  import { resolveReadSources } from "../../indexer/read-preflight.js";
19
18
  import { searchLocal } from "../../indexer/search/db-search.js";
20
19
  import { getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
21
20
  import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
22
- import { bumpUtilityScoresBatch } from "../../storage/repositories/index-utility-repository.js";
23
- import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
24
21
  // Eagerly import source providers to trigger self-registration before the
25
22
  // indexer or path-resolution code runs.
26
23
  import "../../sources/providers/index.js";
@@ -188,7 +185,7 @@ function usageSearchMode(mode) {
188
185
  function maybeLogSearchEvent(input, query, response, mode) {
189
186
  if (input.skipLogging)
190
187
  return;
191
- logSearchEvent(query, response, mode, input.eventSource, input.disableScopedUtility === true, input.attributionProjection);
188
+ logSearchEvent(query, response, mode, input.eventSource, input.attributionProjection);
192
189
  }
193
190
  /**
194
191
  * Resolve entry IDs by file_path lookup (exact match, not LIKE).
@@ -229,7 +226,7 @@ function resolveEntryIds(db, hits) {
229
226
  * Per-entry events are recorded only for stash hits because registry hits
230
227
  * have no local entry_id to reference.
231
228
  */
232
- function logSearchEvent(query, response, mode = "keyword", eventSource = "user", disableScopedUtility = false, attributionProjection = "full") {
229
+ function logSearchEvent(query, response, mode = "keyword", eventSource = "user", attributionProjection = "full") {
233
230
  // Emit a structured event to events.jsonl so workflow-trace consumers
234
231
  // detect akm search invocations without relying on stdout scraping.
235
232
  const stashHits = response.hits.filter((h) => h.type !== "registry");
@@ -276,25 +273,18 @@ function logSearchEvent(query, response, mode = "keyword", eventSource = "user",
276
273
  source: eventSource,
277
274
  });
278
275
  }, TELEMETRY_BUSY_TIMEOUT_MS);
279
- // Bump utility scores for all resolved entries (MemRL retrieval signal).
280
- // The indexer overwrites these at next reindex; bumps are temporary hints.
281
- // Gated to user-sourced events: pipeline searches (improve probes, task
282
- // runner) must not feed the utility signal (meta-review 05 DRIFT-6 —
283
- // the bump previously fired unconditionally, so even correctly-tagged
284
- // machine traffic inflated utility). utility_scores stays in index.db.
285
- const resolvedIds = eventSource === "user" ? resolved.map((r) => r.entryId).filter((id) => id !== undefined) : [];
286
- if (resolvedIds.length > 0) {
287
- let scopeKey;
288
- try {
289
- const stashPath = response.bundleDir;
290
- const disabled = disableScopedUtility || (stashPath && isTransientStashPath(stashPath));
291
- scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
292
- }
293
- catch {
294
- // Non-fatal — fall back to global-only bumps on any error.
295
- }
296
- bumpUtilityScoresBatch(db, resolvedIds, 1.0, 0.1, scopeKey);
297
- }
276
+ // No live utility_scores/utility_scores_scoped write here (#862): a
277
+ // search result is an impression, not a signal that the asset was
278
+ // useful. Rewarding every returned hit created a feedback loop where
279
+ // merely appearing in results inflated future ranking assets
280
+ // surfaced because they'd surfaced before, not because a user acted
281
+ // on them. Retrieval counts are still recorded above via
282
+ // insertUsageEvent (search_count) and rolled into utility_scores by
283
+ // the offline `recomputeUtilityScores` pass (`akm index`), which uses
284
+ // the show/search *select rate* — a ratio that requires an actual
285
+ // `show`/select event, not raw impressions. Explicit signal comes
286
+ // from `akm feedback` (applyFeedbackToUtilityScore) and from
287
+ // selection (recordShowUsage / the `select` event derived from it).
298
288
  }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
299
289
  }
300
290
  catch (err) {
@@ -13,20 +13,25 @@
13
13
  * keying rules across call sites.
14
14
  */
15
15
  import path from "node:path";
16
- import { primaryBundlePath } from "../../core/config/config.js";
16
+ import { bundleKeyForContentRoot, primaryBundlePath } from "../../core/config/config.js";
17
17
  import { deriveBundleId } from "../../indexer/installations.js";
18
18
  export { primaryBundlePath };
19
19
  /**
20
20
  * Upsert the primary filesystem bundle (`{ path, writable: true }`) and point
21
21
  * `defaultBundle` at it. Reuses the current default key when it already names a
22
- * filesystem bundle (so re-pointing the primary keeps a stable id); otherwise
23
- * derives a fresh slug-legal key from the path.
22
+ * filesystem bundle (so re-pointing the primary keeps a stable id). Otherwise,
23
+ * reuses whichever configured bundle ALREADY resolves to `stashDir`'s content
24
+ * root (issue #870 — `AKM_BUNDLE_DIR`/`--dir` pointed at a directory already
25
+ * configured under a different id must not mint a second bundle for it); only
26
+ * when no bundle owns that root yet is a fresh slug-legal key derived.
24
27
  */
25
28
  export function withPrimaryBundle(config, stashDir) {
26
29
  const bundles = { ...(config.bundles ?? {}) };
27
30
  let key = config.defaultBundle;
28
31
  if (!key || !(key in bundles) || typeof bundles[key]?.path !== "string") {
29
- key = deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
32
+ key =
33
+ bundleKeyForContentRoot(config, path.resolve(stashDir)) ??
34
+ deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
30
35
  }
31
36
  bundles[key] = {
32
37
  ...bundles[key],
@@ -8,7 +8,6 @@ import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-ac
8
8
  import { getDbPath } from "../../core/paths.js";
9
9
  import { formatRegistryUrl } from "../../core/registry-url.js";
10
10
  import { error } from "../../core/warn.js";
11
- import { getEffectiveSemanticStatus, readSemanticStatus } from "../../indexer/search/semantic-status.js";
12
11
  import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
13
12
  import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
14
13
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -29,13 +28,6 @@ export function assembleInfo(options) {
29
28
  const defaultBundle = config.defaultBundle ?? null;
30
29
  // Asset types (copy into a mutable array — `placementTypes()` returns readonly)
31
30
  const assetTypes = [...placementTypes()];
32
- const semanticRuntime = readSemanticStatus();
33
- const semanticStatus = getEffectiveSemanticStatus(config, semanticRuntime);
34
- // Search modes
35
- const searchModes = ["fts"];
36
- if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
37
- searchModes.push("semantic", "hybrid");
38
- }
39
31
  // Registries (strip sensitive fields like apiKey from options)
40
32
  const registries = (config.registries ?? []).map((r) => ({
41
33
  url: formatRegistryUrl(r.url),
@@ -58,6 +50,20 @@ export function assembleInfo(options) {
58
50
  // health and search use, so info reads the same database they do.
59
51
  const resolvedDbPath = options?.dbPath ?? getDbPath();
60
52
  const indexStats = readIndexStats(resolvedDbPath);
53
+ // Semantic status is read live from the index's own state, not a cached
54
+ // verdict — a failed embed attempt at search time falls back to FTS and
55
+ // reports that in the search response, it never disables the mode here.
56
+ const semanticStatus = config.semanticSearchMode === "off"
57
+ ? "disabled"
58
+ : !indexStats.hasEmbeddings
59
+ ? "pending"
60
+ : indexStats.vecAvailable
61
+ ? "ready-vec"
62
+ : "ready-js";
63
+ const searchModes = ["fts"];
64
+ if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
65
+ searchModes.push("semantic", "hybrid");
66
+ }
61
67
  return {
62
68
  schemaVersion: 1,
63
69
  version: pkgVersion,
@@ -68,8 +74,6 @@ export function assembleInfo(options) {
68
74
  semanticSearch: {
69
75
  mode: config.semanticSearchMode,
70
76
  status: semanticStatus,
71
- ...(semanticRuntime?.reason ? { reason: semanticRuntime.reason } : {}),
72
- ...(semanticRuntime?.message ? { message: semanticRuntime.message } : {}),
73
77
  },
74
78
  registries,
75
79
  sourceProviders,
@@ -31,7 +31,7 @@ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS,
31
31
  import { UsageError } from "../../core/errors.js";
32
32
  import { TASK_RUN_BOOLEAN_FLAGS, TASK_RUN_VALUE_FLAGS } from "../../tasks/task-run-reserved-flags.js";
33
33
  import { akmTaskExplain } from "./explain.js";
34
- import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksRun, akmTasksSync, akmTasksSyncPlan } from "./tasks.js";
34
+ import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksPrune, akmTasksRun, akmTasksSync, akmTasksSyncPlan, } from "./tasks.js";
35
35
  /** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
36
36
  const bundleArg = {
37
37
  bundle: {
@@ -307,10 +307,13 @@ const tasksHistoryCommand = defineJsonCommand({
307
307
  * #849: `task sync --dry-run`'s exit-code contract, "non-zero when the plan
308
308
  * contains removals" — factored out as a pure function (rather than left
309
309
  * inline in the command's `run()`) so it's directly unit-testable without
310
- * driving the whole CLI through a real scheduler backend.
310
+ * driving the whole CLI through a real scheduler backend. #867: also
311
+ * non-zero when any source failed to parse/prepare — sync degrades (still
312
+ * reconciles the tasks/workflows that DID parse) rather than rejecting the
313
+ * whole set, but a dropped source must still surface as a failing exit.
311
314
  */
312
315
  export function taskSyncDryRunExitCode(preview) {
313
- return preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
316
+ return preview.hasRemovals || (preview.failures?.length ?? 0) > 0 ? EXIT_CODES.GENERAL : undefined;
314
317
  }
315
318
  const tasksSyncCommand = defineJsonCommand({
316
319
  meta: {
@@ -345,6 +348,12 @@ const tasksSyncCommand = defineJsonCommand({
345
348
  }
346
349
  const result = await akmTasksSync({}, args.bundle, { rebind });
347
350
  output("task-sync", result);
351
+ // #867: sync degrades — sources that failed to parse/prepare are
352
+ // excluded from reconciliation and reported in `result.failed` rather
353
+ // than poisoning the whole sync, but their presence must still fail
354
+ // the command's exit code so the breakage stays visible.
355
+ if (result.failed.length > 0)
356
+ process.exitCode = EXIT_CODES.GENERAL;
348
357
  },
349
358
  });
350
359
  // ── `akm task explain` — read-only introspection (P2b Lane B, spec
@@ -389,6 +398,49 @@ const tasksDoctorCommand = defineJsonCommand({
389
398
  output("task-doctor", result);
390
399
  },
391
400
  });
401
+ /**
402
+ * #851: `akm task prune`'s exit-code contract mirrors `task sync --dry-run`'s
403
+ * (`taskSyncDryRunExitCode` above) — non-zero whenever the preview lists
404
+ * removals the invocation didn't (or couldn't, without `--yes`) execute, so
405
+ * `akm task prune` is usable as a CI/health check the same way `sync
406
+ * --dry-run` is.
407
+ */
408
+ export function taskPruneExitCode(result) {
409
+ return result.dryRun && result.preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
410
+ }
411
+ const tasksPruneCommand = defineJsonCommand({
412
+ meta: {
413
+ name: "prune",
414
+ description: "Remove installed scheduler entries `sync` can never reclaim because their own descriptor no longer " +
415
+ "resolves to a live bundle (corrupt/missing --scheduler-context, or the owning bundle directory is " +
416
+ "gone). Defaults to a dry-run preview — zero scheduler writes. Requires --yes to remove anything; " +
417
+ "--id narrows removal to specific binding ids (comma-separated).",
418
+ },
419
+ args: {
420
+ yes: {
421
+ type: "boolean",
422
+ description: "Execute removal of every currently-computed orphan (the plan is still printed first)",
423
+ default: false,
424
+ },
425
+ id: {
426
+ type: "string",
427
+ description: "Comma-separated scheduler binding id(s) to limit pruning to (must already be orphan candidates)",
428
+ },
429
+ },
430
+ async run({ args }) {
431
+ const id = args.id
432
+ ? args.id
433
+ .split(",")
434
+ .map((value) => value.trim())
435
+ .filter(Boolean)
436
+ : undefined;
437
+ const result = await akmTasksPrune({}, { yes: args.yes === true, id });
438
+ output("task-prune", result);
439
+ const exitCode = taskPruneExitCode(result);
440
+ if (exitCode !== undefined)
441
+ process.exitCode = exitCode;
442
+ },
443
+ });
392
444
  export const taskCommand = defineGroupCommand({
393
445
  meta: {
394
446
  name: "task",
@@ -400,6 +452,7 @@ export const taskCommand = defineGroupCommand({
400
452
  explain: tasksExplainCommand,
401
453
  history: tasksHistoryCommand,
402
454
  sync: tasksSyncCommand,
455
+ prune: tasksPruneCommand,
403
456
  doctor: tasksDoctorCommand,
404
457
  },
405
458
  // Bare `akm task` reports scheduler diagnostics. Inspection of individual