akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
7
7
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
8
  import { asNonEmptyString } from "../../core/common.js";
9
- import { isVerbose, warn, warnVerbose } from "../../core/warn.js";
9
+ import { isVerbose, warn } from "../../core/warn.js";
10
10
  export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
11
11
  // ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
12
12
  /**
@@ -835,10 +835,17 @@ export function isEnrichmentComplete(entry) {
835
835
  // ── Native Markdown search projection ──────────────────────────────────────
836
836
  /**
837
837
  * Maximum native Markdown prose carried by the low-weight `content` field.
838
- * Structured fields remain separate and higher-weighted; this bound prevents
839
- * large documents from dominating index size or embedding inputs.
838
+ *
839
+ * Raised far past any real authored document:
840
+ * this used to sit at 16_384 chars, tight enough that ordinary long-form
841
+ * skills/knowledge docs lost their tail from both FTS and the embedding
842
+ * input with no visible signal (the cut was reported via `warnVerbose`,
843
+ * silent unless `AKM_VERBOSE` was set). The remaining bound exists only to
844
+ * stop a truly pathological single file (a committed data dump, a decompressed
845
+ * log) from ballooning index size — not to shave real content — so a caller
846
+ * that hits it is always told, unconditionally.
840
847
  */
841
- export const MARKDOWN_CONTENT_MAX_CHARS = 16_384;
848
+ export const MARKDOWN_CONTENT_MAX_CHARS = 1_000_000;
842
849
  /**
843
850
  * Locate a leading nested frontmatter block in a body: up to three blank
844
851
  * lines, then a `---` line, closed by a later `---` line. Mirrors the
@@ -1220,7 +1227,11 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1220
1227
  entry.content = contentProjection;
1221
1228
  if (truncationInfo.truncated) {
1222
1229
  entry.contentTruncated = true;
1223
- warnVerbose(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
1230
+ // Unconditional, not warnVerbose: this bound now sits far past any
1231
+ // real document, so tripping it means
1232
+ // something unusual is in the bundle and the operator should see
1233
+ // it without having to pass --verbose.
1234
+ warn(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
1224
1235
  }
1225
1236
  }
1226
1237
  }
@@ -1,18 +1,6 @@
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
- * Per-field search text extraction for FTS5 indexing.
6
- *
7
- * Extracted from indexer.ts to break the circular dependency:
8
- * db.ts -> indexer.ts -> db.ts
9
- *
10
- * This module imports only from metadata.ts (for the IndexDocument type),
11
- * so it can be safely imported by both db.ts and indexer.ts.
12
- */
13
- import { warnVerbose } from "../../core/warn.js";
14
- /** Structured metadata plus bounded body text supplied to embedding providers. */
15
- export const SEARCH_TEXT_MAX_CHARS = 8_192;
16
4
  /**
17
5
  * Return per-field search text for multi-column FTS5 indexing.
18
6
  *
@@ -90,25 +78,8 @@ export function buildSearchText(entry) {
90
78
  const structured = [fields.name, fields.description, fields.tags, fields.hints]
91
79
  .filter((field) => field.length > 0)
92
80
  .join(" ");
93
- if (structured.length >= SEARCH_TEXT_MAX_CHARS) {
94
- warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars (content dropped entirely)`);
95
- return truncateUnicodeSafe(structured, SEARCH_TEXT_MAX_CHARS);
96
- }
97
81
  if (!fields.content)
98
82
  return structured;
99
83
  const separator = structured ? " " : "";
100
- const remaining = SEARCH_TEXT_MAX_CHARS - structured.length - separator.length;
101
- if (fields.content.length > remaining) {
102
- warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars`);
103
- }
104
- return `${structured}${separator}${truncateUnicodeSafe(fields.content, remaining)}`;
105
- }
106
- function truncateUnicodeSafe(text, maxChars) {
107
- if (text.length <= maxChars)
108
- return text;
109
- let cut = text.slice(0, maxChars);
110
- const lastCode = cut.charCodeAt(cut.length - 1);
111
- if (lastCode >= 0xd800 && lastCode <= 0xdbff)
112
- cut = cut.slice(0, -1);
113
- return cut.trimEnd();
84
+ return `${structured}${separator}${fields.content}`;
114
85
  }
@@ -8,6 +8,7 @@ import { deepMergeConfig } from "../../core/config/deep-merge.js";
8
8
  import { ConfigError } from "../../core/errors.js";
9
9
  import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
10
10
  import { collectSensitiveValues } from "../../core/redaction.js";
11
+ import { warn } from "../../core/warn.js";
11
12
  import { getHarness } from "../harnesses/index.js";
12
13
  import { DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_LLM_TIMEOUT_MS } from "./config.js";
13
14
  import { getBuiltinAgentProfile } from "./profiles.js";
@@ -205,7 +206,20 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
205
206
  }
206
207
  const engine = resolveEngineConfig(name, config);
207
208
  if (engine.kind !== "llm") {
208
- throw new ConfigError(`Engine "${name}" is not an LLM engine.`, "INVALID_CONFIG_FILE");
209
+ const defaults = ownValue(config, "defaults");
210
+ const fallbackName = ownValue(engine, "llmEngine") ?? (defaults ? ownValue(defaults, "llmEngine") : undefined);
211
+ const fallbackEngine = fallbackName ? resolveEngineConfig(fallbackName, config) : undefined;
212
+ if (!fallbackEngine || fallbackEngine.kind !== "llm") {
213
+ if (options.optional)
214
+ return undefined;
215
+ throw new ConfigError(fallbackName
216
+ ? `Engine "${name}" is not an LLM engine, and its llmEngine fallback "${fallbackName}" is not one either.`
217
+ : `Engine "${name}" is not an LLM engine, and has no llmEngine fallback configured.`, "INVALID_CONFIG_FILE");
218
+ }
219
+ warn(`[akm] Engine "${name}" is an agent engine, not an LLM engine; using its llmEngine "${fallbackName}" instead.`);
220
+ return options.optional
221
+ ? resolveLlmEngineUse(config, [{ engine: fallbackName }], { optional: true })
222
+ : resolveLlmEngineUse(config, [{ engine: fallbackName }]);
209
223
  }
210
224
  let connection = rawLlmConnection(engine);
211
225
  for (const layer of layers) {
@@ -9,6 +9,7 @@ import { writeFileAtomic } from "../../core/common.js";
9
9
  import { ENGINE_NAME_PATTERN_SOURCE } from "../../core/config/engine-semantics.js";
10
10
  import { ConfigError, UsageError } from "../../core/errors.js";
11
11
  import { getConfigDir } from "../../core/paths.js";
12
+ import { warnOnce } from "../../core/warn.js";
12
13
  import { cloneExecutionJsonObject } from "../../execution/json.js";
13
14
  /**
14
15
  * Installed and operator-owned model intent aliases (#802 / WP2).
@@ -218,10 +219,16 @@ export function resolveModelMapAlias(input, engine, map) {
218
219
  const profile = ownValue(tier, selectedEngine);
219
220
  if (profile !== undefined)
220
221
  return selectionFromProfile(input, profile);
221
- if (tier !== undefined) {
222
- throw new ConfigError(`Known alias ${JSON.stringify(input)} has no model mapping for selected engine ${JSON.stringify(engine)}.`, "INVALID_CONFIG_FILE", `Add $.aliases.${alias}.${engine} to models.json.`);
222
+ const knownAliasUnmappedForEngine = tier !== undefined;
223
+ if (knownAliasUnmappedForEngine) {
224
+ warnOnce(`model-map-alias-no-engine-mapping:${alias}:${selectedEngine}`, `[akm] Model alias ${JSON.stringify(input)} has no mapping for engine ${JSON.stringify(engine)}; using ${JSON.stringify(input)} as the literal model name. Add $.aliases.${alias}.${engine} to models.json to map it.`);
223
225
  }
224
- return Object.freeze({ input, interpretation: "exact", model: input });
226
+ return Object.freeze({
227
+ input,
228
+ interpretation: "exact",
229
+ model: input,
230
+ ...(knownAliasUnmappedForEngine ? { unmappedForEngine: true } : {}),
231
+ });
225
232
  }
226
233
  export function userModelMapPath(env = process.env) {
227
234
  return path.join(getConfigDir(env), "models.json");
@@ -235,25 +242,24 @@ function modelMapFileError(label, filePath, action) {
235
242
  * absence; dangling links and every non-regular type are configuration errors.
236
243
  */
237
244
  function readModelMapFile(filePath, label, optional) {
238
- let linkStat;
245
+ let targetStat;
239
246
  try {
240
- linkStat = fs.lstatSync(filePath);
247
+ targetStat = fs.statSync(filePath);
241
248
  }
242
249
  catch (error) {
243
250
  if (optional && error?.code === "ENOENT")
244
251
  return undefined;
245
252
  throw modelMapFileError(label, filePath, optional ? "inspected" : "found");
246
253
  }
247
- if (!linkStat.isFile()) {
248
- throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Move the symlink or non-regular target aside, or replace it with a readable regular models.json file.");
254
+ if (!targetStat.isFile()) {
255
+ throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Replace it with a readable regular models.json file, or a symlink to one.");
249
256
  }
250
- const noFollow = process.platform !== "win32" && typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
251
257
  const nonblock = process.platform !== "win32" && typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
252
258
  let fd;
253
259
  try {
254
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonblock);
260
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | nonblock);
255
261
  const openedStat = fs.fstatSync(fd);
256
- if (!sameFileIdentity(linkStat, openedStat)) {
262
+ if (!sameFileIdentity(targetStat, openedStat)) {
257
263
  throw new ConfigError(`${label} changed while it was being opened: ${filePath}.`, "INVALID_CONFIG_FILE", "Retry after ensuring no other process is replacing models.json.");
258
264
  }
259
265
  const text = fs.readFileSync(fd, "utf8");
@@ -315,9 +315,18 @@ export function buildReflectPrompt(input) {
315
315
  // asset content into shorter prose, drops concrete structure, or strips
316
316
  // load-bearing frontmatter. Loud and explicit so small models follow.
317
317
  //
318
- // maxOutputChars is hoisted so the return value can include it for callers
319
- // on the LLM path that want to set a hard max_tokens cap on the request.
320
- let maxOutputChars;
318
+ // Guard-audit finding 15: this used to also hand back a maxOutputChars
319
+ // value so an LLM-path caller could convert it into a hard `max_tokens`
320
+ // cap on the API request. llm/client.ts's own doc comment (and
321
+ // commands/improve/reflect.ts's recorded history of responses actually
322
+ // getting cut off) is explicit that a character-derived max_tokens causes
323
+ // silent truncation — a real model's output is measured in tokens, not
324
+ // characters, and the ratio between the two varies enough that any fixed
325
+ // conversion either truncates legitimate output or provides no real cap at
326
+ // all. The size policy below is already enforced twice more (the prompt
327
+ // rules the model reads, and the post-processor's own size check), so nothing
328
+ // is lost by not adding a THIRD, byte-derived enforcement point that can
329
+ // only ever cut a response off early, never usefully re-check it.
321
330
  if (input.ref && input.assetContent?.trim()) {
322
331
  // Strip frontmatter to get source body length — mirrors checkReflectSize which
323
332
  // compares body-only lengths. Inline regex avoids importing parseFrontmatter.
@@ -332,8 +341,6 @@ export function buildReflectPrompt(input) {
332
341
  const showCharBounds = sourceBodyLen >= 200;
333
342
  const minChars = Math.max(Math.round(0.5 * sourceBodyLen), 150);
334
343
  const maxChars = Math.min(Math.max(Math.round(2.5 * sourceBodyLen), 2500), 25000);
335
- if (showCharBounds)
336
- maxOutputChars = maxChars;
337
344
  sections.push([
338
345
  "## Content preservation rules (MUST follow)",
339
346
  "1. PRESERVE ALL concrete content: code blocks, fenced snippets, CLI commands, numbered/bulleted checklists, tables, YAML/JSON examples, file paths, configuration keys, environment variable names, and CSS/HTML selectors. These are load-bearing — do NOT replace them with prose summaries.",
@@ -353,7 +360,7 @@ export function buildReflectPrompt(input) {
353
360
  sections.push(`IMPORTANT: The JSON "ref" field is REQUIRED. It MUST be exactly: "${input.ref}"`);
354
361
  }
355
362
  sections.push(reflectResponseContract(input));
356
- return { prompt: sections.join("\n\n"), ...(maxOutputChars !== undefined ? { maxOutputChars } : {}) };
363
+ return { prompt: sections.join("\n\n") };
357
364
  }
358
365
  /**
359
366
  * Build the prompt for `akm propose <type> <name> --task ...`. Asks the
@@ -9,14 +9,24 @@ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquire
9
9
  import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
10
10
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
11
11
  import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
12
+ import { warn } from "../core/warn.js";
12
13
  // ── Lock sentinel ────────────────────────────────────────────────────────────
13
- const LOCK_MAX_RETRIES = 3;
14
- const LOCK_RETRY_DELAY_MS = 100;
14
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
15
+ const LOCK_RETRY_INITIAL_DELAY_MS = 50;
16
+ const LOCK_RETRY_MAX_DELAY_MS = 1_000;
17
+ let lockAcquireTimeoutMsForTests;
18
+ export function _setLockAcquireTimeoutMsForTests(ms) {
19
+ lockAcquireTimeoutMsForTests = ms;
20
+ }
15
21
  async function acquireLockSentinel() {
16
22
  const sentinelPath = getLockfileLockPath();
17
23
  // Ensure the directory exists before attempting to create the sentinel.
18
24
  fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
19
- for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
25
+ const timeoutMs = lockAcquireTimeoutMsForTests ?? LOCK_ACQUIRE_TIMEOUT_MS;
26
+ const deadline = Date.now() + timeoutMs;
27
+ let delayMs = LOCK_RETRY_INITIAL_DELAY_MS;
28
+ let announced = false;
29
+ for (;;) {
20
30
  const releaseBarrier = acquireMaintenanceBarrier();
21
31
  try {
22
32
  const ownership = tryAcquireLockSync(sentinelPath, createLockPayload());
@@ -31,12 +41,17 @@ async function acquireLockSentinel() {
31
41
  finally {
32
42
  releaseBarrier();
33
43
  }
34
- // Another process holds the lock — wait briefly before retrying.
35
- if (attempt < LOCK_MAX_RETRIES - 1) {
36
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
44
+ // Another process holds the lock.
45
+ if (Date.now() >= deadline) {
46
+ throw new ConfigError(`Could not acquire lockfile sentinel at ${sentinelPath} after ${(timeoutMs / 1000).toFixed(1)}s; refusing to write without exclusive ownership.`, "INVALID_CONFIG_FILE");
47
+ }
48
+ if (!announced) {
49
+ announced = true;
50
+ warn("[akm] Waiting for another akm process to release the lockfile...");
37
51
  }
52
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
53
+ delayMs = Math.min(delayMs * 2, LOCK_RETRY_MAX_DELAY_MS);
38
54
  }
39
- throw new ConfigError(`Could not acquire lockfile sentinel at ${sentinelPath}; refusing to write without exclusive ownership.`, "INVALID_CONFIG_FILE");
40
55
  }
41
56
  // ── Read / Write ────────────────────────────────────────────────────────────
42
57
  export function readLockfile() {
@@ -9,11 +9,12 @@
9
9
  */
10
10
  import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
11
11
  import { resolveSecret } from "../core/config/config.js";
12
- import { ENV_REFERENCE_PATTERN } from "../core/config/schema/primitives.js";
12
+ import { isApiKeyReference } from "../core/config/schema/primitives.js";
13
13
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
14
14
  import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
15
15
  import { warn, warnVerbose } from "../core/warn.js";
16
16
  import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
17
+ import { resolveSecretFromStore } from "../sources/snapshot-fetchers/secret-seam.js";
17
18
  import { emitLlmUsage, extractUsageTokens, } from "./usage-telemetry.js";
18
19
  /** Maximum length of an upstream response excerpt included in thrown errors. */
19
20
  const ERROR_BODY_MAX_LEN = 200;
@@ -264,13 +265,16 @@ async function chatCompletionAttemptOnce(config, messages, options, timeoutMs, i
264
265
  throw new Error(formatExtraParamsIssue("LLM extraParams", issue));
265
266
  }
266
267
  const headers = { "Content-Type": "application/json" };
267
- // Resolve ONLY a whole-string env reference. The execution boundary normally
268
- // hands us a materialized credential after resolving `$VAR` upstream, so
269
- // re-running the substitution over a literal key mangled any credential
270
- // containing `$` — `sk-live$ecret` lost everything from the `$` onward, and
271
- // the request failed with an opaque 401. The narrow check keeps the symbolic
272
- // form working for any direct caller that still passes one.
273
- const resolvedKey = ENV_REFERENCE_PATTERN.test(config.apiKey ?? "") ? resolveSecret(config.apiKey) : config.apiKey;
268
+ // Resolve ONLY a whole-string reference ($VAR/${VAR} or secret://<name>).
269
+ // The execution boundary normally hands us a materialized credential after
270
+ // resolving the reference upstream, so re-running the substitution over a
271
+ // literal key mangled any credential containing `$` — `sk-live$ecret` lost
272
+ // everything from the `$` onward, and the request failed with an opaque
273
+ // 401. The narrow check keeps the symbolic form working for any direct
274
+ // caller that still passes one.
275
+ const resolvedKey = isApiKeyReference(config.apiKey ?? "")
276
+ ? resolveSecret(config.apiKey, resolveSecretFromStore)
277
+ : config.apiKey;
274
278
  if (resolvedKey) {
275
279
  headers.Authorization = `Bearer ${resolvedKey}`;
276
280
  }
@@ -447,3 +451,19 @@ export async function probeLlmReachable(config) {
447
451
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
448
452
  }
449
453
  }
454
+ /**
455
+ * Reachability probe for `akm health` (#914): one GET against the
456
+ * OpenAI-compatible `/models` route, bounded by `timeoutMs`. Any HTTP
457
+ * response counts as reachable — the question is whether the endpoint
458
+ * answers, not whether the route exists or the credential is right — so a
459
+ * cold local server is never asked to load a model just to be checked.
460
+ */
461
+ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
462
+ try {
463
+ await fetch(`${config.endpoint.replace(/\/+$/, "")}/models`, { signal: AbortSignal.timeout(timeoutMs) });
464
+ return { reachable: true };
465
+ }
466
+ catch (err) {
467
+ return { reachable: false, error: err instanceof Error ? err.message : String(err) };
468
+ }
469
+ }
@@ -11,6 +11,7 @@ import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/com
11
11
  import { resolveSecret } from "../../core/config/config.js";
12
12
  import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
13
13
  import { warnVerbose } from "../../core/warn.js";
14
+ import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-seam.js";
14
15
  /**
15
16
  * Upper bound on the number of documents in one HTTP request, independent of
16
17
  * the token budget below. Overridable via `config.batchSize`. Purely a
@@ -215,7 +216,7 @@ export class RemoteEmbedder {
215
216
  }
216
217
  buildHeaders() {
217
218
  const headers = { "Content-Type": "application/json" };
218
- const resolvedKey = resolveSecret(this.config.apiKey);
219
+ const resolvedKey = resolveSecret(this.config.apiKey, resolveSecretFromStore);
219
220
  if (resolvedKey) {
220
221
  headers.Authorization = `Bearer ${resolvedKey}`;
221
222
  }
@@ -231,7 +232,7 @@ export class RemoteEmbedder {
231
232
  * far unredacted and uncapped, at readBodyWithByteCap's 10 MB default.
232
233
  */
233
234
  safeErrorBody(body) {
234
- const resolvedKey = resolveSecret(this.config.apiKey);
235
+ const resolvedKey = resolveSecret(this.config.apiKey, resolveSecretFromStore);
235
236
  return redactSensitiveText(redactErrorBody(body), resolvedKey ? [resolvedKey] : []);
236
237
  }
237
238
  }
@@ -1,7 +1,7 @@
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
- import { ConfigError } from "../core/errors.js";
4
+ import { warn } from "../core/warn.js";
5
5
  import { cloneExecutionJsonObject } from "../execution/json.js";
6
6
  import { lowerResolvedExecutionRequest } from "../integrations/agent/execution-lowering.js";
7
7
  import { prepareInlineExecution } from "../integrations/agent/inline-execution.js";
@@ -48,7 +48,8 @@ export function resolveIndexPassExecution(passName, config) {
48
48
  });
49
49
  const lowered = lowerResolvedExecutionRequest(prepared.request, prepared.config);
50
50
  if (lowered.runner.kind !== "llm") {
51
- throw new ConfigError(`Index pass ${JSON.stringify(passName)} requires an LLM engine; ${JSON.stringify(selectedEngine)} is not one.`, "INVALID_CONFIG_FILE");
51
+ warn("[akm] Index pass %s requires an LLM engine; %s is not one. Skipping this pass.", passName, selectedEngine);
52
+ return Object.freeze({ runner: undefined, notices: NO_LOWERING_NOTICES });
52
53
  }
53
54
  return Object.freeze({ runner: lowered.runner, notices: lowered.notices });
54
55
  }
@@ -3,7 +3,11 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  // #484: stamp schemaVersion + shape discriminator on passthrough envelopes so
5
5
  // third-party consumers can pin a schema version and dispatch on shape uniformly.
6
- // Idempotent never overwrites an existing schemaVersion or shape field.
6
+ // #918: also stamp `ok: true` so a caller branching on `.ok` sees the same
7
+ // field on every success envelope that it sees on the `{ok:false,...}` error
8
+ // envelope. Never overwrites an existing `shape`, `schemaVersion`, or `ok`;
9
+ // a command whose exit code grades the outcome emits through
10
+ // `outputWithExitCode` (src/cli/shared.ts), which sets `ok` from that code.
7
11
  //
8
12
  // Builds a shallow copy rather than mutating `result` in place: several
9
13
  // command results (e.g. `akm task sync --dry-run`'s `SchedulerPlanPreview`,
@@ -20,9 +24,11 @@ function makeStampHandler(command) {
20
24
  if (typeof result !== "object" || Array.isArray(result))
21
25
  return result;
22
26
  const obj = result;
23
- if (obj.shape !== undefined && obj.schemaVersion !== undefined)
24
- return obj;
25
27
  return {
28
+ // `ok` first so it heads the printed envelope instead of trailing a
29
+ // (possibly large) dump — the spread below still wins with `obj`'s own
30
+ // `ok` value when present, only its position is fixed here.
31
+ ok: obj.ok ?? true,
26
32
  ...obj,
27
33
  shape: obj.shape ?? command,
28
34
  schemaVersion: obj.schemaVersion ?? 1,
@@ -17,7 +17,7 @@
17
17
  * and throws for unknown commands (v1 spec §9 — exhaustive registry, no silent
18
18
  * fallback).
19
19
  */
20
- import { UsageError } from "../core/errors.js";
20
+ import { warnOnce } from "../core/warn.js";
21
21
  import { curateShapes } from "./shapes/curate.js";
22
22
  import { envListShapes } from "./shapes/env-list.js";
23
23
  import { eventsShapes } from "./shapes/events.js";
@@ -66,13 +66,60 @@ registerOutputShapes(BUILT_IN_OUTPUT_SHAPES);
66
66
  * for a soon-frozen contract, not a silent fallback to `human`).
67
67
  */
68
68
  const SHAPE_SUMMARY_COMMANDS = new Set(["show"]);
69
+ // ── `results` collection alias ──────────────────────────────────────────────
70
+ //
71
+ // Every list-returning command names its collection differently (`hits`,
72
+ // `items`, `proposals`, `sources`, ...) — a caller cannot write one accessor
73
+ // across commands without a per-command lookup table, and the wrong guess
74
+ // (`d.get("hits")` against a `curate` response) reads as "no results" rather
75
+ // than "wrong key", with nothing in the envelope to correct it.
76
+ //
77
+ // This maps each list-returning command to the field already holding its
78
+ // collection, and `withResultsAlias` below adds a `results` key pointing at
79
+ // that SAME array (not a copy) to the shaped output — in every `--shape` /
80
+ // `--detail` combination, `human` included, so `--shape agent` needs no
81
+ // separate handling to "guarantee" it. A new list-returning command MUST add
82
+ // an entry here; there is no way to detect a missed one automatically, so the
83
+ // survey deliberately lives in this one place rather than scattered per
84
+ // handler.
85
+ const LIST_RESULT_COLLECTION_KEYS = {
86
+ search: "hits",
87
+ curate: "items",
88
+ "registry-search": "hits",
89
+ "proposal-list": "proposals",
90
+ list: "sources", // `akm bundle list`
91
+ "env-list": "envs",
92
+ "secret-list": "secrets",
93
+ "registry-list": "registries",
94
+ "workflow-list": "runs",
95
+ "task-history": "rows",
96
+ "log-list": "events", // `akm log list`
97
+ };
98
+ function withResultsAlias(command, shaped) {
99
+ const key = LIST_RESULT_COLLECTION_KEYS[command];
100
+ if (!key)
101
+ return shaped;
102
+ if (shaped === null || typeof shaped !== "object" || Array.isArray(shaped))
103
+ return shaped;
104
+ const obj = shaped;
105
+ if ("results" in obj)
106
+ return shaped;
107
+ const collection = obj[key];
108
+ if (!Array.isArray(collection))
109
+ return shaped;
110
+ // Same array reference as `obj[key]`, never a copy, so `results` cannot
111
+ // silently drift out of sync with the semantic key it aliases.
112
+ return { ...obj, results: collection };
113
+ }
69
114
  export function shapeForCommand(command, result, detail, shape = "human") {
115
+ let effectiveShape = shape;
70
116
  if (shape === "summary" && !SHAPE_SUMMARY_COMMANDS.has(command)) {
71
- throw new UsageError(`'--shape summary' is not supported for 'akm ${command}'. It is only available on 'akm show'.`, "INVALID_SHAPE_VALUE");
117
+ warnOnce(`shape-summary-unsupported:${command}`, `[output] '--shape summary' is not supported for 'akm ${command}' (only 'akm show' has a summary projection); falling back to 'agent'.`);
118
+ effectiveShape = "agent";
72
119
  }
73
120
  const handler = getOutputShapeHandler(command);
74
121
  if (handler) {
75
- return handler(result, detail, shape);
122
+ return withResultsAlias(command, handler(result, detail, effectiveShape));
76
123
  }
77
124
  // v1 spec §9 (output-shape registry exhaustive): no silent JSON.stringify
78
125
  // fallback. A missing case here is a registration bug — fail loudly so
@@ -191,6 +191,7 @@ export function formatProposalDrainPlain(r) {
191
191
  const deferred = Array.isArray(r.deferred) ? r.deferred : [];
192
192
  const skippedByCap = Array.isArray(r.skippedByCap) ? r.skippedByCap : [];
193
193
  const staged = Array.isArray(r.staged) ? r.staged : [];
194
+ const failed = Array.isArray(r.failed) ? r.failed : [];
194
195
  const prefix = r.dryRun === true ? "[dry-run] " : "";
195
196
  const lines = [
196
197
  `${prefix}Drained proposal queue (strategy=${String(r.strategy ?? "?")}, policy=${policy}, applyMode=${applyMode})`,
@@ -199,10 +200,14 @@ export function formatProposalDrainPlain(r) {
199
200
  ` deferred: ${deferred.length}`,
200
201
  ` skippedByCap: ${skippedByCap.length}`,
201
202
  ` staged: ${staged.length}`,
203
+ ` failed: ${failed.length}`,
202
204
  ];
203
205
  for (const d of deferred) {
204
206
  lines.push(` - ${String(d.id ?? "?")} (${String(d.reason ?? "?")})`);
205
207
  }
208
+ for (const f of failed) {
209
+ lines.push(` ! ${String(f.id ?? "?")} (${String(f.reason ?? "?")}): ${String(f.detail ?? "?")}`);
210
+ }
206
211
  appendLoweringNotices(lines, r);
207
212
  return lines.join("\n").trimEnd();
208
213
  }
@@ -172,7 +172,14 @@ export function formatWorkflowRunPlain(result) {
172
172
  const run = typeof result.run === "object" && result.run !== null ? result.run : undefined;
173
173
  if (!run)
174
174
  return null;
175
- const lines = [`run: ${String(run.id ?? "unknown")}`, `status: ${String(run.status ?? "unknown")}`];
175
+ const lines = [];
176
+ // #919: `workflow run <ref>` silently resuming an existing active run (the
177
+ // #485 concurrency guard) is now announced up front, with the escape hatch.
178
+ if (result.resumed === true) {
179
+ lines.push(`resuming existing run ${String(run.id ?? "unknown")} for ${String(run.workflowRef ?? "this ref")}; ` +
180
+ `pass --new to start a fresh run`);
181
+ }
182
+ lines.push(`run: ${String(run.id ?? "unknown")}`, `status: ${String(run.status ?? "unknown")}`);
176
183
  // Creation-time notices (e.g. the implicit engine fallback) must survive the
177
184
  // text renderer: JSON/YAML pass them through, and dropping them here would
178
185
  // hide the announcement from the DEFAULT output mode — where it matters most.