akm-cli 0.9.12 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.9.13] - 2026-09-04
8
+
9
+ ### Added
10
+
11
+ - **A stable `results` alias on every list-returning command (#922).** `search`,
12
+ `curate`, `proposal list`, `bundle list`, `env list`, `secret list`,
13
+ `registry search`, `registry list`, `workflow list`, `task history` and
14
+ `log list` each keep their existing semantic key (`hits`, `items`,
15
+ `proposals`, …) and now also expose the same array as `results`, in every
16
+ shape including `--shape agent`. It is the same array, not a copy. A caller
17
+ reading `hits` from a `curate` response previously got nothing back and could
18
+ reasonably read that as "no results" — there was no error and the `summary`
19
+ alongside it still reported that results were selected. Commands carrying
20
+ several heterogeneous collections (`health`, `task doctor`) are deliberately
21
+ excluded.
22
+ - **Engine and embedding credentials can resolve from the secret store
23
+ (#917).** `apiKey` accepts `secret://<name>` alongside `$VAR` / `${VAR}`,
24
+ resolved through the existing store-backed resolver. A detached SessionEnd
25
+ hook or a cron job — the two contexts least able to supply an environment
26
+ variable and most likely to need extraction — no longer requires editing the
27
+ login environment for a value akm already stores. Literal keys in
28
+ `config.json` are still refused, and an unresolvable reference fails loudly,
29
+ naming the reference and never the secret.
30
+ - **`akm proposal drain` reports what failed (#921).** The envelope carries a
31
+ `failed[]` naming each proposal and why it was refused (stale target,
32
+ validation), instead of reporting `failed: 0` while the same run printed five
33
+ failures to stderr. `--dry-run` now applies the same stale-target check the
34
+ real run does, so its prediction stops disagreeing with the outcome. The
35
+ refusals themselves are correct and unchanged: declining to overwrite a target
36
+ modified since the proposal was created is the desired behaviour. `akm
37
+ improve`'s triage pre-pass reports the same count.
38
+ - **Workflow step output is checked against its declared schema (#923).** A step
39
+ whose returned object does not match its `outputSchema` now says so, naming
40
+ the step and the specific problem (a missing required field, say). This is a
41
+ warning: the run continues and its status is unchanged, because a workflow is
42
+ a guide rather than a contract. Previously an author got neither enforcement
43
+ nor feedback — the only signal was a notice saying akm was not checking, which
44
+ is a different fact from whether the output matched.
45
+
46
+ ### Changed
47
+
48
+ - **A held run lease no longer reports as database corruption (#924).** `akm
49
+ workflow run` surfaced raw SQLite text — `database is locked`, `database disk
50
+ image is malformed`, `disk I/O error` — for what was simply another
51
+ invocation holding the lease. `disk image is malformed` in particular reads as
52
+ data loss and sent one reporter through integrity checks and a WAL review
53
+ before finding the real cause. The lease message now appears at default
54
+ verbosity and carries a dedicated `RUN_LEASE_HELD` code, so a wrapper can tell
55
+ "retry shortly" from "you passed bad input". Genuine SQLite errors still
56
+ report as themselves.
57
+ - **`akm lint` stops flagging templated output paths (#927).** A documented
58
+ run-time filename such as `reports/review-<timestamp>.md` is no longer
59
+ reported as a `stale-path` broken reference. Angle-bracket and brace
60
+ placeholders, `${VAR}`, date-format runs like `YYYYMMDD`, and glob characters
61
+ are all recognised as parameterised rather than missing. Genuinely broken
62
+ literal paths are still reported.
63
+ - **The workflow level-2 heading rule is discoverable before you trip it
64
+ (#926).** The `workflow create` template states that `##` headings are step
65
+ ids and points at `###` for cross-cutting notes, and the compiler's rejection
66
+ now carries the remedy rather than only the diagnosis.
67
+
7
68
  ## [0.9.12] - 2026-09-03
8
69
 
9
70
  ### Added
@@ -16,6 +16,10 @@ steps:
16
16
  Free preamble prose describing what this workflow does. It is indexed for
17
17
  search and shown in `akm show`, but it is never dispatched to a step.
18
18
 
19
+ Level-2 (`##`) headings below are step ids and must exactly match one
20
+ declared in `steps:` above — for cross-cutting notes that aren't a step
21
+ (shared context, prerequisites), use a level-3 (`###`) heading instead.
22
+
19
23
  ## first-step
20
24
 
21
25
  Describe what to do in this step. Refer to run parameters in plain
@@ -1380,6 +1380,7 @@ function finalizeImproveResult(args) {
1380
1380
  promoted: triageDrain.promoted.length,
1381
1381
  rejected: triageDrain.rejected.length,
1382
1382
  deferred: triageDrain.deferred.length,
1383
+ failed: triageDrain.failed.length,
1383
1384
  skippedByCap: triageDrain.skippedByCap.length,
1384
1385
  },
1385
1386
  }
@@ -132,6 +132,14 @@ function fixMissingUpdated(raw, mtime) {
132
132
  return spliceFrontmatterLine(raw, `updated: ${localDateStamp(mtime)}`) ?? raw;
133
133
  }
134
134
  // ── stale-path helpers ────────────────────────────────────────────────────────
135
+ /**
136
+ * A path segment shaped like a run-time filename template rather than a
137
+ * literal reference: `<timestamp>`-style angle brackets, `{stamp}`/`${VAR}`
138
+ * braces, a `YYYYMMDD`/`HHMMSS`-style run of date-format letters, or a glob
139
+ * character. Such a path never exists under its literal spelling, so
140
+ * `stale-path` skips it instead of flagging it.
141
+ */
142
+ const PATH_PLACEHOLDER_PATTERN = /[<{]|[*?]|[YMDHS]{4,}/;
135
143
  function checkStalePath(body) {
136
144
  const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
137
145
  let match;
@@ -139,6 +147,8 @@ function checkStalePath(body) {
139
147
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
140
148
  while ((match = pathRe.exec(body)) !== null) {
141
149
  const candidate = match[0];
150
+ if (PATH_PLACEHOLDER_PATTERN.test(candidate))
151
+ continue;
142
152
  if (!fs.existsSync(candidate)) {
143
153
  stale.push(candidate);
144
154
  }
@@ -123,6 +123,42 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
123
123
  function deferReasonForSource(source) {
124
124
  return source === "distill" ? "possible-dup" : "mid-band";
125
125
  }
126
+ /**
127
+ * Map a thrown error's message to one of `DrainResult.failed`'s stable reason
128
+ * codes, falling back to `fallback` for anything not specifically recognized.
129
+ * Recognizes the write-time guards a proposal can trip during promotion
130
+ * (see repository.ts's `promoteProposalWithLease` / `preflightProposalPromotion`).
131
+ */
132
+ function categorizeDrainFailure(message, fallback) {
133
+ if (/target (?:changed after|was created after) proposal/.test(message))
134
+ return "stale-target";
135
+ if (/failed validation:/.test(message))
136
+ return "validation";
137
+ return fallback;
138
+ }
139
+ function pushDrainFailure(result, id, err, fallbackReason) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ result.failed.push({ id, reason: categorizeDrainFailure(message, fallbackReason), detail: message });
142
+ return message;
143
+ }
144
+ /**
145
+ * Mirror repository.ts's `promoteProposalWithLease` stale-target guard so a
146
+ * dry-run preflight predicts the same refusal a real promote would hit,
147
+ * without writing anything. `assetPath` is the path `preflightProposalPromotion`
148
+ * already resolved for this proposal.
149
+ */
150
+ function assertProposalTargetFresh(proposal, assetPath) {
151
+ const backup = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
152
+ const currentHash = backup ? createHash("sha256").update(backup).digest("hex") : undefined;
153
+ if (proposal.beforeHash !== undefined && (!backup || currentHash !== proposal.beforeHash)) {
154
+ throw new Error(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
155
+ }
156
+ if (proposal.beforeHash === undefined &&
157
+ backup !== undefined &&
158
+ proposal.changes.some((change) => change.op === "create")) {
159
+ throw new Error(`Proposal target was created after proposal ${proposal.id} was created; refusing to overwrite newer content.`);
160
+ }
161
+ }
126
162
  // ---------------------------------------------------------------------------
127
163
  // Judgment tier (Phase 3)
128
164
  // ---------------------------------------------------------------------------
@@ -493,6 +529,7 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
493
529
  deferred: classification.deferred,
494
530
  skippedByCap: [],
495
531
  staged: [],
532
+ failed: [],
496
533
  };
497
534
  // A configured judgment runner makes every deferred item dispatch-eligible.
498
535
  // Validate its symbolic credentials before applying any deterministic gate,
@@ -518,7 +555,8 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
518
555
  result.rejected.push(target.id);
519
556
  }
520
557
  catch (err) {
521
- warn(`[triage] reject failed for ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
558
+ const message = pushDrainFailure(result, target.id, err, "reject-error");
559
+ warn(`[triage] reject failed for ${target.id}: ${message}`);
522
560
  }
523
561
  }
524
562
  // --- Accept ceiling: enforced BEFORE the promote loop ---
@@ -550,13 +588,15 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
550
588
  deterministicPromoted += 1;
551
589
  }
552
590
  catch (err) {
553
- warn(`[triage] promote failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
591
+ const message = pushDrainFailure(result, id, err, "promote-error");
592
+ warn(`[triage] promote failed for ${id}: ${message}`);
554
593
  }
555
594
  }
556
595
  }
557
596
  else if (opts.applyMode === "promote" && opts.dryRun) {
558
- // Exercise the same stamped candidate and lint boundary as real promotion.
559
- // Tests that omit config retain the classification-only seam.
597
+ // Exercise the same stamped candidate, lint, and stale-target boundary as
598
+ // real promotion so a dry-run's predicted promotions match what a real
599
+ // run would do. Tests that omit config retain the classification-only seam.
560
600
  const byId = new Map(pending.map((proposal) => [proposal.id, proposal]));
561
601
  for (const id of withinCap) {
562
602
  try {
@@ -564,7 +604,7 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
564
604
  const proposal = byId.get(id);
565
605
  if (!proposal)
566
606
  throw new Error(`Proposal ${id} disappeared during drain preflight.`);
567
- preflightProposalPromotion(opts.config, proposal, {
607
+ const preflight = preflightProposalPromotion(opts.config, proposal, {
568
608
  ...(opts.target ? { target: opts.target } : {}),
569
609
  gateDecision: {
570
610
  outcome: "auto-accepted",
@@ -572,12 +612,14 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
572
612
  gate: gateLabel,
573
613
  },
574
614
  });
615
+ assertProposalTargetFresh(proposal, preflight.assetPath);
575
616
  }
576
617
  result.promoted.push(id);
577
618
  deterministicPromoted += 1;
578
619
  }
579
620
  catch (err) {
580
- warn(`[triage] preflight failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
621
+ const message = pushDrainFailure(result, id, err, "promote-error");
622
+ warn(`[triage] preflight failed for ${id}: ${message}`);
581
623
  }
582
624
  }
583
625
  }
@@ -473,6 +473,7 @@ const proposalDrainCommand = defineJsonCommand({
473
473
  deferred: result.deferred,
474
474
  skippedByCap: result.skippedByCap,
475
475
  staged: result.staged,
476
+ failed: result.failed,
476
477
  });
477
478
  },
478
479
  });
@@ -27,6 +27,7 @@ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../
27
27
  import { warnOnce } from "../warn.js";
28
28
  import { AkmConfigBaseSchema, EngineConfigSchema, listTopLevelConfigKeys } from "./config-schema.js";
29
29
  import { deepMergeConfig } from "./deep-merge.js";
30
+ import { isApiKeyReference } from "./schema/primitives.js";
30
31
  /**
31
32
  * Parse a dotted path into segments. Empty segments are rejected. Bracket
32
33
  * notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON.
@@ -214,9 +215,12 @@ export function configSet(config, dotted, raw) {
214
215
  const parsed = path[0] === "engines" && path.length === 2
215
216
  ? EngineConfigSchema.safeParse(value)
216
217
  : symbolicApiKey
217
- ? /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(raw)
218
+ ? isApiKeyReference(raw)
218
219
  ? { success: true, data: value }
219
- : { success: false, error: { issues: [{ path: [], message: `apiKey must be $VAR or \${VAR}` }] } }
220
+ : {
221
+ success: false,
222
+ error: { issues: [{ path: [], message: "apiKey must be $VAR, ${VAR}, or secret://<name>" }] },
223
+ }
220
224
  : isUnknownKey
221
225
  ? { success: true, data: value }
222
226
  : schema.safeParse(candidate);
@@ -297,7 +301,7 @@ function rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted) {
297
301
  }
298
302
  if (!isRecord(parsed) || typeof parsed.apiKey !== "string")
299
303
  return;
300
- if (/^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(parsed.apiKey))
304
+ if (isApiKeyReference(parsed.apiKey))
301
305
  return;
302
306
  throw new UsageError(`apiKey cannot be persisted in config; export ${recipeForApiKey([...path, "apiKey"], `${dotted}.apiKey`)} instead. (key: ${dotted}.apiKey)`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " +
303
307
  "Use the corresponding environment variable. AKM reads it at request time.");
@@ -12,6 +12,7 @@ import { bundlesToSourceEntries } from "./config-sources.js";
12
12
  import { upgradeConfigVersion } from "./config-version-shim.js";
13
13
  import { deepMergeConfig } from "./deep-merge.js";
14
14
  import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
15
+ import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
15
16
  export { stripJsonComments } from "./config-io.js";
16
17
  import { getConfigPath } from "../paths.js";
17
18
  import { warn, warnOnce } from "../warn.js";
@@ -299,7 +300,7 @@ export function sanitizeConfigForWrite(config) {
299
300
  const stripped = [];
300
301
  if (config.embedding?.apiKey !== undefined) {
301
302
  const apiKey = config.embedding.apiKey;
302
- if (isEnvReference(apiKey)) {
303
+ if (isApiKeyReference(apiKey)) {
303
304
  // Preserve reference verbatim — not a secret.
304
305
  sanitized.embedding = { ...config.embedding };
305
306
  }
@@ -316,7 +317,7 @@ export function sanitizeConfigForWrite(config) {
316
317
  if (config.engines) {
317
318
  const engines = {};
318
319
  for (const [name, engine] of Object.entries(config.engines)) {
319
- if (engine.kind !== "llm" || engine.apiKey === undefined || isEnvReference(engine.apiKey)) {
320
+ if (engine.kind !== "llm" || engine.apiKey === undefined || isApiKeyReference(engine.apiKey)) {
320
321
  engines[name] = { ...engine };
321
322
  continue;
322
323
  }
@@ -345,19 +346,15 @@ export function sanitizeConfigForWrite(config) {
345
346
  }
346
347
  return sanitized;
347
348
  }
348
- /** Matches the only 0.9 symbolic secret forms: `${VAR}` or `$VAR`. */
349
- function isEnvReference(value) {
350
- return /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
351
- }
352
349
  export function updateConfig(partial) {
353
350
  return mutateConfig((current) => deepMergeConfig(current, partial)).config;
354
351
  }
355
- // ── Helpers ─────────────────────────────────────────────────────────────────
356
352
  /**
357
- * Resolve a single secret value by expanding `${VAR}` / `$VAR` references
358
- * against `process.env`. Use this at apiKey /
359
- * authorization-header consumption sites (LLM client, embedder, agent SDK
360
- * runner) — NOT on the load path. Non-string inputs pass through unchanged.
353
+ * Resolve a single secret value: expand `${VAR}` / `$VAR` against
354
+ * `process.env`, or look up `secret://<name>` via `resolveFromStore`. Use this
355
+ * at apiKey / authorization-header consumption sites (LLM client, embedder,
356
+ * agent SDK runner) — NOT on the load path. Non-string inputs pass through
357
+ * unchanged.
361
358
  *
362
359
  * Returns the input unchanged when no substitution markers are present, so
363
360
  * literal API key strings (already-resolved secrets) are zero-cost.
@@ -365,12 +362,24 @@ export function updateConfig(partial) {
365
362
  * Other config string values (URLs, endpoints, model names, prompts) are
366
363
  * preserved verbatim on read — only fields explicitly routed through this
367
364
  * helper are expanded.
365
+ *
366
+ * A `secret://<name>` value that fails to resolve throws `ConfigError`
367
+ * (naming the ref, never the secret) rather than silently sending an unusable
368
+ * credential.
368
369
  */
369
- export function resolveSecret(value) {
370
+ export function resolveSecret(value, resolveFromStore) {
370
371
  if (value === undefined)
371
372
  return undefined;
372
373
  if (typeof value !== "string")
373
374
  return value;
375
+ const storeRef = SECRET_STORE_REFERENCE_PATTERN.exec(value)?.[1];
376
+ if (storeRef !== undefined) {
377
+ const resolved = resolveFromStore?.(storeRef) ?? null;
378
+ if (resolved === null) {
379
+ throw new ConfigError(`Secret store reference "${value}" did not resolve to a stored value.`, "SECRET_REFERENCE_UNRESOLVED");
380
+ }
381
+ return resolved;
382
+ }
374
383
  if (!value.includes("$"))
375
384
  return value;
376
385
  return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
@@ -28,15 +28,21 @@ export const httpUrl = z.string().refine((v) => v.startsWith("http://") || v.sta
28
28
  });
29
29
  const ENGINE_NAME_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE);
30
30
  export const ENV_REFERENCE_PATTERN = /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
31
+ /** `secret://<name>` — an apiKey reference into the akm secret store, resolved via `resolveSecretFromStore`. */
32
+ export const SECRET_STORE_REFERENCE_PATTERN = /^secret:\/\/(.+)$/;
31
33
  export const engineName = z
32
34
  .string()
33
35
  .max(63)
34
36
  .regex(ENGINE_NAME_PATTERN, "names must be lowercase kebab-case and must not begin with reserved akm-");
37
+ /** The two symbolic apiKey forms akm accepts: an env-var reference or a secret-store reference. Never matches a literal key. */
38
+ export function isApiKeyReference(value) {
39
+ return ENV_REFERENCE_PATTERN.test(value) || SECRET_STORE_REFERENCE_PATTERN.test(value);
40
+ }
35
41
  export function symbolicOrWarnApiKey(label) {
36
42
  return z.string().superRefine((value) => {
37
- if (ENV_REFERENCE_PATTERN.test(value))
43
+ if (isApiKeyReference(value))
38
44
  return;
39
- warnOnce(`config:literal-api-key:${label}`, `A ${label} in config.json is a literal API key, not a $VAR/\${VAR} reference; using it as configured. Prefer \`akm config set ...apiKey '$VAR'\` (with the corresponding env var set) — see docs/reference/data-and-telemetry.md.`);
45
+ warnOnce(`config:literal-api-key:${label}`, `A ${label} in config.json is a literal API key, not a $VAR/\${VAR}/secret:// reference; using it as configured. Prefer \`akm config set ...apiKey '$VAR'\` (with the corresponding env var set) or \`secret://<name>\` (with \`akm secret set <name> ...\`) — see docs/reference/data-and-telemetry.md.`);
40
46
  });
41
47
  }
42
48
  export const chatCompletionsEndpoint = z.string().superRefine((value, ctx) => {
@@ -17,6 +17,7 @@ const CONFIG_HINTS = {
17
17
  UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The bundle directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
18
18
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
19
19
  EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
20
+ SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the name after `secret://` matches, or run `akm secret set <name> <value>` to store it.",
20
21
  };
21
22
  // Code-review finding: COMPOSITION_INVALID covers several unrelated causes
22
23
  // (a rejected with:, a multi-job source, a composition cycle/depth/size
@@ -76,6 +77,7 @@ const USAGE_HINTS = {
76
77
  WORKFLOW_IR_VERSION_UNSUPPORTED: "Abandon the run with `akm workflow abandon <id>`, then start it again from the workflow source — a frozen plan this akm cannot execute is not re-executable in place.",
77
78
  // P3b (docs/plans/specs/p3b-child-executor.md §4.3).
78
79
  WORKFLOW_OUTPUT_INVALID: "Check each `outputs:` entry's `from:` against the step artifact it names, and its `schema:` against the value that step actually promotes.",
80
+ RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
79
81
  };
80
82
  /** Default hint for each NotFoundError code. */
81
83
  const NOT_FOUND_HINTS = {
@@ -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
  }
@@ -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
  }
@@ -66,6 +66,51 @@ 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") {
70
115
  let effectiveShape = shape;
71
116
  if (shape === "summary" && !SHAPE_SUMMARY_COMMANDS.has(command)) {
@@ -74,7 +119,7 @@ export function shapeForCommand(command, result, detail, shape = "human") {
74
119
  }
75
120
  const handler = getOutputShapeHandler(command);
76
121
  if (handler) {
77
- return handler(result, detail, effectiveShape);
122
+ return withResultsAlias(command, handler(result, detail, effectiveShape));
78
123
  }
79
124
  // v1 spec §9 (output-shape registry exhaustive): no silent JSON.stringify
80
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
  }