akm-cli 0.9.0 → 0.9.1-beta.1

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 (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. package/schemas/akm-workflow.json +85 -10
@@ -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";
@@ -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
@@ -290,12 +284,33 @@ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
290
284
  *
291
285
  * Only rows with a non-empty `file_path` are checked — remote/virtual entries
292
286
  * that have no local path are always skipped.
287
+ *
288
+ * "No longer exists" means ABSENT, never merely unreadable (#791). This pass
289
+ * DELETES rows, and `fs.existsSync` reported `false` for a file akm lacked
290
+ * permission to look at exactly as for one that had been removed — so a
291
+ * bundle temporarily mounted read-restricted (a uid mismatch, a tightened
292
+ * parent directory) had its whole index wiped, and the run reported the
293
+ * deletions as a clean success. Unreadable files keep their rows and are
294
+ * reported instead.
293
295
  */
294
296
  function runCleanPass(db, dryRun) {
295
297
  const allEntries = db.prepare("SELECT id, entry_key AS ref, file_path AS path FROM entries").all();
296
298
  // Only check entries that have a non-empty local path (skip remote/virtual).
297
299
  const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
298
- const missing = localEntries.filter((e) => !fs.existsSync(e.path));
300
+ const missing = [];
301
+ const unreadable = [];
302
+ for (const entry of localEntries) {
303
+ const { access, code } = classifyPathAccess(entry.path);
304
+ if (access === "absent")
305
+ missing.push(entry);
306
+ else if (access === "inaccessible")
307
+ unreadable.push({ path: entry.path, ...(code ? { code } : {}) });
308
+ }
309
+ if (unreadable.length > 0) {
310
+ const shown = unreadable.slice(0, 5).map((u) => describeInaccessiblePath(u.path, u.code));
311
+ warn(`Index clean pass kept ${unreadable.length} entr${unreadable.length === 1 ? "y" : "ies"} whose file akm cannot ` +
312
+ `read (unreadable is not deleted): ${shown.join("; ")}${unreadable.length > shown.length ? "; …" : ""}`);
313
+ }
299
314
  if (!dryRun && missing.length > 0) {
300
315
  deleteEntriesByIds(db, missing.map((e) => e.id));
301
316
  }
@@ -319,6 +334,24 @@ export async function akmIndex(options) {
319
334
  return akmIndexOverride(options);
320
335
  return akmIndexReal(options);
321
336
  }
337
+ let indexTransactionHookForTests;
338
+ /**
339
+ * TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
340
+ *
341
+ * Exists because the delete-then-reinsert atomicity guarantee is, by
342
+ * construction, invisible from outside the transaction: by the time
343
+ * `akmIndex()` resolves, the commit has already collapsed both generations
344
+ * into one observable state. Concurrency tests install a hook that opens a
345
+ * SECOND connection at these points and asserts it still sees the previous
346
+ * complete generation. Inert in production (one `undefined?.()` per reindex).
347
+ */
348
+ export function _setIndexTransactionHookForTests(hook) {
349
+ indexTransactionHookForTests = hook;
350
+ }
351
+ /** Fire a named in-transaction observation point (no-op outside tests). */
352
+ function indexTransactionHook(point) {
353
+ indexTransactionHookForTests?.(point);
354
+ }
322
355
  /**
323
356
  * Detect an adapter for every resolvable source that does not declare one, and
324
357
  * persist each detection into `config.json`.
@@ -976,6 +1009,10 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
976
1009
  // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
977
1010
  // re-resolves the rest by entry_ref — subsuming the old detach.
978
1011
  db.exec("DELETE FROM entries");
1012
+ // Atomicity observation point: inside the transaction the tables are now
1013
+ // empty, but no other connection may observe that. See
1014
+ // tests/integration/indexer/reindex-generation-atomicity.test.ts.
1015
+ indexTransactionHook("full-delete-applied");
979
1016
  }
980
1017
  for (const { dirPath, currentStashDir, files, stash, skip, reason, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
981
1018
  if (remove) {
@@ -1089,6 +1126,9 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1089
1126
  }
1090
1127
  }
1091
1128
  }
1129
+ // Atomicity observation point: the new generation is fully written but
1130
+ // uncommitted, so it must still be invisible to other connections.
1131
+ indexTransactionHook("records-persisted");
1092
1132
  });
1093
1133
  insertTransaction();
1094
1134
  deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
@@ -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";
@@ -158,7 +146,12 @@ export async function searchLocal(input) {
158
146
  // reads serve the existing index as-is.
159
147
  await ensureIndex(stashDir);
160
148
  const dbPath = getDbPath();
161
- if (!fs.existsSync(dbPath)) {
149
+ // An index we cannot READ is not an index that does not exist (#791). Saying
150
+ // "No search index available" for a populated index the caller merely lacks
151
+ // permission on is a lie at exit 0 — and an agent consuming this JSON has no
152
+ // way to tell it from a genuine empty result, so it relays the lie onward.
153
+ assertIndexPathReadable(dbPath);
154
+ if (classifyPathAccess(dbPath).access === "absent") {
162
155
  return {
163
156
  hits: [],
164
157
  tip: "No search index available. Run 'akm index' to build one.",
@@ -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
@@ -7,6 +7,7 @@ import { writeFileAtomic } from "../core/common.js";
7
7
  import { ConfigError, rethrowIfTestIsolationError } from "../core/errors.js";
8
8
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js";
9
9
  import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
10
+ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
10
11
  import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
11
12
  // ── Lock sentinel ────────────────────────────────────────────────────────────
12
13
  const LOCK_MAX_RETRIES = 3;
@@ -54,6 +55,29 @@ export function readLockfile() {
54
55
  return [];
55
56
  }
56
57
  }
58
+ /**
59
+ * Refuse to treat an UNREADABLE lockfile (or data dir) as an absent one (#791).
60
+ *
61
+ * Every write path here is read-modify-WRITE: it loads the current entries and
62
+ * writes the whole array back. An unreadable `akm.lock` that reads as `[]`
63
+ * therefore does not merely lose information — the very next
64
+ * `writeFileAtomic` replaces the operator's entire lock record with the single
65
+ * entry this call happened to be adding. That is the same catastrophe R-012
66
+ * guards against for a *corrupt* file, reached instead through a permission
67
+ * fault, and `fs.existsSync`/a swallowed `readFileSync` could not tell the two
68
+ * apart from "the file was never created".
69
+ *
70
+ * No-op when the path is genuinely absent — that case really does have nothing
71
+ * to preserve.
72
+ */
73
+ function assertLockfilePathReadable(target) {
74
+ const { access, code } = classifyPathAccess(target);
75
+ if (access !== "inaccessible")
76
+ return;
77
+ throw new ConfigError(`Refusing to modify the lockfile: ${describeInaccessiblePath(target, code)}. akm cannot read the existing lock ` +
78
+ "records, and writing over them would destroy every bundle they track. Fix the ownership or mode of that " +
79
+ "path (or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns) and retry.", "DATA_DIR_UNREADABLE");
80
+ }
57
81
  /**
58
82
  * Like {@link readLockfile}, but THROWS instead of silently degrading to `[]`
59
83
  * when the on-disk lockfile exists yet is not parseable JSON or not a JSON
@@ -82,7 +106,15 @@ function readLockfileOrThrow() {
82
106
  }
83
107
  catch (err) {
84
108
  rethrowIfTestIsolationError(err);
85
- return []; // File does not exist (or is otherwise unreadable) — nothing to preserve.
109
+ // "Missing file" is the only failure with nothing to preserve. An
110
+ // UNREADABLE lockfile has everything to preserve and we cannot see it —
111
+ // degrading it to `[]` here is precisely the destructive overwrite this
112
+ // function was written to prevent, only triggered by a permission fault
113
+ // instead of a corrupt file (#791). Classify AFTER the failed read so the
114
+ // happy path costs no extra syscall and the answer describes the failure
115
+ // we actually got.
116
+ assertLockfilePathReadable(lockfilePath);
117
+ return [];
86
118
  }
87
119
  let parsed;
88
120
  try {
@@ -172,6 +204,11 @@ export async function upsertLockEntry(entry) {
172
204
  function readLockEntriesForMigration() {
173
205
  let existing = [];
174
206
  const lockfilePath = getLockfilePath();
207
+ // `mergeLockEntriesSync` writes `existing` straight back out, so an
208
+ // unreadable lockfile read as absent would be overwritten with just the
209
+ // migrator's sparse entries (#791). This is also what
210
+ // `assertMigrationLockfileReadable` promises to have checked.
211
+ assertLockfilePathReadable(lockfilePath);
175
212
  if (fs.existsSync(lockfilePath)) {
176
213
  let raw;
177
214
  try {
@@ -211,7 +248,13 @@ export function mergeLockEntriesSync(entries) {
211
248
  writeLockfileUnlocked([...existing.filter((e) => !incomingIds.has(e.id)), ...merged]);
212
249
  }
213
250
  export async function removeLockEntry(id) {
214
- if (!fs.existsSync(getDataDir()))
251
+ // Returning early says "there is no lock record to remove", and the uninstall
252
+ // that called us reports success on that basis. Only an absent data dir earns
253
+ // it — one we cannot read may hold the very entry we were asked to drop, and
254
+ // silently leaving it behind is how a bundle stays "installed" forever (#791).
255
+ const dataDir = getDataDir();
256
+ assertLockfilePathReadable(dataDir);
257
+ if (!fs.existsSync(dataDir))
215
258
  return;
216
259
  const release = await acquireLockSentinel();
217
260
  try {
@@ -16,9 +16,18 @@ function glyphFor(fixed) {
16
16
  return { glyph: "✓", severityRank: 2 };
17
17
  return { glyph: "⚠", severityRank: 1 };
18
18
  }
19
+ /**
20
+ * `file:line` when the finding is line-anchored (workflow parse/compile
21
+ * errors), bare `file` otherwise. `LintIssue.line` is optional precisely
22
+ * because most lint sources are whole-file, so their headline is byte-identical
23
+ * to what it has always been.
24
+ */
25
+ function locationOf(issue) {
26
+ return typeof issue.line === "number" ? `${issue.file}:${issue.line}` : issue.file;
27
+ }
19
28
  function issueEntry(issue) {
20
29
  const { glyph, severityRank } = glyphFor(issue.fixed);
21
- return { severityRank, glyph, headline: `${issue.file} [${issue.issue}] ${issue.detail}` };
30
+ return { severityRank, glyph, headline: `${locationOf(issue)} [${issue.issue}] ${issue.detail}` };
22
31
  }
23
32
  function renderIssueSection(title, issues) {
24
33
  if (issues.length === 0)
@@ -30,14 +39,18 @@ export function formatLintPlain(r) {
30
39
  return null;
31
40
  const fixed = Array.isArray(r.fixed) ? r.fixed : [];
32
41
  const flagged = Array.isArray(r.flagged) ? r.flagged : [];
42
+ const warnings = Array.isArray(r.warnings) ? r.warnings : [];
33
43
  const summary = r.summary;
34
44
  const lines = [];
35
45
  if (typeof r.ok === "boolean")
36
46
  lines.push(`ok: ${r.ok}`);
37
- lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}`);
38
- // Flagged (still needs attention) surfaces before fixed (already handled)
39
- // so a scan of the output hits the actionable items first.
47
+ lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}` +
48
+ ` warnings=${summary?.warnings ?? warnings.length}`);
49
+ // Flagged (still needs attention) surfaces before warnings (advisory,
50
+ // non-fatal) and fixed (already handled), so a scan of the output hits the
51
+ // actionable items first.
40
52
  lines.push("", ...renderIssueSection("flagged", flagged));
53
+ lines.push("", ...renderIssueSection("warnings", warnings));
41
54
  lines.push("", ...renderIssueSection("fixed", fixed));
42
55
  return lines.join("\n").trim();
43
56
  }