akm-cli 0.9.0 → 0.9.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +707 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +8 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +200 -50
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +72 -20
- package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +4 -0
- package/dist/core/common.js +66 -6
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/file-lock.js +7 -1
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +327 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/redaction.js +64 -17
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +7 -0
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +55 -15
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +8 -15
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/lockfile.js +45 -2
- package/dist/output/text/lint-format.js +17 -4
- package/dist/scripts/akm-migrate-node.js +1435 -753
- package/dist/scripts/akm-migrate.js +1403 -721
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +6 -1
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +28 -3
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +139 -11
- package/dist/tasks/schema.js +65 -0
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +457 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +496 -165
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +216 -18
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +8 -0
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +85 -10
package/dist/indexer/indexer.js
CHANGED
|
@@ -7,6 +7,8 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
|
|
|
7
7
|
import { adapterForId } from "../core/adapter/registry.js";
|
|
8
8
|
import { isHttpUrl, toErrorMessage } from "../core/common.js";
|
|
9
9
|
import { concurrentMap } from "../core/concurrent.js";
|
|
10
|
+
import { isLoopbackEndpoint } from "../core/loopback.js";
|
|
11
|
+
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
10
12
|
import { getDbPath } from "../core/paths.js";
|
|
11
13
|
import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
|
|
12
14
|
import { withStateDb } from "../core/state-db.js";
|
|
@@ -39,23 +41,15 @@ function throwIfAborted(signal) {
|
|
|
39
41
|
export function getDefaultLlmConcurrency(llmConfig) {
|
|
40
42
|
if (typeof llmConfig?.concurrency === "number")
|
|
41
43
|
return llmConfig.concurrency;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// comparison actually matches.
|
|
48
|
-
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
49
|
-
if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost"))
|
|
50
|
-
return 1;
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
44
|
+
// Local model servers stay at 1 (single loaded model; parallel requests
|
|
45
|
+
// trigger reload thrash); an absent or unparseable endpoint fails safe as
|
|
46
|
+
// local. ONE classifier decides what "local" means (`core/loopback.ts`,
|
|
47
|
+
// shared with the workflow engine's frozen concurrency default).
|
|
48
|
+
if (isLoopbackEndpoint(llmConfig?.endpoint))
|
|
53
49
|
return 1;
|
|
54
|
-
}
|
|
55
50
|
// Remote endpoints default to a modest 2-wide pool (owner ruling 2026-07-21):
|
|
56
51
|
// enough to overlap request latency without hammering rate-limited APIs.
|
|
57
|
-
//
|
|
58
|
-
// trigger reload thrash). The explicit-override branch above only fires for
|
|
52
|
+
// The explicit-override branch above only fires for
|
|
59
53
|
// callers that put `concurrency` on the connection themselves —
|
|
60
54
|
// `engines.<name>.concurrency` is a valid schema field but `resolveLlmEngineUse`
|
|
61
55
|
// does NOT copy it into the resolved connection, so on the enrichment path the
|
|
@@ -290,12 +284,33 @@ export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
|
|
|
290
284
|
*
|
|
291
285
|
* Only rows with a non-empty `file_path` are checked — remote/virtual entries
|
|
292
286
|
* that have no local path are always skipped.
|
|
287
|
+
*
|
|
288
|
+
* "No longer exists" means ABSENT, never merely unreadable (#791). This pass
|
|
289
|
+
* DELETES rows, and `fs.existsSync` reported `false` for a file akm lacked
|
|
290
|
+
* permission to look at exactly as for one that had been removed — so a
|
|
291
|
+
* bundle temporarily mounted read-restricted (a uid mismatch, a tightened
|
|
292
|
+
* parent directory) had its whole index wiped, and the run reported the
|
|
293
|
+
* deletions as a clean success. Unreadable files keep their rows and are
|
|
294
|
+
* reported instead.
|
|
293
295
|
*/
|
|
294
296
|
function runCleanPass(db, dryRun) {
|
|
295
297
|
const allEntries = db.prepare("SELECT id, entry_key AS ref, file_path AS path FROM entries").all();
|
|
296
298
|
// Only check entries that have a non-empty local path (skip remote/virtual).
|
|
297
299
|
const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
|
|
298
|
-
const missing =
|
|
300
|
+
const missing = [];
|
|
301
|
+
const unreadable = [];
|
|
302
|
+
for (const entry of localEntries) {
|
|
303
|
+
const { access, code } = classifyPathAccess(entry.path);
|
|
304
|
+
if (access === "absent")
|
|
305
|
+
missing.push(entry);
|
|
306
|
+
else if (access === "inaccessible")
|
|
307
|
+
unreadable.push({ path: entry.path, ...(code ? { code } : {}) });
|
|
308
|
+
}
|
|
309
|
+
if (unreadable.length > 0) {
|
|
310
|
+
const shown = unreadable.slice(0, 5).map((u) => describeInaccessiblePath(u.path, u.code));
|
|
311
|
+
warn(`Index clean pass kept ${unreadable.length} entr${unreadable.length === 1 ? "y" : "ies"} whose file akm cannot ` +
|
|
312
|
+
`read (unreadable is not deleted): ${shown.join("; ")}${unreadable.length > shown.length ? "; …" : ""}`);
|
|
313
|
+
}
|
|
299
314
|
if (!dryRun && missing.length > 0) {
|
|
300
315
|
deleteEntriesByIds(db, missing.map((e) => e.id));
|
|
301
316
|
}
|
|
@@ -319,6 +334,24 @@ export async function akmIndex(options) {
|
|
|
319
334
|
return akmIndexOverride(options);
|
|
320
335
|
return akmIndexReal(options);
|
|
321
336
|
}
|
|
337
|
+
let indexTransactionHookForTests;
|
|
338
|
+
/**
|
|
339
|
+
* TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
|
|
340
|
+
*
|
|
341
|
+
* Exists because the delete-then-reinsert atomicity guarantee is, by
|
|
342
|
+
* construction, invisible from outside the transaction: by the time
|
|
343
|
+
* `akmIndex()` resolves, the commit has already collapsed both generations
|
|
344
|
+
* into one observable state. Concurrency tests install a hook that opens a
|
|
345
|
+
* SECOND connection at these points and asserts it still sees the previous
|
|
346
|
+
* complete generation. Inert in production (one `undefined?.()` per reindex).
|
|
347
|
+
*/
|
|
348
|
+
export function _setIndexTransactionHookForTests(hook) {
|
|
349
|
+
indexTransactionHookForTests = hook;
|
|
350
|
+
}
|
|
351
|
+
/** Fire a named in-transaction observation point (no-op outside tests). */
|
|
352
|
+
function indexTransactionHook(point) {
|
|
353
|
+
indexTransactionHookForTests?.(point);
|
|
354
|
+
}
|
|
322
355
|
/**
|
|
323
356
|
* Detect an adapter for every resolvable source that does not declare one, and
|
|
324
357
|
* persist each detection into `config.json`.
|
|
@@ -976,6 +1009,10 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
|
|
|
976
1009
|
// (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
|
|
977
1010
|
// re-resolves the rest by entry_ref — subsuming the old detach.
|
|
978
1011
|
db.exec("DELETE FROM entries");
|
|
1012
|
+
// Atomicity observation point: inside the transaction the tables are now
|
|
1013
|
+
// empty, but no other connection may observe that. See
|
|
1014
|
+
// tests/integration/indexer/reindex-generation-atomicity.test.ts.
|
|
1015
|
+
indexTransactionHook("full-delete-applied");
|
|
979
1016
|
}
|
|
980
1017
|
for (const { dirPath, currentStashDir, files, stash, skip, reason, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
|
|
981
1018
|
if (remove) {
|
|
@@ -1089,6 +1126,9 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
|
|
|
1089
1126
|
}
|
|
1090
1127
|
}
|
|
1091
1128
|
}
|
|
1129
|
+
// Atomicity observation point: the new generation is fully written but
|
|
1130
|
+
// uncommitted, so it must still be invisible to other connections.
|
|
1131
|
+
indexTransactionHook("records-persisted");
|
|
1092
1132
|
});
|
|
1093
1133
|
insertTransaction();
|
|
1094
1134
|
deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
|
|
@@ -43,6 +43,7 @@ import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-r
|
|
|
43
43
|
import { todayIso } from "../../core/common.js";
|
|
44
44
|
import { concurrentMap } from "../../core/concurrent.js";
|
|
45
45
|
import { warn } from "../../core/warn.js";
|
|
46
|
+
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
46
47
|
import { writeAssetToSource } from "../../core/write-source.js";
|
|
47
48
|
import { isProcessEnabled } from "../../llm/feature-gate.js";
|
|
48
49
|
import { resolveIndexPassLLM } from "../../llm/index-passes.js";
|
|
@@ -449,6 +450,9 @@ function markParentProcessed(parent) {
|
|
|
449
450
|
const next = assembleAsset(updatedFm, block.content);
|
|
450
451
|
try {
|
|
451
452
|
fs.writeFileSync(parent.filePath, next, "utf8");
|
|
453
|
+
// #652: the parent's `inference_processed` stamp is a real asset mutation
|
|
454
|
+
// (the documented writeAssetToSource exception above) — journal it.
|
|
455
|
+
recordWrittenPath(parent.filePath);
|
|
452
456
|
}
|
|
453
457
|
catch (err) {
|
|
454
458
|
warn(`memory inference: failed to mark parent processed ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1,27 +1,15 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
/**
|
|
5
|
-
* Database-backed (SQLite + FTS5/vector) source search implementation.
|
|
6
|
-
*
|
|
7
|
-
* Extracted from source-search.ts to break the circular import:
|
|
8
|
-
* source-search.ts → sources/providers/filesystem.ts → db-search.ts (no cycle)
|
|
9
|
-
*
|
|
10
|
-
* source-search.ts imports this module for the `searchLocal` export.
|
|
11
|
-
* sources/providers/filesystem.ts also imports `searchLocal` from here.
|
|
12
|
-
*
|
|
13
|
-
* Renamed from `local-search.ts` to signal that this is the DB-layer search
|
|
14
|
-
* implementation, not a "local vs. remote" distinction.
|
|
15
|
-
*/
|
|
16
|
-
import fs from "node:fs";
|
|
17
4
|
import path from "node:path";
|
|
18
5
|
import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
|
|
19
6
|
import { stashDirFor } from "../../core/asset/asset-placement.js";
|
|
20
7
|
import { displayRef } from "../../core/asset/resolve-ref.js";
|
|
8
|
+
import { classifyPathAccess } from "../../core/path-access.js";
|
|
21
9
|
import { getDbPath } from "../../core/paths.js";
|
|
22
10
|
import { defaultRendererRegistry } from "../../core/type-presentation.js";
|
|
23
11
|
import { warn } from "../../core/warn.js";
|
|
24
|
-
import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
|
|
12
|
+
import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
|
|
25
13
|
import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
|
|
26
14
|
import { searchFts } from "../../storage/repositories/index-fts-repository.js";
|
|
27
15
|
import { getMeta } from "../../storage/repositories/index-meta-repository.js";
|
|
@@ -158,7 +146,12 @@ export async function searchLocal(input) {
|
|
|
158
146
|
// reads serve the existing index as-is.
|
|
159
147
|
await ensureIndex(stashDir);
|
|
160
148
|
const dbPath = getDbPath();
|
|
161
|
-
|
|
149
|
+
// An index we cannot READ is not an index that does not exist (#791). Saying
|
|
150
|
+
// "No search index available" for a populated index the caller merely lacks
|
|
151
|
+
// permission on is a lie at exit 0 — and an agent consuming this JSON has no
|
|
152
|
+
// way to tell it from a genuine empty result, so it relays the lie onward.
|
|
153
|
+
assertIndexPathReadable(dbPath);
|
|
154
|
+
if (classifyPathAccess(dbPath).access === "absent") {
|
|
162
155
|
return {
|
|
163
156
|
hits: [],
|
|
164
157
|
tip: "No search index available. Run 'akm index' to build one.",
|
|
@@ -43,6 +43,29 @@ function resolveCredential(name, engine, config) {
|
|
|
43
43
|
? { names: [specific, "AKM_LLM_API_KEY"], required: false }
|
|
44
44
|
: { names: [specific], required: false };
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Read a credential descriptor's value out of `process.env`: the FIRST
|
|
48
|
+
* non-empty trimmed value across `names`, in declared order. A `required`
|
|
49
|
+
* descriptor that resolves to nothing is a config error naming its PRIMARY
|
|
50
|
+
* variable — the one an operator is told to set.
|
|
51
|
+
*
|
|
52
|
+
* The ONE env-credential seam. The live-config dispatch boundary
|
|
53
|
+
* ({@link materializeLlmConnection}) and the FROZEN workflow dispatch boundary
|
|
54
|
+
* (`materializeFrozenLlm` in `workflows/exec/unit-dispatch.ts`, whose frozen
|
|
55
|
+
* snapshots carry a structurally identical descriptor) both read through it, so
|
|
56
|
+
* lookup order and the failure message cannot drift between them.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveCredentialFromEnv(credential) {
|
|
59
|
+
for (const name of credential?.names ?? []) {
|
|
60
|
+
const candidate = process.env[name]?.trim();
|
|
61
|
+
if (candidate)
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
if (credential?.required) {
|
|
65
|
+
throw new ConfigError(`Required engine credential ${credential.names[0]} is not set.`, "INVALID_CONFIG_FILE");
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
46
69
|
/** Collect materialized engine credentials for output and persistence redaction. */
|
|
47
70
|
export function collectEngineCredentialValues(config, envSource = process.env) {
|
|
48
71
|
const values = new Set();
|
|
@@ -112,17 +135,7 @@ export function materializeLlmConnection(resolved) {
|
|
|
112
135
|
throw new ConfigError(formatExtraParamsIssue(`Engine "${resolved.engine}" extraParams`, issue), "INVALID_CONFIG_FILE");
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
|
-
|
|
116
|
-
for (const name of resolved.credential?.names ?? []) {
|
|
117
|
-
const candidate = process.env[name]?.trim();
|
|
118
|
-
if (candidate) {
|
|
119
|
-
apiKey = candidate;
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (resolved.credential?.required && !apiKey) {
|
|
124
|
-
throw new ConfigError(`Required engine credential ${resolved.credential.names[0]} is not set.`, "INVALID_CONFIG_FILE");
|
|
125
|
-
}
|
|
138
|
+
const apiKey = resolveCredentialFromEnv(resolved.credential);
|
|
126
139
|
return {
|
|
127
140
|
...resolved.connection,
|
|
128
141
|
...(apiKey ? { apiKey } : {}),
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const BUILTIN_ALIASES = [
|
|
14
14
|
{
|
|
15
15
|
// Anthropic's Mythos-class tier above Opus — the recommended resolution
|
|
16
|
-
// target for the `deep` workflow tier (see docs/reference/
|
|
16
|
+
// target for the `deep` workflow tier (see docs/reference/workflow-schema.md).
|
|
17
17
|
alias: "fable",
|
|
18
18
|
platforms: {
|
|
19
19
|
claude: "claude-fable-5",
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Built-in profile registry for external agent CLIs (v1 spec §12.1).
|
|
6
|
+
*
|
|
7
|
+
* A `AgentProfile` is the minimum metadata required to shell-out to a
|
|
8
|
+
* coding-agent CLI. Named engines lower canonical harness metadata into this
|
|
9
|
+
* intentionally small internal shape. The wrapper is in `./spawn.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import { COMMON_SPAWN_ENV_PASSTHROUGH } from "../../core/spawn-env.js";
|
|
4
12
|
// AKM_EVENT_SOURCE carries usage-event provenance (improve/task) so that akm
|
|
5
13
|
// invocations a spawned agent makes are recorded as machine traffic, not user
|
|
6
14
|
// demand (DRIFT-6). Without it in the passthrough whitelist, buildChildEnv drops
|
|
7
15
|
// the stamp at the agent boundary — e.g. `akm wiki ingest` spawns an agent whose
|
|
8
16
|
// `akm curate/show/search` tool-calls then log source='user', silently inflating
|
|
9
17
|
// every lane's read-back (GRR). It is a provenance tag, never a secret.
|
|
10
|
-
const COMMON_PASSTHROUGH =
|
|
18
|
+
const COMMON_PASSTHROUGH = COMMON_SPAWN_ENV_PASSTHROUGH;
|
|
11
19
|
/**
|
|
12
20
|
* Built-in profiles for the agent CLIs akm knows out of the box: the five the
|
|
13
21
|
* v1 spec calls out explicitly, plus the P2 harness adapters (copilot, pi,
|
|
@@ -14,90 +14,21 @@
|
|
|
14
14
|
* NEVER imports an LLM SDK. Agents are reachable only via shell-out;
|
|
15
15
|
* this is a pre-emptive guarantee against the #222 invariant.
|
|
16
16
|
*/
|
|
17
|
-
import fs from "node:fs";
|
|
18
|
-
import os from "node:os";
|
|
19
|
-
import path from "node:path";
|
|
20
17
|
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
|
|
21
|
-
import {
|
|
18
|
+
import { collectAllowlistedEnv } from "../../core/spawn-env.js";
|
|
19
|
+
import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js";
|
|
22
20
|
import { getCommandBuilder } from "./builders.js";
|
|
23
21
|
import { DEFAULT_AGENT_TIMEOUT_MS } from "./config.js";
|
|
24
|
-
/**
|
|
25
|
-
* Supplement `existingPath` with well-known user binary directories when
|
|
26
|
-
* running in a scheduler context (cron/launchd) where PATH is stripped.
|
|
27
|
-
*
|
|
28
|
-
* Detection heuristic: if the current PATH does not contain the user's home
|
|
29
|
-
* directory, we are likely in a stripped scheduler env. In an interactive
|
|
30
|
-
* shell the user's home almost always appears (e.g. ~/.bun/bin, ~/.cargo/bin).
|
|
31
|
-
*
|
|
32
|
-
* Only directories that actually exist on disk are prepended, and only if
|
|
33
|
-
* they are not already present, so interactive-shell PATH ordering is never
|
|
34
|
-
* disturbed.
|
|
35
|
-
*/
|
|
36
|
-
export function supplementPathForSchedulerContext(existingPath) {
|
|
37
|
-
const home = os.homedir();
|
|
38
|
-
// If PATH already contains the home directory, we are in an interactive
|
|
39
|
-
// shell — skip supplementation entirely.
|
|
40
|
-
if (existingPath.split(path.delimiter).some((d) => d.startsWith(home))) {
|
|
41
|
-
return existingPath;
|
|
42
|
-
}
|
|
43
|
-
const candidates = pathCandidatesForCurrentPlatform(home);
|
|
44
|
-
const existing = new Set(existingPath.split(path.delimiter).filter(Boolean));
|
|
45
|
-
const toAdd = candidates.filter((d) => !existing.has(d) && fs.existsSync(d));
|
|
46
|
-
if (toAdd.length === 0)
|
|
47
|
-
return existingPath;
|
|
48
|
-
return [...toAdd, existingPath].filter(Boolean).join(path.delimiter);
|
|
49
|
-
}
|
|
50
|
-
function pathCandidatesForCurrentPlatform(home) {
|
|
51
|
-
if (process.platform === "win32") {
|
|
52
|
-
// Windows: Bun + Cargo + Scoop + Chocolatey + system tools. Order favors
|
|
53
|
-
// user-local installs over machine-global so the user's chosen toolchain
|
|
54
|
-
// wins. These paths are commonly stripped from Task Scheduler / service
|
|
55
|
-
// environments, mirroring the cron/launchd problem on POSIX.
|
|
56
|
-
const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
|
|
57
|
-
const userProfile = process.env.USERPROFILE ?? home;
|
|
58
|
-
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
59
|
-
return [
|
|
60
|
-
path.join(userProfile, ".bun", "bin"),
|
|
61
|
-
path.join(localAppData, "Programs", "bun"),
|
|
62
|
-
path.join(userProfile, ".cargo", "bin"),
|
|
63
|
-
path.join(localAppData, "Programs", "Git", "cmd"),
|
|
64
|
-
path.join(userProfile, "scoop", "shims"),
|
|
65
|
-
path.join(programFiles, "Git", "cmd"),
|
|
66
|
-
"C:\\ProgramData\\chocolatey\\bin",
|
|
67
|
-
];
|
|
68
|
-
}
|
|
69
|
-
return [
|
|
70
|
-
path.join(home, ".bun", "bin"),
|
|
71
|
-
path.join(home, ".cargo", "bin"),
|
|
72
|
-
path.join(home, ".local", "bin"),
|
|
73
|
-
"/opt/homebrew/bin",
|
|
74
|
-
"/opt/homebrew/sbin",
|
|
75
|
-
"/usr/local/bin",
|
|
76
|
-
];
|
|
77
|
-
}
|
|
78
22
|
/**
|
|
79
23
|
* Build the child env. Starts empty and copies through:
|
|
80
|
-
* • Every name in `profile.envPassthrough
|
|
24
|
+
* • Every name in `profile.envPassthrough` (via the shared
|
|
25
|
+
* {@link collectAllowlistedEnv}, which also supplements PATH for
|
|
26
|
+
* scheduler contexts where the inherited PATH is stripped).
|
|
81
27
|
* • Every entry in `profile.env`.
|
|
82
28
|
* • Every entry in `options.env` (highest precedence).
|
|
83
|
-
*
|
|
84
|
-
* PATH is supplemented with well-known user binary directories when running
|
|
85
|
-
* in a scheduler context (cron/launchd) where the inherited PATH is stripped.
|
|
86
|
-
* See {@link supplementPathForSchedulerContext}.
|
|
87
29
|
*/
|
|
88
30
|
function buildChildEnv(profile, options) {
|
|
89
|
-
const
|
|
90
|
-
const env = {};
|
|
91
|
-
for (const name of profile.envPassthrough) {
|
|
92
|
-
const value = source[name];
|
|
93
|
-
if (value !== undefined)
|
|
94
|
-
env[name] = value;
|
|
95
|
-
}
|
|
96
|
-
// Supplement PATH after passthrough so the scheduler-context fix applies to
|
|
97
|
-
// the value actually coming from the environment source.
|
|
98
|
-
if (env.PATH !== undefined) {
|
|
99
|
-
env.PATH = supplementPathForSchedulerContext(env.PATH);
|
|
100
|
-
}
|
|
31
|
+
const env = collectAllowlistedEnv(profile.envPassthrough, options.envSource ?? process.env);
|
|
101
32
|
if (profile.env) {
|
|
102
33
|
for (const [k, v] of Object.entries(profile.env))
|
|
103
34
|
env[k] = v;
|
|
@@ -108,19 +39,16 @@ function buildChildEnv(profile, options) {
|
|
|
108
39
|
}
|
|
109
40
|
return env;
|
|
110
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* This path's phrasing of the SHARED incomplete-capture verdict
|
|
44
|
+
* ({@link streamCaptureFailure} in `core/subprocess.ts`). The classification
|
|
45
|
+
* lives in the primitive so the agent path and the workflow `exec` path cannot
|
|
46
|
+
* drift apart on what "the capture did not complete" means; only the sentence
|
|
47
|
+
* naming the profile is local. Message text is unchanged from the inlined copy.
|
|
48
|
+
*/
|
|
111
49
|
function streamFailureMessage(profileName, stdout, stderr) {
|
|
112
|
-
const failures =
|
|
113
|
-
|
|
114
|
-
failures.push(`stdout read failed: ${stdout.error instanceof Error ? stdout.error.message : String(stdout.error)}`);
|
|
115
|
-
if (stderr.error)
|
|
116
|
-
failures.push(`stderr read failed: ${stderr.error instanceof Error ? stderr.error.message : String(stderr.error)}`);
|
|
117
|
-
if (stdout.timedOut)
|
|
118
|
-
failures.push("stdout drain timed out");
|
|
119
|
-
if (stderr.timedOut)
|
|
120
|
-
failures.push("stderr drain timed out");
|
|
121
|
-
if (failures.length === 0)
|
|
122
|
-
return undefined;
|
|
123
|
-
return `agent CLI "${profileName}" output capture failed: ${failures.join("; ")}`;
|
|
50
|
+
const failures = streamCaptureFailure(stdout, stderr);
|
|
51
|
+
return failures === undefined ? undefined : `agent CLI "${profileName}" output capture failed: ${failures}`;
|
|
124
52
|
}
|
|
125
53
|
/**
|
|
126
54
|
* Spawn the agent CLI described by `profile` with `prompt` (forwarded as
|
|
@@ -7,6 +7,7 @@ import { writeFileAtomic } from "../core/common.js";
|
|
|
7
7
|
import { ConfigError, rethrowIfTestIsolationError } from "../core/errors.js";
|
|
8
8
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js";
|
|
9
9
|
import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
|
|
10
|
+
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
10
11
|
import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
|
|
11
12
|
// ── Lock sentinel ────────────────────────────────────────────────────────────
|
|
12
13
|
const LOCK_MAX_RETRIES = 3;
|
|
@@ -54,6 +55,29 @@ export function readLockfile() {
|
|
|
54
55
|
return [];
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Refuse to treat an UNREADABLE lockfile (or data dir) as an absent one (#791).
|
|
60
|
+
*
|
|
61
|
+
* Every write path here is read-modify-WRITE: it loads the current entries and
|
|
62
|
+
* writes the whole array back. An unreadable `akm.lock` that reads as `[]`
|
|
63
|
+
* therefore does not merely lose information — the very next
|
|
64
|
+
* `writeFileAtomic` replaces the operator's entire lock record with the single
|
|
65
|
+
* entry this call happened to be adding. That is the same catastrophe R-012
|
|
66
|
+
* guards against for a *corrupt* file, reached instead through a permission
|
|
67
|
+
* fault, and `fs.existsSync`/a swallowed `readFileSync` could not tell the two
|
|
68
|
+
* apart from "the file was never created".
|
|
69
|
+
*
|
|
70
|
+
* No-op when the path is genuinely absent — that case really does have nothing
|
|
71
|
+
* to preserve.
|
|
72
|
+
*/
|
|
73
|
+
function assertLockfilePathReadable(target) {
|
|
74
|
+
const { access, code } = classifyPathAccess(target);
|
|
75
|
+
if (access !== "inaccessible")
|
|
76
|
+
return;
|
|
77
|
+
throw new ConfigError(`Refusing to modify the lockfile: ${describeInaccessiblePath(target, code)}. akm cannot read the existing lock ` +
|
|
78
|
+
"records, and writing over them would destroy every bundle they track. Fix the ownership or mode of that " +
|
|
79
|
+
"path (or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns) and retry.", "DATA_DIR_UNREADABLE");
|
|
80
|
+
}
|
|
57
81
|
/**
|
|
58
82
|
* Like {@link readLockfile}, but THROWS instead of silently degrading to `[]`
|
|
59
83
|
* when the on-disk lockfile exists yet is not parseable JSON or not a JSON
|
|
@@ -82,7 +106,15 @@ function readLockfileOrThrow() {
|
|
|
82
106
|
}
|
|
83
107
|
catch (err) {
|
|
84
108
|
rethrowIfTestIsolationError(err);
|
|
85
|
-
|
|
109
|
+
// "Missing file" is the only failure with nothing to preserve. An
|
|
110
|
+
// UNREADABLE lockfile has everything to preserve and we cannot see it —
|
|
111
|
+
// degrading it to `[]` here is precisely the destructive overwrite this
|
|
112
|
+
// function was written to prevent, only triggered by a permission fault
|
|
113
|
+
// instead of a corrupt file (#791). Classify AFTER the failed read so the
|
|
114
|
+
// happy path costs no extra syscall and the answer describes the failure
|
|
115
|
+
// we actually got.
|
|
116
|
+
assertLockfilePathReadable(lockfilePath);
|
|
117
|
+
return [];
|
|
86
118
|
}
|
|
87
119
|
let parsed;
|
|
88
120
|
try {
|
|
@@ -172,6 +204,11 @@ export async function upsertLockEntry(entry) {
|
|
|
172
204
|
function readLockEntriesForMigration() {
|
|
173
205
|
let existing = [];
|
|
174
206
|
const lockfilePath = getLockfilePath();
|
|
207
|
+
// `mergeLockEntriesSync` writes `existing` straight back out, so an
|
|
208
|
+
// unreadable lockfile read as absent would be overwritten with just the
|
|
209
|
+
// migrator's sparse entries (#791). This is also what
|
|
210
|
+
// `assertMigrationLockfileReadable` promises to have checked.
|
|
211
|
+
assertLockfilePathReadable(lockfilePath);
|
|
175
212
|
if (fs.existsSync(lockfilePath)) {
|
|
176
213
|
let raw;
|
|
177
214
|
try {
|
|
@@ -211,7 +248,13 @@ export function mergeLockEntriesSync(entries) {
|
|
|
211
248
|
writeLockfileUnlocked([...existing.filter((e) => !incomingIds.has(e.id)), ...merged]);
|
|
212
249
|
}
|
|
213
250
|
export async function removeLockEntry(id) {
|
|
214
|
-
|
|
251
|
+
// Returning early says "there is no lock record to remove", and the uninstall
|
|
252
|
+
// that called us reports success on that basis. Only an absent data dir earns
|
|
253
|
+
// it — one we cannot read may hold the very entry we were asked to drop, and
|
|
254
|
+
// silently leaving it behind is how a bundle stays "installed" forever (#791).
|
|
255
|
+
const dataDir = getDataDir();
|
|
256
|
+
assertLockfilePathReadable(dataDir);
|
|
257
|
+
if (!fs.existsSync(dataDir))
|
|
215
258
|
return;
|
|
216
259
|
const release = await acquireLockSentinel();
|
|
217
260
|
try {
|
|
@@ -16,9 +16,18 @@ function glyphFor(fixed) {
|
|
|
16
16
|
return { glyph: "✓", severityRank: 2 };
|
|
17
17
|
return { glyph: "⚠", severityRank: 1 };
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* `file:line` when the finding is line-anchored (workflow parse/compile
|
|
21
|
+
* errors), bare `file` otherwise. `LintIssue.line` is optional precisely
|
|
22
|
+
* because most lint sources are whole-file, so their headline is byte-identical
|
|
23
|
+
* to what it has always been.
|
|
24
|
+
*/
|
|
25
|
+
function locationOf(issue) {
|
|
26
|
+
return typeof issue.line === "number" ? `${issue.file}:${issue.line}` : issue.file;
|
|
27
|
+
}
|
|
19
28
|
function issueEntry(issue) {
|
|
20
29
|
const { glyph, severityRank } = glyphFor(issue.fixed);
|
|
21
|
-
return { severityRank, glyph, headline: `${issue
|
|
30
|
+
return { severityRank, glyph, headline: `${locationOf(issue)} [${issue.issue}] ${issue.detail}` };
|
|
22
31
|
}
|
|
23
32
|
function renderIssueSection(title, issues) {
|
|
24
33
|
if (issues.length === 0)
|
|
@@ -30,14 +39,18 @@ export function formatLintPlain(r) {
|
|
|
30
39
|
return null;
|
|
31
40
|
const fixed = Array.isArray(r.fixed) ? r.fixed : [];
|
|
32
41
|
const flagged = Array.isArray(r.flagged) ? r.flagged : [];
|
|
42
|
+
const warnings = Array.isArray(r.warnings) ? r.warnings : [];
|
|
33
43
|
const summary = r.summary;
|
|
34
44
|
const lines = [];
|
|
35
45
|
if (typeof r.ok === "boolean")
|
|
36
46
|
lines.push(`ok: ${r.ok}`);
|
|
37
|
-
lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}`
|
|
38
|
-
|
|
39
|
-
//
|
|
47
|
+
lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}` +
|
|
48
|
+
` warnings=${summary?.warnings ?? warnings.length}`);
|
|
49
|
+
// Flagged (still needs attention) surfaces before warnings (advisory,
|
|
50
|
+
// non-fatal) and fixed (already handled), so a scan of the output hits the
|
|
51
|
+
// actionable items first.
|
|
40
52
|
lines.push("", ...renderIssueSection("flagged", flagged));
|
|
53
|
+
lines.push("", ...renderIssueSection("warnings", warnings));
|
|
41
54
|
lines.push("", ...renderIssueSection("fixed", fixed));
|
|
42
55
|
return lines.join("\n").trim();
|
|
43
56
|
}
|