akm-cli 0.9.0 → 0.9.1-beta.2

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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -7,6 +7,8 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
+ import { isLoopbackEndpoint } from "../core/loopback.js";
11
+ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
10
12
  import { getDbPath } from "../core/paths.js";
11
13
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
12
14
  import { withStateDb } from "../core/state-db.js";
@@ -17,7 +19,7 @@ import { closeDatabase, openExistingDatabase, openIndexDatabase } from "../stora
17
19
  import { deleteEntriesByDirAndStash, deleteEntriesByDirExceptKeys, deleteEntriesByIds, deleteEntriesByStashDir, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedDirPathsByStashDir, getIndexedStashDirsByDir, relinkUsageEvents, upsertEntry, upsertWorkflowDocument, } from "../storage/repositories/index-entries-repository.js";
18
20
  import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
19
21
  import { clearStaleCacheEntries } from "../storage/repositories/index-llm-cache-repository.js";
20
- import { deleteIndexDirState, deleteIndexDirStatesByStashDir, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
22
+ import { deleteIndexDirState, deleteIndexDirStatesByStashDir, deleteMeta, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
21
23
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
22
24
  import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
23
25
  import { takeWorkflowDocument } from "../workflows/runtime/document-cache.js";
@@ -39,23 +41,15 @@ function throwIfAborted(signal) {
39
41
  export function getDefaultLlmConcurrency(llmConfig) {
40
42
  if (typeof llmConfig?.concurrency === "number")
41
43
  return llmConfig.concurrency;
42
- if (!llmConfig?.endpoint)
43
- return 1;
44
- try {
45
- const url = new URL(llmConfig.endpoint);
46
- // URL.hostname keeps IPv6 brackets ("[::1]") — strip them so the loopback
47
- // comparison actually matches.
48
- const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
49
- if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost"))
50
- return 1;
51
- }
52
- catch {
44
+ // Local model servers stay at 1 (single loaded model; parallel requests
45
+ // trigger reload thrash); an absent or unparseable endpoint fails safe as
46
+ // local. ONE classifier decides what "local" means (`core/loopback.ts`,
47
+ // shared with the workflow engine's frozen concurrency default).
48
+ if (isLoopbackEndpoint(llmConfig?.endpoint))
53
49
  return 1;
54
- }
55
50
  // Remote endpoints default to a modest 2-wide pool (owner ruling 2026-07-21):
56
51
  // enough to overlap request latency without hammering rate-limited APIs.
57
- // Local model servers stay at 1 (single loaded model; parallel requests
58
- // trigger reload thrash). The explicit-override branch above only fires for
52
+ // The explicit-override branch above only fires for
59
53
  // callers that put `concurrency` on the connection themselves —
60
54
  // `engines.<name>.concurrency` is a valid schema field but `resolveLlmEngineUse`
61
55
  // does NOT copy it into the resolved connection, so on the enrichment path the
@@ -164,7 +158,12 @@ async function runWalkPhase(ctx) {
164
158
  async function runEmbeddingPhase(ctx) {
165
159
  const { db, config, signal, onProgress } = ctx;
166
160
  throwIfAborted(signal);
167
- ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress);
161
+ // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
162
+ // inert — its throwIfAborted checks and the signal it threads into embedBatch
163
+ // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
164
+ // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
165
+ // stop the embedding phase, the longest phase of an index run.
166
+ ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal);
168
167
  ctx.timing.tEmbedEnd = Date.now();
169
168
  }
170
169
  /**
@@ -290,12 +289,33 @@ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
290
289
  *
291
290
  * Only rows with a non-empty `file_path` are checked — remote/virtual entries
292
291
  * that have no local path are always skipped.
292
+ *
293
+ * "No longer exists" means ABSENT, never merely unreadable (#791). This pass
294
+ * DELETES rows, and `fs.existsSync` reported `false` for a file akm lacked
295
+ * permission to look at exactly as for one that had been removed — so a
296
+ * bundle temporarily mounted read-restricted (a uid mismatch, a tightened
297
+ * parent directory) had its whole index wiped, and the run reported the
298
+ * deletions as a clean success. Unreadable files keep their rows and are
299
+ * reported instead.
293
300
  */
294
301
  function runCleanPass(db, dryRun) {
295
302
  const allEntries = db.prepare("SELECT id, entry_key AS ref, file_path AS path FROM entries").all();
296
303
  // Only check entries that have a non-empty local path (skip remote/virtual).
297
304
  const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
298
- const missing = localEntries.filter((e) => !fs.existsSync(e.path));
305
+ const missing = [];
306
+ const unreadable = [];
307
+ for (const entry of localEntries) {
308
+ const { access, code } = classifyPathAccess(entry.path);
309
+ if (access === "absent")
310
+ missing.push(entry);
311
+ else if (access === "inaccessible")
312
+ unreadable.push({ path: entry.path, ...(code ? { code } : {}) });
313
+ }
314
+ if (unreadable.length > 0) {
315
+ const shown = unreadable.slice(0, 5).map((u) => describeInaccessiblePath(u.path, u.code));
316
+ warn(`Index clean pass kept ${unreadable.length} entr${unreadable.length === 1 ? "y" : "ies"} whose file akm cannot ` +
317
+ `read (unreadable is not deleted): ${shown.join("; ")}${unreadable.length > shown.length ? "; …" : ""}`);
318
+ }
299
319
  if (!dryRun && missing.length > 0) {
300
320
  deleteEntriesByIds(db, missing.map((e) => e.id));
301
321
  }
@@ -319,6 +339,24 @@ export async function akmIndex(options) {
319
339
  return akmIndexOverride(options);
320
340
  return akmIndexReal(options);
321
341
  }
342
+ let indexTransactionHookForTests;
343
+ /**
344
+ * TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
345
+ *
346
+ * Exists because the delete-then-reinsert atomicity guarantee is, by
347
+ * construction, invisible from outside the transaction: by the time
348
+ * `akmIndex()` resolves, the commit has already collapsed both generations
349
+ * into one observable state. Concurrency tests install a hook that opens a
350
+ * SECOND connection at these points and asserts it still sees the previous
351
+ * complete generation. Inert in production (one `undefined?.()` per reindex).
352
+ */
353
+ export function _setIndexTransactionHookForTests(hook) {
354
+ indexTransactionHookForTests = hook;
355
+ }
356
+ /** Fire a named in-transaction observation point (no-op outside tests). */
357
+ function indexTransactionHook(point) {
358
+ indexTransactionHookForTests?.(point);
359
+ }
322
360
  /**
323
361
  * Detect an adapter for every resolvable source that does not declare one, and
324
362
  * persist each detection into `config.json`.
@@ -976,6 +1014,10 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
976
1014
  // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
977
1015
  // re-resolves the rest by entry_ref — subsuming the old detach.
978
1016
  db.exec("DELETE FROM entries");
1017
+ // Atomicity observation point: inside the transaction the tables are now
1018
+ // empty, but no other connection may observe that. See
1019
+ // tests/integration/indexer/reindex-generation-atomicity.test.ts.
1020
+ indexTransactionHook("full-delete-applied");
979
1021
  }
980
1022
  for (const { dirPath, currentStashDir, files, stash, skip, reason, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
981
1023
  if (remove) {
@@ -1089,6 +1131,9 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1089
1131
  }
1090
1132
  }
1091
1133
  }
1134
+ // Atomicity observation point: the new generation is fully written but
1135
+ // uncommitted, so it must still be invisible to other connections.
1136
+ indexTransactionHook("records-persisted");
1092
1137
  });
1093
1138
  insertTransaction();
1094
1139
  deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
@@ -1317,9 +1362,20 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1317
1362
  const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
1318
1363
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
1319
1364
  if (storedFingerprint && storedFingerprint !== currentFingerprint) {
1320
- // Model/provider changed → stored vectors are incompatible. Clear them
1321
- // (same dimension, so keep the vec table); re-embedded by this index run.
1322
- purgeEmbeddings(db);
1365
+ // Model/provider changed → stored vectors are incompatible. Clear them;
1366
+ // re-embedded by this index run.
1367
+ //
1368
+ // The vec table goes too. "Same dimension, so keep the vec table" only held
1369
+ // for a same-width model swap: entries_vec is a vec0 virtual table declared
1370
+ // at a FIXED width, so after a dimension-changing model change every insert
1371
+ // failed against the old width, and ensureSchema's dim-change rebuild never
1372
+ // fired because it only runs for callers that pass an explicit
1373
+ // embeddingDim. The stale table survived `--full` — the exact remedy the
1374
+ // warning recommended. Clearing the stored dim lets the next ensureSchema
1375
+ // materialize it at the new width; until then the fast-path flag reads
1376
+ // false (no table) and search uses the complete BLOB table.
1377
+ purgeEmbeddings(db, { dropVecTable: true });
1378
+ deleteMeta(db, "embeddingDim");
1323
1379
  }
1324
1380
  try {
1325
1381
  const { embedBatch } = await import("../llm/embedder.js");
@@ -1364,6 +1420,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1364
1420
  let storedCount = 0;
1365
1421
  let skippedCount = 0;
1366
1422
  let vecFailedCount = 0;
1423
+ let vecUnavailableCount = 0;
1367
1424
  db.transaction(() => {
1368
1425
  for (let i = 0; i < allEntries.length; i++) {
1369
1426
  const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
@@ -1375,6 +1432,8 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1375
1432
  }
1376
1433
  if (res.vec === "failed")
1377
1434
  vecFailedCount++;
1435
+ if (res.vec === "unavailable")
1436
+ vecUnavailableCount++;
1378
1437
  }
1379
1438
  })();
1380
1439
  if (skippedCount > 0) {
@@ -1384,7 +1443,13 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1384
1443
  // instead of inferring readiness from stored-BLOB counts. Any failure
1385
1444
  // marks the fast path degraded, routing search to the JS-cosine fallback
1386
1445
  // over the (complete) BLOB table — honest degradation, not a hard failure.
1387
- setVecFastPathReady(db, vecFailedCount === 0);
1446
+ //
1447
+ // 'unavailable' has to degrade the flag too. It means no vec row was
1448
+ // written at all, so marking the fast path ready left a later open (a
1449
+ // different runtime, or sqlite-vec installed afterwards) trusting an
1450
+ // empty entries_vec and returning zero semantic hits against a fully
1451
+ // populated BLOB table.
1452
+ setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0);
1388
1453
  if (vecFailedCount > 0) {
1389
1454
  warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
1390
1455
  "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
@@ -43,6 +43,7 @@ import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-r
43
43
  import { todayIso } from "../../core/common.js";
44
44
  import { concurrentMap } from "../../core/concurrent.js";
45
45
  import { warn } from "../../core/warn.js";
46
+ import { recordWrittenPath } from "../../core/write-provenance.js";
46
47
  import { writeAssetToSource } from "../../core/write-source.js";
47
48
  import { isProcessEnabled } from "../../llm/feature-gate.js";
48
49
  import { resolveIndexPassLLM } from "../../llm/index-passes.js";
@@ -449,6 +450,9 @@ function markParentProcessed(parent) {
449
450
  const next = assembleAsset(updatedFm, block.content);
450
451
  try {
451
452
  fs.writeFileSync(parent.filePath, next, "utf8");
453
+ // #652: the parent's `inference_processed` stamp is a real asset mutation
454
+ // (the documented writeAssetToSource exception above) — journal it.
455
+ recordWrittenPath(parent.filePath);
452
456
  }
453
457
  catch (err) {
454
458
  warn(`memory inference: failed to mark parent processed ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1,27 +1,15 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- /**
5
- * Database-backed (SQLite + FTS5/vector) source search implementation.
6
- *
7
- * Extracted from source-search.ts to break the circular import:
8
- * source-search.ts → sources/providers/filesystem.ts → db-search.ts (no cycle)
9
- *
10
- * source-search.ts imports this module for the `searchLocal` export.
11
- * sources/providers/filesystem.ts also imports `searchLocal` from here.
12
- *
13
- * Renamed from `local-search.ts` to signal that this is the DB-layer search
14
- * implementation, not a "local vs. remote" distinction.
15
- */
16
- import fs from "node:fs";
17
4
  import path from "node:path";
18
5
  import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
19
6
  import { stashDirFor } from "../../core/asset/asset-placement.js";
20
7
  import { displayRef } from "../../core/asset/resolve-ref.js";
8
+ import { classifyPathAccess } from "../../core/path-access.js";
21
9
  import { getDbPath } from "../../core/paths.js";
22
10
  import { defaultRendererRegistry } from "../../core/type-presentation.js";
23
11
  import { warn } from "../../core/warn.js";
24
- import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
12
+ import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
25
13
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
26
14
  import { searchFts } from "../../storage/repositories/index-fts-repository.js";
27
15
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -98,6 +86,21 @@ export function shouldQueryPositiveFeedbackCounts(utilityDecayRaw) {
98
86
  return boost > 1.0;
99
87
  }
100
88
  // ── Main search entrypoint ───────────────────────────────────────────────────
89
+ /**
90
+ * Whether an embedding provider is actually configured.
91
+ *
92
+ * A remote provider needs BOTH endpoint and model. A LOCAL provider needs
93
+ * neither — `embedding.localModel` selects a transformers model that runs in
94
+ * process. Checking only the remote pair told every local-provider user that
95
+ * "no embedding provider is configured" and pointed them at
96
+ * `akm config set embedding '{"endpoint":...}'`, which is the wrong remedy and
97
+ * hid the real diagnostic recorded in the semantic status.
98
+ */
99
+ function hasConfiguredEmbeddingProvider(config) {
100
+ if (config.embedding?.localModel)
101
+ return true;
102
+ return Boolean(config.embedding?.endpoint && config.embedding?.model);
103
+ }
101
104
  export async function searchLocal(input) {
102
105
  const { query, searchType, limit, stashDir, sources, config } = input;
103
106
  const filters = input.filters;
@@ -117,7 +120,7 @@ export async function searchLocal(input) {
117
120
  if (rawStatus && rawStatus.providerFingerprint !== currentFingerprint) {
118
121
  warnings.push("Embedding config changed. Run 'akm index --full' to rebuild the semantic index with the new provider.");
119
122
  }
120
- else if (!config.embedding?.endpoint || !config.embedding?.model) {
123
+ else if (!hasConfiguredEmbeddingProvider(config)) {
121
124
  // #480: when semantic mode is `auto` but no embedding provider is
122
125
  // configured (e.g. `akm setup --yes` ran without picking one), telling
123
126
  // the user to "run akm setup" is misleading — they just did. Surface
@@ -132,7 +135,7 @@ export async function searchLocal(input) {
132
135
  }
133
136
  }
134
137
  if (config.semanticSearchMode === "auto" && semanticStatus === "blocked") {
135
- if (!config.embedding?.endpoint || !config.embedding?.model) {
138
+ if (!hasConfiguredEmbeddingProvider(config)) {
136
139
  // F7/A2: same predicate as the `pending` branch above (#480) — a
137
140
  // `blocked` status can outlive the provider config that produced it
138
141
  // (e.g. the embedding config was later unset). This is not a fault;
@@ -158,7 +161,12 @@ export async function searchLocal(input) {
158
161
  // reads serve the existing index as-is.
159
162
  await ensureIndex(stashDir);
160
163
  const dbPath = getDbPath();
161
- if (!fs.existsSync(dbPath)) {
164
+ // An index we cannot READ is not an index that does not exist (#791). Saying
165
+ // "No search index available" for a populated index the caller merely lacks
166
+ // permission on is a lie at exit 0 — and an agent consuming this JSON has no
167
+ // way to tell it from a genuine empty result, so it relays the lie onward.
168
+ assertIndexPathReadable(dbPath);
169
+ if (classifyPathAccess(dbPath).access === "absent") {
162
170
  return {
163
171
  hits: [],
164
172
  tip: "No search index available. Run 'akm index' to build one.",
@@ -123,7 +123,12 @@ function walkStashGit(stashRoot, options) {
123
123
  for (const relFile of files) {
124
124
  const absPath = path.join(stashRoot, relFile);
125
125
  try {
126
- if (fs.statSync(absPath).isFile()) {
126
+ // lstat, not stat: a tracked symlink must not be dereferenced. statSync
127
+ // follows the link, so a target outside stashRoot would be read and
128
+ // indexed. The manual walk below already skips symlinks for exactly this
129
+ // reason; the two walkers have to agree regardless of whether the stash
130
+ // happens to sit inside a git repo.
131
+ if (fs.lstatSync(absPath).isFile()) {
127
132
  results.push(buildFileContext(stashRoot, absPath));
128
133
  }
129
134
  }
@@ -74,6 +74,16 @@ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
74
74
  * on win32 or when the env supplies PATHEXT (which is also the seam tests use
75
75
  * to cover Windows resolution from a POSIX runner).
76
76
  */
77
+ /**
78
+ * Script shims that `child_process.spawn` cannot execute directly on Windows.
79
+ *
80
+ * Node refuses to spawn `.bat`/`.cmd` without a shell (the CVE-2024-27980
81
+ * hardening), so a detection that returns one of these reports an agent as
82
+ * available that dispatch then fails to launch. They stay in the candidate
83
+ * list — a shim is better than reporting the CLI as missing — but they sort
84
+ * last, so a real `.exe` next to `claude.cmd` wins.
85
+ */
86
+ const NON_SPAWNABLE_SUFFIXES = new Set([".cmd", ".bat"]);
77
87
  function executableSuffixes(envSource) {
78
88
  const pathext = envSource.PATHEXT ?? envSource.Pathext ?? envSource.pathext;
79
89
  if (process.platform !== "win32" && !pathext)
@@ -83,7 +93,9 @@ function executableSuffixes(envSource) {
83
93
  .map((ext) => ext.trim())
84
94
  .filter(Boolean)
85
95
  .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
86
- return ["", ...exts];
96
+ const directlySpawnable = exts.filter((ext) => !NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
97
+ const shims = exts.filter((ext) => NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
98
+ return ["", ...directlySpawnable, ...shims];
87
99
  }
88
100
  let detectOverrides;
89
101
  /** TEST-ONLY. Swap the detection implementations; pass undefined to restore. */
@@ -43,6 +43,29 @@ function resolveCredential(name, engine, config) {
43
43
  ? { names: [specific, "AKM_LLM_API_KEY"], required: false }
44
44
  : { names: [specific], required: false };
45
45
  }
46
+ /**
47
+ * Read a credential descriptor's value out of `process.env`: the FIRST
48
+ * non-empty trimmed value across `names`, in declared order. A `required`
49
+ * descriptor that resolves to nothing is a config error naming its PRIMARY
50
+ * variable — the one an operator is told to set.
51
+ *
52
+ * The ONE env-credential seam. The live-config dispatch boundary
53
+ * ({@link materializeLlmConnection}) and the FROZEN workflow dispatch boundary
54
+ * (`materializeFrozenLlm` in `workflows/exec/unit-dispatch.ts`, whose frozen
55
+ * snapshots carry a structurally identical descriptor) both read through it, so
56
+ * lookup order and the failure message cannot drift between them.
57
+ */
58
+ export function resolveCredentialFromEnv(credential) {
59
+ for (const name of credential?.names ?? []) {
60
+ const candidate = process.env[name]?.trim();
61
+ if (candidate)
62
+ return candidate;
63
+ }
64
+ if (credential?.required) {
65
+ throw new ConfigError(`Required engine credential ${credential.names[0]} is not set.`, "INVALID_CONFIG_FILE");
66
+ }
67
+ return undefined;
68
+ }
46
69
  /** Collect materialized engine credentials for output and persistence redaction. */
47
70
  export function collectEngineCredentialValues(config, envSource = process.env) {
48
71
  const values = new Set();
@@ -112,17 +135,7 @@ export function materializeLlmConnection(resolved) {
112
135
  throw new ConfigError(formatExtraParamsIssue(`Engine "${resolved.engine}" extraParams`, issue), "INVALID_CONFIG_FILE");
113
136
  }
114
137
  }
115
- let apiKey;
116
- for (const name of resolved.credential?.names ?? []) {
117
- const candidate = process.env[name]?.trim();
118
- if (candidate) {
119
- apiKey = candidate;
120
- break;
121
- }
122
- }
123
- if (resolved.credential?.required && !apiKey) {
124
- throw new ConfigError(`Required engine credential ${resolved.credential.names[0]} is not set.`, "INVALID_CONFIG_FILE");
125
- }
138
+ const apiKey = resolveCredentialFromEnv(resolved.credential);
126
139
  return {
127
140
  ...resolved.connection,
128
141
  ...(apiKey ? { apiKey } : {}),
@@ -13,7 +13,7 @@
13
13
  const BUILTIN_ALIASES = [
14
14
  {
15
15
  // Anthropic's Mythos-class tier above Opus — the recommended resolution
16
- // target for the `deep` workflow tier (see docs/reference/workflows.md).
16
+ // target for the `deep` workflow tier (see docs/reference/workflow-schema.md).
17
17
  alias: "fable",
18
18
  platforms: {
19
19
  claude: "claude-fable-5",
@@ -1,13 +1,21 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Built-in profile registry for external agent CLIs (v1 spec §12.1).
6
+ *
7
+ * A `AgentProfile` is the minimum metadata required to shell-out to a
8
+ * coding-agent CLI. Named engines lower canonical harness metadata into this
9
+ * intentionally small internal shape. The wrapper is in `./spawn.ts`.
10
+ */
11
+ import { COMMON_SPAWN_ENV_PASSTHROUGH } from "../../core/spawn-env.js";
4
12
  // AKM_EVENT_SOURCE carries usage-event provenance (improve/task) so that akm
5
13
  // invocations a spawned agent makes are recorded as machine traffic, not user
6
14
  // demand (DRIFT-6). Without it in the passthrough whitelist, buildChildEnv drops
7
15
  // the stamp at the agent boundary — e.g. `akm wiki ingest` spawns an agent whose
8
16
  // `akm curate/show/search` tool-calls then log source='user', silently inflating
9
17
  // every lane's read-back (GRR). It is a provenance tag, never a secret.
10
- const COMMON_PASSTHROUGH = ["HOME", "PATH", "USER", "LANG", "LC_ALL", "TERM", "TMPDIR", "AKM_EVENT_SOURCE"];
18
+ const COMMON_PASSTHROUGH = COMMON_SPAWN_ENV_PASSTHROUGH;
11
19
  /**
12
20
  * Built-in profiles for the agent CLIs akm knows out of the box: the five the
13
21
  * v1 spec calls out explicitly, plus the P2 harness adapters (copilot, pi,
@@ -14,90 +14,21 @@
14
14
  * NEVER imports an LLM SDK. Agents are reachable only via shell-out;
15
15
  * this is a pre-emptive guarantee against the #222 invariant.
16
16
  */
17
- import fs from "node:fs";
18
- import os from "node:os";
19
- import path from "node:path";
20
17
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
21
- import { runManagedSubprocess, } from "../../core/subprocess.js";
18
+ import { collectAllowlistedEnv } from "../../core/spawn-env.js";
19
+ import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js";
22
20
  import { getCommandBuilder } from "./builders.js";
23
21
  import { DEFAULT_AGENT_TIMEOUT_MS } from "./config.js";
24
- /**
25
- * Supplement `existingPath` with well-known user binary directories when
26
- * running in a scheduler context (cron/launchd) where PATH is stripped.
27
- *
28
- * Detection heuristic: if the current PATH does not contain the user's home
29
- * directory, we are likely in a stripped scheduler env. In an interactive
30
- * shell the user's home almost always appears (e.g. ~/.bun/bin, ~/.cargo/bin).
31
- *
32
- * Only directories that actually exist on disk are prepended, and only if
33
- * they are not already present, so interactive-shell PATH ordering is never
34
- * disturbed.
35
- */
36
- export function supplementPathForSchedulerContext(existingPath) {
37
- const home = os.homedir();
38
- // If PATH already contains the home directory, we are in an interactive
39
- // shell — skip supplementation entirely.
40
- if (existingPath.split(path.delimiter).some((d) => d.startsWith(home))) {
41
- return existingPath;
42
- }
43
- const candidates = pathCandidatesForCurrentPlatform(home);
44
- const existing = new Set(existingPath.split(path.delimiter).filter(Boolean));
45
- const toAdd = candidates.filter((d) => !existing.has(d) && fs.existsSync(d));
46
- if (toAdd.length === 0)
47
- return existingPath;
48
- return [...toAdd, existingPath].filter(Boolean).join(path.delimiter);
49
- }
50
- function pathCandidatesForCurrentPlatform(home) {
51
- if (process.platform === "win32") {
52
- // Windows: Bun + Cargo + Scoop + Chocolatey + system tools. Order favors
53
- // user-local installs over machine-global so the user's chosen toolchain
54
- // wins. These paths are commonly stripped from Task Scheduler / service
55
- // environments, mirroring the cron/launchd problem on POSIX.
56
- const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
57
- const userProfile = process.env.USERPROFILE ?? home;
58
- const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
59
- return [
60
- path.join(userProfile, ".bun", "bin"),
61
- path.join(localAppData, "Programs", "bun"),
62
- path.join(userProfile, ".cargo", "bin"),
63
- path.join(localAppData, "Programs", "Git", "cmd"),
64
- path.join(userProfile, "scoop", "shims"),
65
- path.join(programFiles, "Git", "cmd"),
66
- "C:\\ProgramData\\chocolatey\\bin",
67
- ];
68
- }
69
- return [
70
- path.join(home, ".bun", "bin"),
71
- path.join(home, ".cargo", "bin"),
72
- path.join(home, ".local", "bin"),
73
- "/opt/homebrew/bin",
74
- "/opt/homebrew/sbin",
75
- "/usr/local/bin",
76
- ];
77
- }
78
22
  /**
79
23
  * Build the child env. Starts empty and copies through:
80
- * • Every name in `profile.envPassthrough`.
24
+ * • Every name in `profile.envPassthrough` (via the shared
25
+ * {@link collectAllowlistedEnv}, which also supplements PATH for
26
+ * scheduler contexts where the inherited PATH is stripped).
81
27
  * • Every entry in `profile.env`.
82
28
  * • Every entry in `options.env` (highest precedence).
83
- *
84
- * PATH is supplemented with well-known user binary directories when running
85
- * in a scheduler context (cron/launchd) where the inherited PATH is stripped.
86
- * See {@link supplementPathForSchedulerContext}.
87
29
  */
88
30
  function buildChildEnv(profile, options) {
89
- const source = options.envSource ?? process.env;
90
- const env = {};
91
- for (const name of profile.envPassthrough) {
92
- const value = source[name];
93
- if (value !== undefined)
94
- env[name] = value;
95
- }
96
- // Supplement PATH after passthrough so the scheduler-context fix applies to
97
- // the value actually coming from the environment source.
98
- if (env.PATH !== undefined) {
99
- env.PATH = supplementPathForSchedulerContext(env.PATH);
100
- }
31
+ const env = collectAllowlistedEnv(profile.envPassthrough, options.envSource ?? process.env);
101
32
  if (profile.env) {
102
33
  for (const [k, v] of Object.entries(profile.env))
103
34
  env[k] = v;
@@ -108,19 +39,16 @@ function buildChildEnv(profile, options) {
108
39
  }
109
40
  return env;
110
41
  }
42
+ /**
43
+ * This path's phrasing of the SHARED incomplete-capture verdict
44
+ * ({@link streamCaptureFailure} in `core/subprocess.ts`). The classification
45
+ * lives in the primitive so the agent path and the workflow `exec` path cannot
46
+ * drift apart on what "the capture did not complete" means; only the sentence
47
+ * naming the profile is local. Message text is unchanged from the inlined copy.
48
+ */
111
49
  function streamFailureMessage(profileName, stdout, stderr) {
112
- const failures = [];
113
- if (stdout.error)
114
- failures.push(`stdout read failed: ${stdout.error instanceof Error ? stdout.error.message : String(stdout.error)}`);
115
- if (stderr.error)
116
- failures.push(`stderr read failed: ${stderr.error instanceof Error ? stderr.error.message : String(stderr.error)}`);
117
- if (stdout.timedOut)
118
- failures.push("stdout drain timed out");
119
- if (stderr.timedOut)
120
- failures.push("stderr drain timed out");
121
- if (failures.length === 0)
122
- return undefined;
123
- return `agent CLI "${profileName}" output capture failed: ${failures.join("; ")}`;
50
+ const failures = streamCaptureFailure(stdout, stderr);
51
+ return failures === undefined ? undefined : `agent CLI "${profileName}" output capture failed: ${failures}`;
124
52
  }
125
53
  /**
126
54
  * Spawn the agent CLI described by `profile` with `prompt` (forwarded as
@@ -118,6 +118,12 @@ let _testServer = null;
118
118
  // Test seam replacing the real `createOpencode` import (see __setServerFactory).
119
119
  let _serverFactory = null;
120
120
  let _exitHookInstalled = false;
121
+ /**
122
+ * True once `process.on("exit")` has fired. Close paths consult it because an
123
+ * exit handler runs after the event loop has stopped: no timer scheduled there
124
+ * will ever fire, so any cleanup that needs to happen must happen inline.
125
+ */
126
+ let _processExiting = false;
121
127
  /**
122
128
  * Test-only seam: inject a fake {@link SdkServer} so `runOpencodeSdk` can be
123
129
  * exercised without the real `@opencode-ai/sdk` (which would spin up a server).
@@ -424,6 +430,20 @@ async function createManagedOpencode(options) {
424
430
  }
425
431
  if (childExited())
426
432
  return;
433
+ if (_processExiting) {
434
+ // During `process.on("exit")` the event loop is finished, so a timer
435
+ // scheduled here would never fire and the SIGKILL escalation could not
436
+ // happen at all — a server child that ignores SIGTERM outlived akm.
437
+ // Exit handlers must be synchronous, so escalate immediately instead of
438
+ // waiting out a grace period we have no way to wait for.
439
+ try {
440
+ proc.kill("SIGKILL");
441
+ }
442
+ catch {
443
+ /* already dead */
444
+ }
445
+ return;
446
+ }
427
447
  closeEscalation = setTimeout(() => {
428
448
  closeEscalation = undefined;
429
449
  if (childExited())
@@ -525,6 +545,7 @@ async function startServer(profile, sdkConfig, env, registryKey, startupSignal)
525
545
  if (!_exitHookInstalled) {
526
546
  _exitHookInstalled = true;
527
547
  process.once("exit", () => {
548
+ _processExiting = true;
528
549
  void closeServer();
529
550
  });
530
551
  }