akm-cli 0.9.1-beta.1 → 0.9.1-beta.3

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 (64) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/dist/cli/parse-args.js +7 -1
  3. package/dist/commands/env/child-env.js +14 -0
  4. package/dist/commands/health/advisories.js +5 -5
  5. package/dist/commands/health/html-report.js +2 -2
  6. package/dist/commands/health/metrics.js +38 -22
  7. package/dist/commands/health/report-view-model.js +1 -1
  8. package/dist/commands/improve/consolidate.js +61 -9
  9. package/dist/commands/improve/eval-cases.js +2 -0
  10. package/dist/commands/improve/memory/memory-improve.js +1 -0
  11. package/dist/commands/lint/base-linter.js +93 -20
  12. package/dist/commands/lint/index.js +5 -1
  13. package/dist/commands/sources/add-cli.js +8 -2
  14. package/dist/commands/sources/migration-help.js +12 -3
  15. package/dist/commands/sources/self-update.js +9 -1
  16. package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
  17. package/dist/core/adapter/adapters/akm-lint.js +6 -2
  18. package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
  19. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  20. package/dist/core/asset/frontmatter.js +6 -1
  21. package/dist/core/common.js +81 -3
  22. package/dist/core/config/config-io.js +5 -45
  23. package/dist/core/config/schema/engines.js +14 -3
  24. package/dist/core/extra-params.js +11 -0
  25. package/dist/core/fs-txn.js +15 -2
  26. package/dist/core/json-schema.js +19 -2
  27. package/dist/core/paths.js +16 -2
  28. package/dist/core/redaction.js +22 -1
  29. package/dist/core/state-db.js +1 -0
  30. package/dist/core/write-source.js +26 -2
  31. package/dist/indexer/indexer.js +48 -9
  32. package/dist/indexer/search/db-search.js +17 -2
  33. package/dist/indexer/walk/walker.js +6 -1
  34. package/dist/integrations/agent/detect.js +13 -1
  35. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  36. package/dist/integrations/lockfile.js +10 -0
  37. package/dist/llm/client.js +14 -19
  38. package/dist/llm/embedder.js +23 -3
  39. package/dist/llm/embedders/remote.js +27 -2
  40. package/dist/output/html-render.js +40 -1
  41. package/dist/runtime.js +23 -1
  42. package/dist/scripts/akm-migrate-node.js +303 -107
  43. package/dist/scripts/akm-migrate.js +303 -107
  44. package/dist/setup/setup.js +22 -7
  45. package/dist/sources/providers/git-install.js +25 -2
  46. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  47. package/dist/storage/database.js +71 -12
  48. package/dist/storage/engines/sqlite-migrations.js +61 -2
  49. package/dist/storage/repositories/index-connection.js +11 -1
  50. package/dist/storage/repositories/index-meta-repository.js +11 -0
  51. package/dist/storage/repositories/index-schema.js +17 -2
  52. package/dist/storage/repositories/index-vec-repository.js +43 -5
  53. package/dist/storage/repositories/salience-repository.js +13 -12
  54. package/dist/storage/sqlite-pragmas.js +12 -1
  55. package/dist/tasks/runner.js +84 -7
  56. package/dist/tasks/scheduler-invocation.js +19 -0
  57. package/dist/tasks/schema.js +21 -1
  58. package/dist/text-import-hook.mjs +1 -1
  59. package/dist/workflows/exec/native-executor.js +8 -0
  60. package/dist/workflows/exec/step-work.js +10 -2
  61. package/dist/workflows/parser.js +26 -1
  62. package/package.json +1 -1
  63. package/schemas/akm-config.json +10 -5
  64. package/schemas/akm-workflow.json +7 -3
package/CHANGELOG.md CHANGED
@@ -6,7 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.9.1-beta.1] - 2026-08-13
9
+ ## [0.9.1-beta.3] - 2026-08-18
10
+
11
+ ### Fixed
12
+
13
+ - Preserve multiline frontmatter descriptions when `akm lint --fix` quotes
14
+ colons, recover already-malformed quoted descriptions, and report a fix only
15
+ when the file actually changes.
16
+ - Skip consolidation promotion proposals whose body already exists in a live
17
+ knowledge asset, preventing exact-content duplicates from recurring in the
18
+ proposal backlog.
19
+ - Derive utility `last_used_at` values only from real user retrieval events
20
+ (`search`, `show`, and `curate`) instead of stamping assets with index time.
21
+ - Compute the salience-distribution health metric over every positive,
22
+ non-missing salience value rather than a top-ranked 100-row slice, and report
23
+ the evaluated sample size.
24
+
25
+ ## [0.9.1-beta.2] - 2026-08-17
10
26
 
11
27
  ### Breaking changes & migration
12
28
 
@@ -361,6 +377,23 @@ what an upgrader reads first.
361
377
 
362
378
  ### Fixed
363
379
 
380
+ - **Upgrading Node after installing akm now explains itself.** A native binding
381
+ is built for the Node ABI present at install time, so upgrading Node major
382
+ versions afterwards leaves akm reporting a bare Node internals message —
383
+ *"The module … was compiled against a different Node.js version"*, or on a
384
+ second attempt the even less helpful *"Module did not self-register"*. akm now
385
+ recognises that failure and answers with the one command that fixes it
386
+ (`npm rebuild better-sqlite3`), names the ABI actually running, and says
387
+ plainly that this is not a broken install.
388
+
389
+ The diagnostic had to move to do this. It wrapped the `require`, but
390
+ `require("better-sqlite3")` **succeeds** against a mismatched binding — the
391
+ package resolves its `.node` file lazily — so the error lands at
392
+ `new Database(...)` and the loader's handler never saw it. The previous text
393
+ telling the user to look for a version mismatch "in the error below" was
394
+ unreachable. Found by installing the published build under Node 22 and running
395
+ it under Node 24.
396
+
364
397
  - **akm's Node fallback no longer aborts at teardown on Node 24.** On Node
365
398
  24.19.0 and later, any command that opened a database could intermittently
366
399
  die with `node::RemoveEnvironmentCleanupHook … Assertion (env) != nullptr`
@@ -45,8 +45,14 @@ export function parsePositiveIntFlag(raw, flagName = "--limit") {
45
45
  const trimmed = raw.trim();
46
46
  if (!trimmed)
47
47
  return undefined;
48
+ // Strict digits, matching parseNonNegativeIntFlag below. parseInt stops at the
49
+ // first non-digit, so "10x" silently became 10, "3.5" became 3, and
50
+ // "5 apples" became 5 — accepted rather than rejected as invalid.
51
+ if (!/^\d+$/.test(trimmed)) {
52
+ throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
53
+ }
48
54
  const parsed = parseInt(trimmed, 10);
49
- if (Number.isNaN(parsed) || parsed <= 0) {
55
+ if (parsed <= 0) {
50
56
  throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
51
57
  }
52
58
  return parsed;
@@ -1,6 +1,7 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
4
5
  const CLEAN_ENV_ALLOWLIST = [
5
6
  "HOME",
6
7
  "PATH",
@@ -38,6 +39,19 @@ export function buildChildEnv(parentEnv, options) {
38
39
  if (parentEnv[key] !== undefined)
39
40
  base[key] = parentEnv[key];
40
41
  }
42
+ // The allowlist above is POSIX-shaped. On Windows a child started without
43
+ // SystemRoot/COMSPEC/PATHEXT and friends frequently cannot start at all —
44
+ // which is why every other spawn path in the codebase applies this floor
45
+ // (see spawnEnvNamesFor). `env run --clean` / `secret run --clean` did not,
46
+ // so clean-mode injection was unusable there. The floor is names the OS
47
+ // requires of any child, not user configuration, so it does not weaken what
48
+ // "clean" means about inherited secrets.
49
+ if (process.platform === "win32") {
50
+ for (const key of WIN32_SPAWN_ENV_FLOOR) {
51
+ if (parentEnv[key] !== undefined)
52
+ base[key] = parentEnv[key];
53
+ }
54
+ }
41
55
  }
42
56
  for (const key of options.inherit) {
43
57
  if (parentEnv[key] !== undefined)
@@ -53,17 +53,17 @@ export function collectImproveAdvisories(db, stateDbPath, since, improveSummary)
53
53
  "treat outcome-derived rank contributions as noise until a real usage/outcome signal lands.",
54
54
  });
55
55
  }
56
- // Salience-distribution collapse: Gini below the uniform baseline means
57
- // ranking no longer discriminates between assets.
56
+ // Salience-distribution collapse across all assets with retrieval evidence.
58
57
  if (improveSummary.degradation?.salienceUniformityFlagged) {
58
+ const sampleSize = improveSummary.degradation.retrievalSalienceSampleSize;
59
59
  advisories.push({
60
60
  name: "salience-uniformity-collapse",
61
61
  status: "warn",
62
62
  kind: "deterministic",
63
63
  confidence: "high",
64
- message: `Salience distribution collapsed toward uniform: top-100 retrieval_salience Gini = ` +
65
- `${improveSummary.degradation.corpusCentroidDistance} < 0.08 (uniform baseline ≈ 0.1). ` +
66
- "Ranking currently carries little to no discrimination between assets.",
64
+ message: `Salience distribution collapsed toward uniform: retrieval_salience Gini = ` +
65
+ `${improveSummary.degradation.corpusCentroidDistance} < 0.08 across ${sampleSize} ` +
66
+ "observed, resolvable assets. Ranking carries little discrimination among assets with retrieval evidence.",
67
67
  });
68
68
  }
69
69
  // Enrichment-vs-minting policy: enrichment lanes edit existing assets;
@@ -351,8 +351,8 @@ function renderActionItems(vm) {
351
351
  prio: "P2",
352
352
  cls: "warn",
353
353
  title: "Salience distribution collapsed: retrieval_salience Gini < 0.08",
354
- descHtml: "The top-100 salience scores are near-uniform (uniform baseline ≈ 0.1) " +
355
- "ranking currently carries little to no discrimination between assets. " +
354
+ descHtml: `The ${vm.degradation.retrievalSalienceSampleSize} observed, resolvable salience scores are near-uniform — ` +
355
+ "ranking carries little discrimination among assets with retrieval evidence. " +
356
356
  `Corpus diversity proxy: ${esc(String(vm.degradation.corpusCentroidDistance))}.`,
357
357
  remedy: "akm health --format json | jq '.improve.degradation'",
358
358
  });
@@ -12,11 +12,23 @@ import { withStateDb } from "../../core/state-db.js";
12
12
  import { insertEvent } from "../../storage/repositories/events-repository.js";
13
13
  import { queryImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
14
14
  import { listStateProposals } from "../../storage/repositories/proposals-repository.js";
15
- import { getTopRetrievalSalience } from "../../storage/repositories/salience-repository.js";
15
+ import { getObservedRetrievalSalience } from "../../storage/repositories/salience-repository.js";
16
16
  import { roundRate, toFiniteNumber } from "./improve-metrics.js";
17
17
  import { ENRICHMENT_LANES, } from "./types.js";
18
18
  /** Event type appended + read back by the state.db round-trip probe. */
19
19
  const HEALTH_PROBE_EVENT = "health_probe";
20
+ /**
21
+ * Retrieval-salience Gini guardrails.
22
+ *
23
+ * These are distribution-shape thresholds, not quantiles tied to a corpus
24
+ * size. Synthetic anchors pinned in monitor-liveness.test.ts are ~0.01 for a
25
+ * near-uniform two-band distribution, 0.25 for a balanced 0.25/0.75 spread,
26
+ * and ~0.82 for one dominant value among nine 0.01 values. Full-observation
27
+ * production snapshots also sit stably in the neutral band: 0.2613 at n=1,209
28
+ * (2026-07-12) and 0.2506 at n=1,454 (2026-08-17, missing rows excluded).
29
+ */
30
+ const SALIENCE_UNIFORMITY_GINI_THRESHOLD = 0.08;
31
+ const SALIENCE_ENTRENCHMENT_GINI_THRESHOLD = 0.35;
20
32
  /** Synthetic sentinel ref (ref-grammar decision D-R3): a colon-free
21
33
  * `<subsystem>/_<marker>` label. `health` has no asset stash-subdir, so
22
34
  * `health/_probe` names the subsystem. */
@@ -204,38 +216,41 @@ export function computeEnrichmentMintingRollup(db, since, until) {
204
216
  * @param until - Window end (ISO-8601).
205
217
  */
206
218
  export function computeDegradationMetrics(db, since, until) {
207
- // (a) Corpus diversity — salience rank distribution of the top-100 assets.
208
- // We use the Gini coefficient of retrieval_salience scores as an intra-corpus
209
- // diversity proxy. A Gini close to 1 = highly concentrated (entrenched top
210
- // assets), Gini near 0 = flat/diverse. This is a single-snapshot metric;
211
- // consecutive-run centroid distance requires cross-run history not yet stored.
219
+ // (a) Corpus diversity — distribution of every observed retrieval-salience
220
+ // value for a currently resolvable asset. Zero is the no-observation floor
221
+ // and is excluded: the diagnostic measures discrimination among assets that
222
+ // have a retrieval signal, not corpus coverage.
223
+ //
224
+ // Do not preselect by rank_score here. The old top-100 sample truncated the
225
+ // distribution before measuring it, producing a low Gini even when the full
226
+ // observed corpus had a healthy spread.
212
227
  let corpusCentroidDistance = Number.NaN;
228
+ let retrievalSalienceSampleSize = 0;
213
229
  let entrenchmentFlagged;
214
230
  let salienceUniformityFlagged;
215
231
  try {
216
232
  // Fail-open: the asset_salience table may not exist yet (pre-WS-1 install).
217
- // getTopRetrievalSalience (storage/repositories/salience-repository.ts,
218
- // #672 part 2) owns the raw SQL; this call site's try/catch is unchanged.
219
- const rows = getTopRetrievalSalience(db, 100);
233
+ const rows = getObservedRetrievalSalience(db);
234
+ retrievalSalienceSampleSize = rows.length;
220
235
  if (rows.length >= 5) {
236
+ // The repository returns ascending values. Keep a defensive sort so the
237
+ // O(n log n) closed-form Gini remains correct if that contract changes.
221
238
  const vals = rows.map((r) => r.retrieval_salience).sort((a, b) => a - b);
222
239
  const n = vals.length;
223
- const sumAbsDiff = vals.reduce((acc, xi, i) => {
224
- return acc + vals.slice(i + 1).reduce((a, xj) => a + Math.abs(xi - xj), 0);
225
- }, 0);
226
- const mean = vals.reduce((a, b) => a + b, 0) / n;
227
- // `sumAbsDiff` contains each unordered pair once, so the standard Gini
228
- // denominator is × mean (the equivalent ordered-pair formula has 2n²).
229
- const gini = mean > 0 ? sumAbsDiff / (n * n * mean) : 0;
240
+ const sum = vals.reduce((acc, value) => acc + value, 0);
241
+ const weightedSum = vals.reduce((acc, value, index) => acc + (index + 1) * value, 0);
242
+ // Closed-form Gini for sorted non-negative values. This is O(n log n)
243
+ // including the defensive sort; the previous pairwise implementation
244
+ // was O(n²) and only safe because the sample was capped at 100.
245
+ const gini = sum > 0 ? (2 * weightedSum) / (n * sum) - (n + 1) / n : 0;
230
246
  // Re-express as a diversity proxy in [0,1]: high gini = low diversity.
231
247
  // corpusCentroidDistance approximation: gini is "distance from uniform".
232
- // Two-tailed: >0.35 flags entrenchment (robustly above the ~0.1 uniform
233
- // baseline); <0.08 flags uniformity collapse the distribution no longer
234
- // discriminates between assets (live 2026-07 value 0.040 sat unflagged
235
- // in this tail under the old one-tailed check).
248
+ // Two-tailed: high concentration flags entrenchment; very low spread
249
+ // flags near-uniformity. Calibration provenance is documented with the
250
+ // constants above and pinned by synthetic distribution tests.
236
251
  corpusCentroidDistance = roundRate(gini);
237
- entrenchmentFlagged = gini > 0.35;
238
- salienceUniformityFlagged = gini < 0.08;
252
+ entrenchmentFlagged = gini > SALIENCE_ENTRENCHMENT_GINI_THRESHOLD;
253
+ salienceUniformityFlagged = gini < SALIENCE_UNIFORMITY_GINI_THRESHOLD;
239
254
  }
240
255
  }
241
256
  catch {
@@ -305,6 +320,7 @@ export function computeDegradationMetrics(db, since, until) {
305
320
  }
306
321
  return {
307
322
  corpusCentroidDistance,
323
+ retrievalSalienceSampleSize,
308
324
  entrenchmentFlagged,
309
325
  salienceUniformityFlagged,
310
326
  mergeFidelityContradictionRate,
@@ -455,7 +455,7 @@ function buildSummaryRows(aggregates, trend) {
455
455
  "Corpus diversity (Gini)",
456
456
  num(degradation.corpusCentroidDistance),
457
457
  degradation.entrenchmentFlagged || degradation.salienceUniformityFlagged ? "down" : "flat",
458
- "Gini coefficient of retrieval_salience for top-100 ranked assets. Two-tailed: >0.35 = entrenchment risk; <0.08 = collapsed toward uniform (ranking no longer discriminates).",
458
+ `Gini coefficient of positive retrieval_salience values across ${degradation.retrievalSalienceSampleSize} resolvable assets. Two-tailed: >0.35 = entrenchment risk; <0.08 = collapsed toward uniform.`,
459
459
  ], [
460
460
  "Merge fidelity contradiction rate",
461
461
  pct(degradation.mergeFidelityContradictionRate, 1),
@@ -286,6 +286,46 @@ function loadPendingConsolidateProposalHashes(stashDir) {
286
286
  }
287
287
  return hashes;
288
288
  }
289
+ /**
290
+ * Hash the bodies of live knowledge assets once per consolidation run.
291
+ *
292
+ * Pending-proposal dedup prevents repeated queue entries, but accepted
293
+ * proposals leave that set. Without a live-asset guard, the next run can copy
294
+ * the same memory body into a new knowledge slug indefinitely. Scan the target
295
+ * tree directly (rather than trusting the asynchronously refreshed index) so
296
+ * an already-written asset suppresses recurrence immediately.
297
+ */
298
+ export function loadExistingKnowledgeBodyHashes(targetRoot) {
299
+ const hashes = new Set();
300
+ const knowledgeRoot = path.join(targetRoot, "knowledge");
301
+ if (!fs.existsSync(knowledgeRoot))
302
+ return hashes;
303
+ const visit = (dir) => {
304
+ let entries;
305
+ try {
306
+ entries = fs.readdirSync(dir, { withFileTypes: true });
307
+ }
308
+ catch {
309
+ return;
310
+ }
311
+ for (const entry of entries) {
312
+ const entryPath = path.join(dir, entry.name);
313
+ if (entry.isDirectory()) {
314
+ visit(entryPath);
315
+ }
316
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
317
+ try {
318
+ hashes.add(cacheHash(fs.readFileSync(entryPath, "utf8")));
319
+ }
320
+ catch {
321
+ // An unreadable asset cannot provide reliable duplicate evidence.
322
+ }
323
+ }
324
+ }
325
+ };
326
+ visit(knowledgeRoot);
327
+ return hashes;
328
+ }
289
329
  /** Parse a stored provenance ref and emit its canonical D-R5 display spelling. */
290
330
  function canonicalStoredXref(ref) {
291
331
  try {
@@ -976,6 +1016,7 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
976
1016
  memoryByRef,
977
1017
  promoted,
978
1018
  promotedSourceRefs: new Set(),
1019
+ existingKnowledgeBodyHashes: loadExistingKnowledgeBodyHashes(opts.writeTarget.source.path),
979
1020
  promotionFailures,
980
1021
  warnings,
981
1022
  pushSkipReason: accounting.pushSkipReason,
@@ -1011,8 +1052,26 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
1011
1052
  },
1012
1053
  });
1013
1054
  }
1055
+ /** Reject a promotion when its body already exists in knowledge or the queue. */
1056
+ function shouldSkipPromotionBodyDuplicate(args) {
1057
+ const { bodyHash, op, knowledgeRef, ctx } = args;
1058
+ if (ctx.existingKnowledgeBodyHashes.has(bodyHash)) {
1059
+ ctx.warnings.push(`Skipping promote: identical body already exists in knowledge; skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1060
+ ctx.pushSkipReason("promote", op.ref, "dedup_existing_knowledge");
1061
+ return true;
1062
+ }
1063
+ const contentDupProposal = listProposals(ctx.stashDir, { status: "pending" })
1064
+ .filter((proposal) => proposal.source === "consolidate")
1065
+ .find((proposal) => cacheHash(proposalContent(proposal)) === bodyHash);
1066
+ if (!contentDupProposal)
1067
+ return false;
1068
+ ctx.warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1069
+ ctx.pushSkipReason("promote", op.ref, "dedup_pending_proposal");
1070
+ return true;
1071
+ }
1014
1072
  /** Execute one reconciled promotion by emitting a reviewable proposal. */
1015
- async function emitPromotionProposal(op, ctx) {
1073
+ /** @internal Executes the real proposal-emission path for one promote operation. */
1074
+ export async function emitPromotionProposal(op, ctx) {
1016
1075
  const { config, stashDir, sourceRun, target, memoryByRef, warnings, pushSkipReason, promoted, promotedSourceRefs } = ctx;
1017
1076
  const entry = memoryByRef.get(op.ref);
1018
1077
  if (!entry) {
@@ -1111,15 +1170,8 @@ async function emitPromotionProposal(op, ctx) {
1111
1170
  // Use cacheHash (case-preserving stripped body) to match the canonical
1112
1171
  // hash domain used by the body-embedding cache and pending-proposal set.
1113
1172
  const bodyHash = cacheHash(sourceBody);
1114
- const allPendingConsolidateProposals = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
1115
- const contentDupProposal = allPendingConsolidateProposals.find((p) => {
1116
- return cacheHash(proposalContent(p)) === bodyHash;
1117
- });
1118
- if (contentDupProposal) {
1119
- warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1120
- pushSkipReason("promote", op.ref, "dedup_pending_proposal");
1173
+ if (shouldSkipPromotionBodyDuplicate({ bodyHash, op, knowledgeRef, ctx }))
1121
1174
  return;
1122
- }
1123
1175
  try {
1124
1176
  // Use LLM-provided description; fall back to memory's own description
1125
1177
  // (post-sanitization frontmatter is authoritative).
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { writeFileAtomic } from "../../core/common.js";
7
+ import { recordWrittenPath } from "../../core/write-provenance.js";
7
8
  export function writeEvalCase(stashDir, evalCase) {
8
9
  const evalDir = path.join(stashDir, ".akm", "eval-cases");
9
10
  fs.mkdirSync(evalDir, { recursive: true });
@@ -28,6 +29,7 @@ Use it as a regression test: future improve runs on this ref should not produce
28
29
  output that would be rejected for the same reason.
29
30
  `;
30
31
  writeFileAtomic(filePath, content);
32
+ recordWrittenPath(filePath);
31
33
  return filePath;
32
34
  }
33
35
  export function countEvalCases(stashDir) {
@@ -543,6 +543,7 @@ function appendBeliefStateTransitionLog(stashDir, transitions) {
543
543
  }))
544
544
  .join("\n");
545
545
  fs.appendFileSync(logPath, `${lines}\n`, "utf8");
546
+ recordWrittenPath(logPath);
546
547
  return logPath;
547
548
  }
548
549
  function priorBeliefStateForArchive(candidate) {
@@ -33,6 +33,7 @@
33
33
  // ----------------------------------------------------------------------------
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
+ import { isScalar, parseDocument } from "yaml";
36
37
  import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
37
38
  import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
38
39
  import { spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
@@ -41,26 +42,87 @@ import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
41
42
  import { localDateStamp } from "../../core/common.js";
42
43
  import { findFenceRegions } from "./markdown-insertion.js";
43
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
45
+ /** Fold physically wrapped prose the same way a YAML plain scalar does. */
46
+ function foldDescriptionLines(lines) {
47
+ let value = "";
48
+ let blankLines = 0;
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed) {
52
+ blankLines++;
53
+ continue;
54
+ }
55
+ if (value)
56
+ value += blankLines > 0 ? "\n".repeat(blankLines) : " ";
57
+ value += trimmed;
58
+ blankLines = 0;
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * Recover a description from malformed wrapped YAML.
64
+ *
65
+ * Older producers emitted a valid quoted first physical line followed by
66
+ * indented prose outside the quote. The full document cannot be parsed, but
67
+ * the first line can still be decoded independently and the continuation can
68
+ * be folded without guessing at escape sequences in that first segment.
69
+ */
70
+ function recoverMalformedDescription(firstSegment, continuation) {
71
+ const firstLine = parseDocument(`description: ${firstSegment}`);
72
+ const firstValue = firstLine.get("description", true);
73
+ const decodedFirst = firstLine.errors.length === 0 && isScalar(firstValue) && typeof firstValue.value === "string"
74
+ ? firstValue.value
75
+ : firstSegment.trim();
76
+ const value = foldDescriptionLines([decodedFirst, ...continuation]);
77
+ return value || null;
78
+ }
79
+ /**
80
+ * Quote the complete description scalar, including physical continuation
81
+ * lines, and verify that the replacement is valid YAML before returning it.
82
+ */
44
83
  function fixUnquotedColon(raw) {
84
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
45
85
  const lines = raw.split(/\r?\n/);
46
86
  if (lines[0]?.trim() !== "---")
47
- return raw;
87
+ return null;
48
88
  const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
49
89
  if (closeIdx === -1)
50
- return raw;
90
+ return null;
51
91
  for (let i = 1; i < closeIdx; i++) {
52
- const m = lines[i].match(/^(description:\s*)(.*)/);
92
+ const m = lines[i]?.match(/^(description:\s*)(.*)/);
53
93
  if (!m)
54
94
  continue;
55
- const prefix = m[1];
56
- const value = m[2].trim();
57
- if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
58
- (value.startsWith("'") && value.endsWith("'") && value.length >= 2))
95
+ const [, prefix, firstSegment] = m;
96
+ if (prefix === undefined || firstSegment === undefined)
59
97
  continue;
60
- lines[i] = `${prefix}"${value.replace(/"/g, '\\"')}"`;
61
- break;
98
+ let continuationEnd = i + 1;
99
+ while (continuationEnd < closeIdx) {
100
+ const continuationLine = lines[continuationEnd];
101
+ if (continuationLine === undefined || (!/^[ \t]/.test(continuationLine) && continuationLine.trim()))
102
+ break;
103
+ continuationEnd++;
104
+ }
105
+ const frontmatter = lines.slice(1, closeIdx).join("\n");
106
+ const document = parseDocument(frontmatter);
107
+ const description = document.get("description", true);
108
+ const value = document.errors.length === 0 && isScalar(description) && typeof description.value === "string"
109
+ ? description.value
110
+ : recoverMalformedDescription(firstSegment.trim(), lines.slice(i + 1, continuationEnd));
111
+ if (value === null)
112
+ return null;
113
+ lines.splice(i, continuationEnd - i, `${prefix}${JSON.stringify(value)}`);
114
+ const candidate = lines.join(eol);
115
+ const candidateCloseIdx = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
116
+ const candidateDocument = parseDocument(lines.slice(1, candidateCloseIdx).join("\n"));
117
+ const candidateDescription = candidateDocument.get("description", true);
118
+ if (candidateDocument.errors.length > 0 ||
119
+ !isScalar(candidateDescription) ||
120
+ candidateDescription.value !== value) {
121
+ return null;
122
+ }
123
+ return candidate;
62
124
  }
63
- return lines.join("\n");
125
+ return null;
64
126
  }
65
127
  function checkMissingUpdated(data, frontmatterText) {
66
128
  return frontmatterText !== null && !("updated" in data);
@@ -473,16 +535,27 @@ export function runBaseChecks(ctx) {
473
535
  const unquotedColonDetail = checkUnquotedDescriptionColon(ctx.frontmatter);
474
536
  if (unquotedColonDetail) {
475
537
  if (ctx.fix) {
476
- currentRaw = fixUnquotedColon(currentRaw);
477
- modified = true;
478
- const issue = {
479
- file: ctx.relPath,
480
- issue: "unquoted-colon",
481
- detail: unquotedColonDetail,
482
- fixed: true,
483
- };
484
- issues.push(issue);
485
- pendingFixes.push(issue);
538
+ const fixedRaw = fixUnquotedColon(currentRaw);
539
+ if (fixedRaw === null) {
540
+ issues.push({
541
+ file: ctx.relPath,
542
+ issue: "unquoted-colon",
543
+ detail: `${unquotedColonDetail} — could not construct a valid YAML replacement`,
544
+ fixed: "failed",
545
+ });
546
+ }
547
+ else {
548
+ currentRaw = fixedRaw;
549
+ modified = true;
550
+ const issue = {
551
+ file: ctx.relPath,
552
+ issue: "unquoted-colon",
553
+ detail: unquotedColonDetail,
554
+ fixed: true,
555
+ };
556
+ issues.push(issue);
557
+ pendingFixes.push(issue);
558
+ }
486
559
  }
487
560
  else {
488
561
  issues.push({
@@ -502,7 +502,11 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
502
502
  }
503
503
  for (const filePath of assetFiles) {
504
504
  // Skip registry-cached read-only files — --fix must not mutate them.
505
- if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
505
+ // Compare on a separator-normalized copy: on Windows these paths carry
506
+ // backslashes, so the forward-slash substring never matched and --fix
507
+ // rewrote files inside the registry cache.
508
+ const posixPath = filePath.replace(/\\/g, "/");
509
+ if (posixPath.includes("/.cache/") || posixPath.includes("/registry/"))
506
510
  continue;
507
511
  const relPath = path.relative(stashRoot, filePath);
508
512
  let raw;
@@ -10,6 +10,7 @@ import { decideDangerousKeyInstall } from "../../core/activation-policy.js";
10
10
  import { UsageError } from "../../core/errors.js";
11
11
  import { appendEvent } from "../../core/events.js";
12
12
  import { warn } from "../../core/warn.js";
13
+ import { sanitizeString } from "../../sources/providers/provider-utils.js";
13
14
  import { akmRemove } from "./installed-stashes.js";
14
15
  import { akmAdd } from "./source-add.js";
15
16
  import { addStash } from "./source-manage.js";
@@ -180,9 +181,14 @@ export async function auditInstalledStashForDangerousKeys(opts) {
180
181
  groupedByEnv.set(f.envRef, existing);
181
182
  }
182
183
  for (const [envRef, keys] of groupedByEnv) {
183
- warn(`[warn] Env "${envRef}" in stash "${stashLabel}" contains potentially dangerous keys:`);
184
+ // envRef and keys come from filenames and KEY names inside a downloaded or
185
+ // cloned bundle, i.e. attacker-controllable. Tar validation rejects NUL but
186
+ // not ESC/CSI, and git checkout allows them in filenames on Linux/macOS —
187
+ // so printing them raw let a crafted bundle rewrite this security prompt
188
+ // with terminal escapes right before an "Install anyway?" confirmation.
189
+ warn(`[warn] Env "${sanitizeString(envRef)}" in stash "${sanitizeString(stashLabel)}" contains potentially dangerous keys:`);
184
190
  for (const key of keys) {
185
- warn(` - ${key}: can hijack process execution via \`akm env run\``);
191
+ warn(` - ${sanitizeString(key)}: can hijack process execution via \`akm env run\``);
186
192
  }
187
193
  }
188
194
  const confirmed = await p.confirm({
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
6
7
  import { getDirname } from "../../runtime.js";
7
8
  const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
8
9
  const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
@@ -24,9 +25,13 @@ function loadChangelog() {
24
25
  }
25
26
  }
26
27
  catch {
27
- // fall through to bundled notes
28
+ // fall through to the embedded copy
28
29
  }
29
- return undefined;
30
+ // In the `bun build --compile` standalone binary, import.meta.url points into
31
+ // the virtual /$bunfs tree and every existsSync above misses, so `akm help
32
+ // migrate <version>` degraded to the generic "no dedicated note" message for
33
+ // EVERY version. Only assets imported `with { type: "text" }` are embedded.
34
+ return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
30
35
  }
31
36
  /**
32
37
  * Load the bundled migration note for a specific version, if one exists.
@@ -87,7 +92,11 @@ function resolveLatestVersion(changelog) {
87
92
  return undefined;
88
93
  }
89
94
  function extractChangelogSection(changelog, version) {
90
- const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|\\Z)`, "m");
95
+ // `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
96
+ // the section at the first capital Z in the body (and failed outright for the
97
+ // last entry). `$` with the `m` flag would stop at the first line end, so the
98
+ // end-of-input alternative has to be an explicit lookahead for the input end.
99
+ const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
91
100
  const match = changelog.match(pattern);
92
101
  if (!match)
93
102
  return undefined;
@@ -10,6 +10,7 @@ import { ConfigError } from "../../core/errors.js";
10
10
  import { warn } from "../../core/warn.js";
11
11
  import { githubHeaders } from "../../integrations/github.js";
12
12
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
13
+ import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
13
14
  const REPO = "itlackey/akm";
14
15
  const DEFAULT_PACKAGE_NAME = "akm-cli";
15
16
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -474,7 +475,14 @@ function readInstalledCliVersion(akmBin) {
474
475
  return match?.[0];
475
476
  }
476
477
  function runRequiredCommand(akmBin, args, label) {
477
- const result = childProcess.spawnSync(akmBin, args, {
478
+ // A bare "akm" is not spawnable on Windows: npm/pnpm/yarn install a global CLI
479
+ // as akm.cmd / akm.ps1 shims, and spawnSync without a shell does not apply
480
+ // PATHEXT — so the package-manager upgrade arm died with ENOENT before it ever
481
+ // ran. resolveAkmInvocation returns a concrete argv (launcher, runtime + main
482
+ // script, or a standalone binary) for however this install actually runs.
483
+ // An explicit path (the standalone arm passes one) is used as given.
484
+ const [command, ...prefixArgs] = path.isAbsolute(akmBin) ? [akmBin] : resolveAkmInvocation().argv;
485
+ const result = childProcess.spawnSync(command ?? akmBin, [...prefixArgs, ...args], {
478
486
  encoding: "utf8",
479
487
  env: process.env,
480
488
  stdio: "pipe",