akm-cli 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/STABILITY.md +22 -14
  3. package/dist/commands/health/checks.js +23 -7
  4. package/dist/commands/health/improve-metrics.js +12 -0
  5. package/dist/commands/improve/distill/quality-gate.js +13 -5
  6. package/dist/commands/improve/eval-cases.js +9 -2
  7. package/dist/commands/improve/improve.js +19 -4
  8. package/dist/commands/improve/loop-stages.js +13 -3
  9. package/dist/commands/tasks/tasks-cli.js +48 -1
  10. package/dist/commands/tasks/validate.js +186 -0
  11. package/dist/commands/url-checker.js +75 -16
  12. package/dist/core/bundle-id.js +7 -1
  13. package/dist/core/config/schema/engines.js +17 -0
  14. package/dist/core/improve-result.js +8 -0
  15. package/dist/core/paths.js +112 -0
  16. package/dist/indexer/search/search-source.js +3 -2
  17. package/dist/integrations/agent/engine-resolution.js +92 -3
  18. package/dist/integrations/agent/execution-lowering.js +15 -2
  19. package/dist/integrations/agent/runner-dispatch.js +16 -3
  20. package/dist/integrations/agent/runner.js +2 -0
  21. package/dist/output/shapes/passthrough.js +1 -0
  22. package/dist/scripts/akm-migrate-node.js +1043 -822
  23. package/dist/scripts/akm-migrate.js +1043 -822
  24. package/dist/tasks/scheduler-sync.js +51 -25
  25. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  26. package/docs/integration/bundling-akm.md +1 -1
  27. package/docs/migration/v0.8-to-v0.9.md +32 -0
  28. package/docs/reference/cli.md +32 -6
  29. package/docs/reference/configuration.md +12 -2
  30. package/docs/reference/data-and-telemetry.md +1 -1
  31. package/docs/reference/tasks.md +8 -0
  32. package/package.json +1 -1
  33. package/schemas/akm-config.json +8 -0
@@ -0,0 +1,186 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm task validate <path>` (#907) — parse ONE task file by filesystem
6
+ * path, not a concept ref/id, and report the same diagnostic `akm task
7
+ * sync` would produce for it. The file need not live in any configured
8
+ * bundle: unlike every other `akm task` subcommand, this one never resolves
9
+ * a bundle/adapter/concept id at all — it reads exactly the path it was
10
+ * given and classifies it.
11
+ *
12
+ * Reuses the exact version-routing shim `parseTaskSource`
13
+ * (`src/tasks/source/parse-task-source.ts`) already applies for every other
14
+ * task-source reader (`akm task sync`'s `compileTaskSources` included) —
15
+ * this module never forks a second parser or a second v2/v3 migration
16
+ * planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
17
+ * the SAME front-end helpers that shim itself calls first; they are used
18
+ * here only to recover the file's ORIGINALLY DECLARED schema version for
19
+ * the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
20
+ * always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
21
+ * its own once a v2/v3 source has been converted in memory.
22
+ *
23
+ * Beyond parsing, this module also runs the SAME two per-source gates
24
+ * `akm task sync`'s `compileTaskSources` runs before it ever installs a
25
+ * schedule — `assertTaskScheduleInputsSatisfyContract` and
26
+ * `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
27
+ * exactly this reuse) — so a file `sync` would reject can never
28
+ * be reported `valid`/`converts` here. Cron dialect is checked against
29
+ * `backendNameForPlatform()`, the same platform default `sync` falls back
30
+ * to whenever it has no injected/native-inspected backend to hand (see
31
+ * `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
32
+ * installed anywhere; there is no native scheduler state to inspect for it.
33
+ *
34
+ * Deliberately DOES NOT call `prepareTaskV3Execution` (the function
35
+ * `compileTaskSources` calls between those two gates) or resolve an
36
+ * execution engine: that path resolves a composed command/persona ref
37
+ * against the local index and lowers the task's cascade-composed
38
+ * engine/model — both of which assume a real, indexed bundle and a
39
+ * configured engine. A bare file passed to `validate` has neither, so
40
+ * running that step would make an otherwise-valid command-kind task report
41
+ * `invalid` on any machine with no engine configured. `resolved`
42
+ * is therefore the compiled task shape `sync` itself would build a
43
+ * scheduler binding from — id, the compiled v4 `version`, `target`
44
+ * (`uses`/`run`), the declared `inputs` contract, and `schedule` bindings —
45
+ * never an execution-lowered plan.
46
+ *
47
+ * Outcome classification (mirrors `parse-task-source.ts`'s own routing
48
+ * table in its header, extended for the two gates above):
49
+ * - `valid` — parses as task source v4 directly (declared `version: 4`)
50
+ * and passes both sync gates.
51
+ * - `converts` — declared `version: 2` or `3`; the deterministic
52
+ * in-memory migrator produced a valid v4 document that
53
+ * passes both sync gates.
54
+ * - `blocked` — declared `version: 2` or `3`; the migrator itself
55
+ * could not convert it (an ambiguous/unmigratable
56
+ * shape) — the ONLY way `parseTaskSource` ever throws
57
+ * for those two version numbers, so no message-text
58
+ * sniffing is needed to tell this apart from `invalid`.
59
+ * - `invalid` — the document declares SOME version (`4`, or anything
60
+ * other than 2/3/4) but fails to parse/validate, OR it
61
+ * parsed (directly or via a SUCCESSFUL v2/v3
62
+ * conversion) but fails one of the two sync gates
63
+ * above, OR the YAML itself does not parse at all
64
+ * (a genuine syntax error, not merely a non-task
65
+ * shape) — reported with the parser's own reason.
66
+ * - `not-a-task` — the document parses as YAML but never declares a
67
+ * `version:` field at all (or isn't a YAML mapping) —
68
+ * the strongest signal available that the file was
69
+ * never intended as a task source in the first place.
70
+ */
71
+ import fs from "node:fs";
72
+ import path from "node:path";
73
+ import { UsageError } from "../../core/errors.js";
74
+ import { backendNameForPlatform } from "../../tasks/backends/index.js";
75
+ import { assertTaskScheduleCronValid, assertTaskScheduleInputsSatisfyContract } from "../../tasks/scheduler-sync.js";
76
+ import { own, readBoundedTaskSourceYaml } from "../../tasks/source/bounded-document.js";
77
+ import { parseTaskSource, peekTaskSourceVersion } from "../../tasks/source/parse-task-source.js";
78
+ /** True when `root` is a YAML mapping that itself declares a `version:` key, regardless of that key's type/value. */
79
+ function declaresVersionKey(root) {
80
+ return root !== null && typeof root === "object" && !Array.isArray(root) && own(root, "version");
81
+ }
82
+ function buildResolved(id, v4) {
83
+ return {
84
+ id,
85
+ version: v4.version,
86
+ ...(v4.name !== undefined ? { name: v4.name } : {}),
87
+ ...(v4.description !== undefined ? { description: v4.description } : {}),
88
+ target: v4.target,
89
+ inputs: v4.inputs ?? {},
90
+ schedule: v4.schedule,
91
+ };
92
+ }
93
+ export async function akmTaskValidate(filePath) {
94
+ const resolvedPath = path.resolve(filePath);
95
+ let stat;
96
+ try {
97
+ stat = fs.statSync(resolvedPath);
98
+ }
99
+ catch {
100
+ throw new UsageError(`Task file not found: ${JSON.stringify(filePath)}.`, "INVALID_FLAG_VALUE");
101
+ }
102
+ if (!stat.isFile()) {
103
+ throw new UsageError(`${JSON.stringify(filePath)} is not a regular file.`, "INVALID_FLAG_VALUE");
104
+ }
105
+ let yaml;
106
+ try {
107
+ yaml = fs.readFileSync(resolvedPath, "utf8");
108
+ }
109
+ catch (cause) {
110
+ throw new UsageError(`Task file ${JSON.stringify(filePath)} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_FLAG_VALUE");
111
+ }
112
+ // Peek the declared version BEFORE the real parse, using the identical
113
+ // bounded YAML front end `parseTaskSource` calls internally — never a
114
+ // second/looser YAML reader. A front-end failure here (unparseable YAML,
115
+ // not a mapping, exceeds a resource bound) means the document itself does
116
+ // not parse at all — tracked as `peekFailed` so that case reports
117
+ // `invalid`, never `not-a-task` (`not-a-task` is reserved for
118
+ // YAML that DOES parse but never declared a task shape). The real parse
119
+ // below throws the identical error either way, so nothing is lost by
120
+ // swallowing it here.
121
+ let root;
122
+ let peekFailed = false;
123
+ try {
124
+ root = readBoundedTaskSourceYaml({ yaml, filePath: resolvedPath }, { sourceLabel: "task source" }).root;
125
+ }
126
+ catch {
127
+ peekFailed = true;
128
+ }
129
+ const declaredVersion = peekFailed ? undefined : peekTaskSourceVersion(root);
130
+ const hasVersionKey = !peekFailed && declaresVersionKey(root);
131
+ const workspaceRoot = path.dirname(resolvedPath);
132
+ const backend = backendNameForPlatform();
133
+ let parsed;
134
+ try {
135
+ parsed = parseTaskSource({ yaml, filePath: resolvedPath, workspaceRoot });
136
+ }
137
+ catch (cause) {
138
+ if (!(cause instanceof UsageError))
139
+ throw cause;
140
+ const reason = cause.message;
141
+ // `parseTaskSource` only ever throws for a declared version 2/3 via the
142
+ // unmigratable-conversion branch (see this file's header) — no separate
143
+ // message check needed to recognize "blocked" here.
144
+ if (declaredVersion === 2 || declaredVersion === 3) {
145
+ return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
146
+ }
147
+ if (peekFailed) {
148
+ return { ok: false, path: resolvedPath, outcome: "invalid", reason };
149
+ }
150
+ if (!hasVersionKey) {
151
+ return { ok: false, path: resolvedPath, outcome: "not-a-task", reason };
152
+ }
153
+ return {
154
+ ok: false,
155
+ path: resolvedPath,
156
+ ...(declaredVersion !== undefined ? { sourceVersion: declaredVersion } : {}),
157
+ outcome: "invalid",
158
+ reason,
159
+ };
160
+ }
161
+ // Success is unreachable from any path that leaves `declaredVersion`
162
+ // undefined — the router requires a numeric 2/3/4 version to reach here.
163
+ const sourceVersion = declaredVersion ?? 4;
164
+ // The document itself parsed (directly, or via a successful v2/v3
165
+ // conversion) — now the two gates `compileTaskSources` runs before
166
+ // accepting it. A violation here is `invalid`, never `blocked`: the
167
+ // migrator already succeeded, so this is the same kind of defect a
168
+ // native v4 document with the identical schedule would have.
169
+ try {
170
+ assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
171
+ assertTaskScheduleCronValid(parsed.v4, backend);
172
+ }
173
+ catch (cause) {
174
+ if (!(cause instanceof UsageError))
175
+ throw cause;
176
+ return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
177
+ }
178
+ const id = path.parse(resolvedPath).name;
179
+ return {
180
+ ok: true,
181
+ path: resolvedPath,
182
+ sourceVersion,
183
+ outcome: sourceVersion === 2 || sourceVersion === 3 ? "converts" : "valid",
184
+ resolved: buildResolved(id, parsed.v4),
185
+ };
186
+ }
@@ -1,34 +1,93 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { concurrentMap } from "../core/concurrent.js";
5
+ import { systemErrorCode } from "../core/system-error.js";
4
6
  const URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
7
+ /**
8
+ * URLs are checked `URL_CHECK_CONCURRENCY` at a time via {@link concurrentMap}
9
+ * rather than all at once: a knowledge bundle can hold thousands of links, and
10
+ * firing every HEAD request in one `Promise.allSettled` batch is a
11
+ * self-inflicted denial-of-service against whatever host happens to be linked
12
+ * most. Bounding concurrency changes only how fast the check runs, never how
13
+ * much of it happens — every URL still gets checked.
14
+ */
15
+ const URL_CHECK_CONCURRENCY = 8;
16
+ /**
17
+ * Per-HEAD-request timeout, matching the old `TIMEOUT_MS` the caps this
18
+ * checker replaced used. A dead site rarely refuses cleanly — it hangs — so
19
+ * without this a handful of unresponsive hosts could stall the whole check
20
+ * indefinitely instead of the timed-out URLs simply showing up as dead.
21
+ */
22
+ const DEAD_URL_TIMEOUT_MS = 5000;
23
+ /**
24
+ * DNS/connection-level codes meaning "this machine could not reach the host
25
+ * right now" — a corporate DNS block, an offline sandbox, a transient blip —
26
+ * as opposed to "the resource is gone". Same grouping `classifyVectorFailure`
27
+ * (indexer/search/db-search.ts) uses for its "connection failed" bucket.
28
+ * These are counted in `coverage.skipped`, never reported as a `DeadUrl`: an
29
+ * indeterminate result is not evidence a link is dead.
30
+ */
31
+ const NETWORK_ERROR_CODES = new Set([
32
+ "ECONNREFUSED",
33
+ "ECONNRESET",
34
+ "ENETUNREACH",
35
+ "EHOSTUNREACH",
36
+ "ENOTFOUND",
37
+ "EAI_AGAIN",
38
+ ]);
5
39
  /**
6
40
  * Check every URL in `entries` and report the ones that are dead.
7
41
  *
8
42
  * No cap, no per-entry slice, no ceiling option. There used to be a
9
43
  * `MAX_URLS = 20` plus an undocumented `slice(0, 3)` per entry, so this
10
44
  * examined at most twenty links in a bundle holding thousands and reported
11
- * success. The first attempt at fixing that kept the cap and layered a
12
- * coverage report, a warning, and an override on top — a constraint plus more
13
- * code to explain the constraint. Both are gone. It checks what you asked it
14
- * to check.
45
+ * success. Both are gone. It checks what you asked it to check, at a bounded
46
+ * concurrency (see {@link URL_CHECK_CONCURRENCY}) so a large bundle does not
47
+ * turn into a request flood, and each request is bounded by
48
+ * {@link DEAD_URL_TIMEOUT_MS} so one unresponsive host cannot stall the rest.
15
49
  *
16
- * A request that fails, times out, or cannot resolve surfaces as a `DeadUrl`
17
- * rather than being swallowed, so a network problem is visible instead of
18
- * looking like a clean bill of health.
50
+ * A `>=400` response or a timeout surfaces as a `DeadUrl` (a timeout as
51
+ * `status: "timeout"`) rather than being swallowed, so a network problem is
52
+ * visible instead of looking like a clean bill of health. A DNS/connection
53
+ * failure (see {@link NETWORK_ERROR_CODES}) is different: it says the check
54
+ * itself could not run, not that the URL is dead, so it is counted in
55
+ * `coverage.skipped` instead of either `deadUrls` or a silent success. Any
56
+ * other thrown error still reports as `status: "error"`. `coverage.checked`
57
+ * and `coverage.total` are equal only when nothing was skipped this way.
19
58
  */
20
59
  export async function checkDeadUrls(_stashDir, entries) {
21
60
  const urlsToCheck = entries.flatMap((entry) => (entry.body.match(URL_RE) ?? []).map((url) => ({ ref: entry.ref, url })));
22
- const results = [];
23
- await Promise.allSettled(urlsToCheck.map(async ({ ref, url }) => {
61
+ const outcomes = await concurrentMap(urlsToCheck, async ({ ref, url }) => {
24
62
  try {
25
- const res = await fetch(url, { method: "HEAD", redirect: "follow" });
26
- if (res.status >= 400)
27
- results.push({ ref, url, status: res.status });
63
+ const res = await fetch(url, {
64
+ method: "HEAD",
65
+ redirect: "follow",
66
+ signal: AbortSignal.timeout(DEAD_URL_TIMEOUT_MS),
67
+ });
68
+ return res.status >= 400 ? { dead: { ref, url, status: res.status } } : {};
28
69
  }
29
- catch {
30
- results.push({ ref, url, status: "error" });
70
+ catch (err) {
71
+ if (err instanceof DOMException && err.name === "TimeoutError") {
72
+ return { dead: { ref, url, status: "timeout" } };
73
+ }
74
+ const code = systemErrorCode(err);
75
+ if (code && NETWORK_ERROR_CODES.has(code)) {
76
+ return { skipped: true };
77
+ }
78
+ return { dead: { ref, url, status: "error" } };
31
79
  }
32
- }));
33
- return results;
80
+ }, URL_CHECK_CONCURRENCY);
81
+ const deadUrls = [];
82
+ let skipped = 0;
83
+ for (const outcome of outcomes) {
84
+ if (outcome?.dead)
85
+ deadUrls.push(outcome.dead);
86
+ if (outcome?.skipped)
87
+ skipped += 1;
88
+ }
89
+ return {
90
+ deadUrls,
91
+ coverage: { checked: urlsToCheck.length - skipped, total: urlsToCheck.length, skipped },
92
+ };
34
93
  }
@@ -46,6 +46,12 @@ function ensureUniqueId(preferred, sourcePath, used) {
46
46
  n++;
47
47
  return `${suffixed}-${n}`;
48
48
  }
49
- function shortHash(input) {
49
+ /**
50
+ * First 8 hex chars of `input`'s sha256 — deterministic and short enough to
51
+ * suffix a slug or a path key. Exported so callers needing the same
52
+ * short-hash-of-a-resolved-path primitive (e.g. `getStashStateKey` in
53
+ * `paths.ts`) don't grow their own duplicate.
54
+ */
55
+ export function shortHash(input) {
50
56
  return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
51
57
  }
@@ -68,6 +68,11 @@ const LlmEngineSchema = z
68
68
  endpoint: chatCompletionsEndpoint,
69
69
  model: nonEmptyString,
70
70
  apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
71
+ // #905: file-backed alternative to `apiKey` for hosts that refuse
72
+ // secrets in the process environment. A plain filesystem path (`~`
73
+ // expanded, read at dispatch) — see resolveLlmEngineUse/
74
+ // materializeLlmConnectionWithCredential in integrations/agent/engine-resolution.ts.
75
+ apiKeyFile: nonEmptyString.optional(),
71
76
  temperature: z.number().finite().optional(),
72
77
  maxTokens: positiveInt.optional(),
73
78
  timeoutMs: timeoutMsField,
@@ -83,6 +88,17 @@ const LlmEngineSchema = z
83
88
  if (key in value)
84
89
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is not valid on an LLM engine` });
85
90
  }
91
+ // #905: apiKey and apiKeyFile are two alternative ways to supply the same
92
+ // credential; a third (the implicit AKM_ENGINE_<NAME>_API_KEY env var) is
93
+ // still available when neither is set, so only the both-set case is
94
+ // rejected here.
95
+ if (value.apiKey !== undefined && value.apiKeyFile !== undefined) {
96
+ ctx.addIssue({
97
+ code: z.ZodIssueCode.custom,
98
+ path: ["apiKeyFile"],
99
+ message: "apiKey and apiKeyFile cannot both be set",
100
+ });
101
+ }
86
102
  });
87
103
  const AgentEngineSchema = z
88
104
  .object({
@@ -103,6 +119,7 @@ const AgentEngineSchema = z
103
119
  "provider",
104
120
  "endpoint",
105
121
  "apiKey",
122
+ "apiKeyFile",
106
123
  "temperature",
107
124
  "maxTokens",
108
125
  "concurrency",
@@ -27,6 +27,7 @@ const COMMON_FIELDS = [
27
27
  "coverageGaps",
28
28
  "evalCasesWritten",
29
29
  "deadUrls",
30
+ "deadUrlCoverage",
30
31
  "reflectsWithErrorContext",
31
32
  "memoryInference",
32
33
  "graphExtraction",
@@ -440,6 +441,7 @@ function validateCommon(value) {
440
441
  "sync",
441
442
  "terminated",
442
443
  "plan",
444
+ "deadUrlCoverage",
443
445
  ]) {
444
446
  if (value[field] !== undefined && !isRecord(value[field]))
445
447
  fail(`${field} must be an object`);
@@ -453,6 +455,12 @@ function validateCommon(value) {
453
455
  fail("terminated.errorMessage must be a string when present");
454
456
  }
455
457
  }
458
+ if (isRecord(value.deadUrlCoverage)) {
459
+ requireExactFields(value.deadUrlCoverage, new Set(["checked", "total", "skipped"]));
460
+ for (const field of ["checked", "total", "skipped"]) {
461
+ requireCount(value.deadUrlCoverage, field, "deadUrlCoverage");
462
+ }
463
+ }
456
464
  }
457
465
  /**
458
466
  * Per-`schemaVersion` decoders for the persisted `improve_runs.result_json`
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
+ import { shortHash } from "./bundle-id.js";
13
14
  import { ConfigError } from "./errors.js";
14
15
  import { IS_WINDOWS } from "./platform.js";
15
16
  /**
@@ -257,6 +258,117 @@ export function getRegistryCacheDir() {
257
258
  export function getRegistryIndexCacheDir() {
258
259
  return path.join(getCacheDir(), "registry-index");
259
260
  }
261
+ // ── State directory ──────────────────────────────────────────────────────────
262
+ /**
263
+ * Returns the XDG state directory for akm (`~/.local/state/akm` on
264
+ * Linux/macOS, `%LOCALAPPDATA%\akm\state` on Windows).
265
+ *
266
+ * Per-machine runtime state that is neither durable application data
267
+ * (`$DATA`), a purely regenerable cache (`$CACHE`), nor state that must
268
+ * travel with a bundle's own content (`$STASH/.akm`; see "What May Live in
269
+ * $STASH/.akm" in docs/architecture/internals/storage-locations.md).
270
+ *
271
+ * Env overrides (in priority order):
272
+ * AKM_STATE_DIR — point to any directory
273
+ * XDG_STATE_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
274
+ */
275
+ export function getStateDir(env = process.env, platform = process.platform) {
276
+ const override = env.AKM_STATE_DIR?.trim();
277
+ if (override)
278
+ return override;
279
+ if (platform === "win32") {
280
+ const localAppData = env.LOCALAPPDATA?.trim();
281
+ if (localAppData)
282
+ return path.join(localAppData, "akm", "state");
283
+ const userProfile = env.USERPROFILE?.trim();
284
+ if (userProfile)
285
+ return path.join(userProfile, "AppData", "Local", "akm", "state");
286
+ const appData = env.APPDATA?.trim();
287
+ if (!appData) {
288
+ throw new ConfigError("Unable to determine state directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
289
+ }
290
+ return path.join(appData, "..", "Local", "akm", "state");
291
+ }
292
+ const xdgStateHome = env.XDG_STATE_HOME?.trim();
293
+ if (xdgStateHome)
294
+ return path.join(xdgStateHome, "akm");
295
+ const home = env.HOME?.trim();
296
+ if (!home)
297
+ return homelessFallbackDir("akm-state");
298
+ return path.join(home, ".local", "state", "akm");
299
+ }
300
+ // ── Per-stash state under $STATE / $CACHE (itlackey/akm#890) ─────────────────
301
+ /**
302
+ * Deterministic, filesystem-safe key for machine-local state that belongs to
303
+ * one stash but must not live under `$STASH` itself. Namespaces `$STATE`/
304
+ * `$CACHE` writers by the resolved absolute stash directory — the filesystem
305
+ * counterpart of how `state.db`'s `proposals` table keys per-stash rows by
306
+ * its `stash_dir` column — so two stashes on one machine never collide.
307
+ *
308
+ * Reuses {@link shortHash} (bundle-id.ts) rather than growing a second
309
+ * sha256-truncated-hex helper. (The other existing option,
310
+ * `getCurrentWorkflowScopeKey`'s `dir:v1:<sha256>` in
311
+ * `src/workflows/authoring/scope-key.ts`, is a state.db column value for a
312
+ * different subsystem — full untruncated hex plus a `dir:v1:` prefix, which
313
+ * is neither filesystem-safe as a directory segment on Windows (`:`) nor
314
+ * short, so it is not a fit here.) `shortHash` trims to 8 hex chars (32
315
+ * bits) — fine at its original call site because a collision only ever
316
+ * shortens a batch-unique slug suffix that a retry loop immediately
317
+ * re-disambiguates. Here there is no such retry loop, but a machine
318
+ * realistically has, at most, a handful to a few dozen distinct stash
319
+ * directories ever registered — 32 bits of digest space makes an accidental
320
+ * collision between two of them astronomically unlikely.
321
+ */
322
+ export function getStashStateKey(stashDir) {
323
+ const resolved = path.resolve(stashDir).replace(/\\/g, "/");
324
+ const normalized = IS_WINDOWS ? resolved.toLowerCase() : resolved;
325
+ return shortHash(normalized);
326
+ }
327
+ function stashScopedDir(base, stashDir) {
328
+ return path.join(base, getStashStateKey(stashDir));
329
+ }
330
+ /**
331
+ * `$STATE/improve/distill-rejected/<stash>/` — lessons that failed the
332
+ * distill quality gate. Moved out of `$STASH/.akm/distill-rejected/`
333
+ * (itlackey/akm#890): nothing reads it to resolve bundle content, so it does
334
+ * not meet the "must travel with the content" rule.
335
+ */
336
+ export function getDistillRejectedDir(stashDir) {
337
+ return stashScopedDir(path.join(getStateDir(), "improve", "distill-rejected"), stashDir);
338
+ }
339
+ /**
340
+ * `$STATE/improve/eval-cases/<stash>/` — regression eval cases captured from
341
+ * rejected distill/proposal output. Moved out of `$STASH/.akm/eval-cases/`
342
+ * (itlackey/akm#890).
343
+ */
344
+ export function getEvalCasesDir(stashDir) {
345
+ return stashScopedDir(path.join(getStateDir(), "improve", "eval-cases"), stashDir);
346
+ }
347
+ /**
348
+ * `$STATE/improve/measurement/verdicts/<stash>/` — `akm-eval-proactive-verdict`
349
+ * reports. Moved out of `$STASH/.akm/measurement/verdicts/` (itlackey/akm#890);
350
+ * the pilot treatment file at `$STASH/.akm/measurement/` is manually-authored
351
+ * measurement input and stays put.
352
+ */
353
+ export function getMeasurementVerdictsDir(stashDir) {
354
+ return stashScopedDir(path.join(getStateDir(), "improve", "measurement", "verdicts"), stashDir);
355
+ }
356
+ /**
357
+ * `$CACHE/index/unresolved-sources/<stash>/` — synthetic placeholder path for
358
+ * a configured source whose content root did not resolve this run. Never
359
+ * written to disk; only used as a stable, reportable `SearchSource.path`.
360
+ * Moved out of `$STASH/.akm/unresolved-sources/` (itlackey/akm#890).
361
+ */
362
+ export function getUnresolvedSourcesDir(stashDir) {
363
+ return stashScopedDir(path.join(getCacheDir(), "index", "unresolved-sources"), stashDir);
364
+ }
365
+ /**
366
+ * `$STATE/locks/<stash>/` — per-stash operational lock files for the improve
367
+ * pipeline (e.g. `improve.lock`). Moved out of `$STASH/.akm/` (itlackey/akm#890).
368
+ */
369
+ export function getStashLocksDir(stashDir) {
370
+ return stashScopedDir(path.join(getStateDir(), "locks"), stashDir);
371
+ }
260
372
  // ── Scheduled-task runtime directories (logs + history) ──────────────────────
261
373
  export function getTaskLogDir() {
262
374
  return path.join(getCacheDir(), "tasks", "logs");
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { isWithin, resolveStashDir } from "../../core/common.js";
7
7
  import { bundleComponentConfig, bundlesToSourceEntries, getSources, loadConfig } from "../../core/config/config.js";
8
+ import { getUnresolvedSourcesDir } from "../../core/paths.js";
8
9
  import { resolveGitContentRoot, resolveWritable } from "../../core/write-source.js";
9
10
  import { lockContentRootFor } from "../../integrations/lockfile.js";
10
11
  import { resolveSourceProviderFactory } from "../../sources/provider-factory.js";
@@ -86,14 +87,14 @@ export function resolveSourceEntries(overrideStashDir, existingConfig) {
86
87
  const component = bundleComponentConfig(config.bundles?.[entry.name ?? ""]);
87
88
  const contentRoot = resolveEntryContentDir(entry);
88
89
  if (contentRoot == null) {
89
- const unresolvedPath = path.join(implicitStashDir ?? process.cwd(), ".akm", "unresolved-sources", entry.name ?? entry.type);
90
+ const unresolvedPath = path.join(getUnresolvedSourcesDir(implicitStashDir ?? process.cwd()), entry.name ?? entry.type);
90
91
  addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
91
92
  continue;
92
93
  }
93
94
  const dir = path.resolve(contentRoot, component?.root ?? ".");
94
95
  if (!isWithin(dir, contentRoot)) {
95
96
  warn(`Warning: component root "${component?.root}" escapes bundle "${entry.name}"; skipping source.`);
96
- const unresolvedPath = path.join(contentRoot, ".akm", "unresolved-sources", entry.name ?? entry.type);
97
+ const unresolvedPath = path.join(getUnresolvedSourcesDir(contentRoot), entry.name ?? entry.type);
97
98
  addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
98
99
  continue;
99
100
  }