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 +61 -0
- package/dist/assets/workflows/workflow-template.md +4 -0
- package/dist/commands/improve/improve.js +1 -0
- package/dist/commands/lint/base-linter.js +10 -0
- package/dist/commands/proposal/drain.js +48 -6
- package/dist/commands/proposal/proposal-cli.js +1 -0
- package/dist/core/config/config-walker.js +7 -3
- package/dist/core/config/config.js +21 -12
- package/dist/core/config/schema/primitives.js +8 -2
- package/dist/core/errors.js +2 -0
- package/dist/llm/client.js +12 -8
- package/dist/llm/embedders/remote.js +3 -2
- package/dist/output/shapes.js +46 -1
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/scripts/akm-migrate-node.js +323 -172
- package/dist/scripts/akm-migrate.js +323 -172
- package/dist/storage/repositories/workflow-runs-repository.js +118 -10
- package/dist/workflows/exec/run-workflow.js +1 -1
- package/dist/workflows/exec/step-work.js +41 -0
- package/dist/workflows/parser.js +1 -1
- package/dist/workflows/runtime/runs.js +29 -5
- package/docs/reference/cli.md +18 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
559
|
-
//
|
|
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
|
-
|
|
621
|
+
const message = pushDrainFailure(result, id, err, "promote-error");
|
|
622
|
+
warn(`[triage] preflight failed for ${id}: ${message}`);
|
|
581
623
|
}
|
|
582
624
|
}
|
|
583
625
|
}
|
|
@@ -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
|
-
?
|
|
218
|
+
? isApiKeyReference(raw)
|
|
218
219
|
? { success: true, data: value }
|
|
219
|
-
: {
|
|
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 (
|
|
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 (
|
|
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 ||
|
|
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
|
|
358
|
-
*
|
|
359
|
-
* authorization-header consumption sites (LLM client, embedder,
|
|
360
|
-
* runner) — NOT on the load path. Non-string inputs pass through
|
|
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 (
|
|
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) => {
|
package/dist/core/errors.js
CHANGED
|
@@ -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 = {
|
package/dist/llm/client.js
CHANGED
|
@@ -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 {
|
|
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
|
|
268
|
-
// hands us a materialized credential after
|
|
269
|
-
// re-running the substitution over a
|
|
270
|
-
// containing `$` — `sk-live$ecret` lost
|
|
271
|
-
// the request failed with an opaque
|
|
272
|
-
// form working for any direct
|
|
273
|
-
|
|
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
|
}
|
package/dist/output/shapes.js
CHANGED
|
@@ -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
|
}
|