akm-cli 0.9.0-beta.57 → 0.9.0-beta.59

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 (56) hide show
  1. package/dist/assets/prompts/extract-session.md +5 -1
  2. package/dist/cli/config-migrate.js +7 -1
  3. package/dist/commands/config-cli.js +8 -11
  4. package/dist/commands/health/stash-exposure.js +46 -0
  5. package/dist/commands/health/windows.js +6 -7
  6. package/dist/commands/health.js +31 -10
  7. package/dist/commands/improve/collapse-detector.js +2 -1
  8. package/dist/commands/improve/consolidate/eligibility.js +0 -17
  9. package/dist/commands/improve/consolidate.js +209 -167
  10. package/dist/commands/improve/distill/promote-memory.js +4 -3
  11. package/dist/commands/improve/distill/quality-gate.js +7 -4
  12. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  13. package/dist/commands/improve/distill.js +26 -12
  14. package/dist/commands/improve/extract-prompt.js +16 -2
  15. package/dist/commands/improve/extract.js +16 -8
  16. package/dist/commands/improve/improve-auto-accept.js +22 -1
  17. package/dist/commands/improve/loop-stages.js +7 -2
  18. package/dist/commands/improve/memory/memory-belief.js +14 -15
  19. package/dist/commands/improve/memory/memory-contradiction-detect.js +60 -32
  20. package/dist/commands/improve/memory/memory-improve.js +27 -27
  21. package/dist/commands/improve/preparation.js +6 -5
  22. package/dist/commands/improve/procedural.js +1 -0
  23. package/dist/commands/improve/recombine.js +3 -11
  24. package/dist/commands/improve/reflect-noise.js +1 -1
  25. package/dist/commands/improve/reflect.js +4 -3
  26. package/dist/commands/improve/shared.js +9 -6
  27. package/dist/commands/proposal/drain-policies.js +4 -2
  28. package/dist/commands/read/remember-cli.js +1 -1
  29. package/dist/commands/read/show.js +15 -0
  30. package/dist/commands/remember.js +11 -12
  31. package/dist/commands/sources/init.js +5 -1
  32. package/dist/commands/sources/stash-skeleton.js +34 -0
  33. package/dist/commands/tasks/default-tasks.js +3 -2
  34. package/dist/core/asset/frontmatter.js +22 -0
  35. package/dist/core/common.js +1 -15
  36. package/dist/core/config/config-io.js +10 -1
  37. package/dist/core/config/config-migration.js +2 -15
  38. package/dist/core/config/config-schema.js +15 -3
  39. package/dist/core/config/config.js +22 -14
  40. package/dist/core/paths.js +4 -4
  41. package/dist/core/time.js +53 -0
  42. package/dist/indexer/db/db.js +51 -46
  43. package/dist/indexer/graph/graph-extraction.js +1 -13
  44. package/dist/indexer/indexer.js +77 -65
  45. package/dist/indexer/search/db-search.js +41 -6
  46. package/dist/indexer/search/ranking-contributors.js +14 -8
  47. package/dist/indexer/search/search-source.js +15 -3
  48. package/dist/llm/feature-gate.js +4 -8
  49. package/dist/output/renderers.js +4 -0
  50. package/dist/scripts/migrate-storage.js +83 -59
  51. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +6 -0
  52. package/dist/storage/repositories/registry-cache.js +2 -1
  53. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  54. package/dist/workflows/runtime/runs.js +6 -1
  55. package/package.json +1 -1
  56. package/dist/assets/tasks/core/update-stashes.yml +0 -4
@@ -52,7 +52,7 @@ import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDataba
52
52
  import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, markRecombineHypothesisPromoted, recordRecombineInduction, } from "../../storage/repositories/recombine-repository.js";
53
53
  import { archiveProposal, createProposal, isProposalSkipped, listProposals } from "../proposal/repository.js";
54
54
  import { isValidDescription, isValidWhenToUse, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
55
- import { isConsolidationEligibleMemoryName, isSessionCaptureMemoryName } from "./consolidate.js";
55
+ import { isConsolidationEligibleMemoryName } from "./consolidate.js";
56
56
  import { resolveImproveLlmFn } from "./shared.js";
57
57
  const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
58
58
  const DEFAULT_MIN_CLUSTER_SIZE = 3;
@@ -174,10 +174,6 @@ const JUNK_ENTITY_NORMS = new Set([
174
174
  "metadata",
175
175
  "status",
176
176
  ]);
177
- // #632 — `isSessionCaptureMemoryName` now lives in ./consolidate/eligibility so
178
- // both recombine and consolidate can reuse it without a circular import. It is
179
- // re-exported here for back-compat (existing importers + tests).
180
- export { isSessionCaptureMemoryName };
181
177
  /**
182
178
  * #632 — an entity carries no clustering signal (and must be skipped) when it is
183
179
  * a generic extraction artefact (session / structured-log bookkeeping), a raw
@@ -229,12 +225,7 @@ export function isJunkEntity(entity) {
229
225
  */
230
226
  export function buildRelatednessClusters(entries, opts) {
231
227
  // Only consolidation-eligible memories participate (exclude `.derived`).
232
- // #632 durable memories only: exclude `.derived` (via
233
- // `isConsolidationEligibleMemoryName`) AND session-capture telemetry dumps
234
- // whose embedded metadata pollutes both tag and entity clustering.
235
- const memories = entries.filter((e) => e.entry.type === "memory" &&
236
- isConsolidationEligibleMemoryName(e.entry.name) &&
237
- !isSessionCaptureMemoryName(e.entry.name));
228
+ const memories = entries.filter((e) => e.entry.type === "memory" && isConsolidationEligibleMemoryName(e.entry.name));
238
229
  // signal -> member entries
239
230
  const groups = new Map();
240
231
  const add = (signal, entry) => {
@@ -562,6 +553,7 @@ export async function akmRecombine(opts) {
562
553
  systemPrompt: RECOMBINE_SYSTEM_PROMPT,
563
554
  tag: "[recombine]",
564
555
  signal: opts.signal,
556
+ activeProfile: opts.improveProfile,
565
557
  });
566
558
  if (!llmFn) {
567
559
  warnings.push("recombine: no LLM configured — skipping");
@@ -250,7 +250,7 @@ export function normalizeTrailingWhitespace(text) {
250
250
  export function cosmeticNormalForm(text) {
251
251
  const { fmText, body } = splitFrontmatterText(normalizeTrailingWhitespace(text));
252
252
  const fmCanonical = fmText === null ? "" : canonicalizeYamlText(fmText);
253
- return `${fmCanonical}${normalizeMarkdownBody(body)}`;
253
+ return `${fmCanonical}\u0000${normalizeMarkdownBody(body)}`;
254
254
  }
255
255
  /**
256
256
  * Split a normalized blob into `{ fmText, body }`. Returns `fmText: null`
@@ -30,7 +30,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
30
30
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
31
31
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
32
32
  import { resolveStashDir } from "../../core/common.js";
33
- import { loadConfig } from "../../core/config/config.js";
33
+ import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
34
34
  import { ConfigError, UsageError } from "../../core/errors.js";
35
35
  import { appendEvent, readEvents } from "../../core/events.js";
36
36
  import { lintLessonContent } from "../../core/lesson-lint.js";
@@ -798,7 +798,7 @@ export async function akmReflect(options = {}) {
798
798
  }
799
799
  else {
800
800
  const cfg = options.config ?? loadConfig();
801
- const reflectProcess = cfg.profiles?.improve?.default?.processes?.reflect;
801
+ const reflectProcess = getImproveProcessConfig(cfg, "reflect", options.improveProfile);
802
802
  // Resolve the runner from the improve profile's reflect entry when present.
803
803
  runnerSpec = resolveImproveProcessRunnerFromProfile(reflectProcess, cfg) ?? undefined;
804
804
  if (runnerSpec) {
@@ -1171,7 +1171,8 @@ export async function akmReflect(options = {}) {
1171
1171
  // `profiles.improve.default.processes.reflect.qualityGate.enabled` or
1172
1172
  // `profiles.improve.default.processes.distill.qualityGate.enabled` (the
1173
1173
  // `lesson_quality_gate` flag name is the legacy alias still accepted by
1174
- // `isLlmFeatureEnabled`). Fail-open: any judge error passes through.
1174
+ // `isLlmFeatureEnabled`). Fail-CLOSED (07 P0-2): a judge error / no-LLM /
1175
+ // parse failure rejects the proposal rather than passing it through.
1175
1176
  // G-Eval (arXiv:2303.16634) — quality judgment before admission.
1176
1177
  const runtimeConfig = options.config ??
1177
1178
  (() => {
@@ -1,7 +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 { getDefaultLlmConfig } from "../../core/config/config.js";
4
+ import { getDefaultLlmConfig, getImproveProcessConfig } from "../../core/config/config.js";
5
5
  import { warn } from "../../core/warn.js";
6
6
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
7
7
  import { chatCompletion } from "../../llm/client.js";
@@ -21,13 +21,16 @@ export function refSlug(ref) {
21
21
  }
22
22
  /**
23
23
  * Resolve the production LLM seam for an improve process (`recombine` /
24
- * `procedural`) from the active default improve profile. Returns a function
25
- * that issues one bounded chatCompletion per call, or `undefined` when no LLM
26
- * is configured (the pass then makes no calls). Previously copied verbatim in
27
- * recombine.ts and procedural.ts.
24
+ * `procedural`). Returns a function that issues one bounded chatCompletion per
25
+ * call, or `undefined` when no LLM is configured (the pass then makes no
26
+ * calls). Previously copied verbatim in recombine.ts and procedural.ts.
27
+ *
28
+ * When `opts.activeProfile` is supplied, its per-process runner override wins
29
+ * over the `default` profile so `akm improve --profile <name>` selects the
30
+ * profile's model; absent falls back to `default`.
28
31
  */
29
32
  export function resolveImproveLlmFn(config, opts) {
30
- const processConfig = config.profiles?.improve?.default?.processes?.[opts.processKey];
33
+ const processConfig = getImproveProcessConfig(config, opts.processKey, opts.activeProfile);
31
34
  const runnerSpec = resolveImproveProcessRunnerFromProfile(processConfig, config);
32
35
  const llmConfig = runnerSpec && runnerIsLlm(runnerSpec) ? runnerSpec.connection : getDefaultLlmConfig(config);
33
36
  if (!llmConfig)
@@ -39,8 +39,10 @@ const GeneratorSchema = z.enum(PROPOSAL_SOURCES, {
39
39
  export const PERSONAL_STASH = {
40
40
  name: "personal-stash",
41
41
  accept: [
42
- // Extract proposals carry freshly-pulled real content — accept when present.
43
- { generator: "extract", minContentLines: 1 },
42
+ // Extract proposals carry freshly-pulled real content — accept when present,
43
+ // but cap the diff for parity with reflect(80)/consolidate(200): an
44
+ // arbitrarily large extract should not auto-promote with zero LLM calls.
45
+ { generator: "extract", minContentLines: 1, maxDiffLines: 200 },
44
46
  // Reflect refinements: accept small ones; larger refinements defer to review.
45
47
  { generator: "reflect", maxDiffLines: 80 },
46
48
  // Consolidate within the diff band; mid-band lands in `defer` below.
@@ -60,7 +60,7 @@ export const rememberCommand = defineJsonCommand({
60
60
  },
61
61
  expires: {
62
62
  type: "string",
63
- description: "Expiry duration shorthand (e.g. 30d, 12h, 6m). Resolved to an ISO date.",
63
+ description: "Expiry duration shorthand e.g. 30d, 12h, 5m (minutes), 3M (months). Resolved to an ISO date.",
64
64
  },
65
65
  source: {
66
66
  type: "string",
@@ -363,6 +363,21 @@ export async function showLocal(input) {
363
363
  }
364
364
  const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, source?.registryId);
365
365
  const response = renderer.buildShowResponse(renderCtx);
366
+ // 07 P1-D: provenance-aware toolPolicy CEILING. An agent's self-declared
367
+ // `tools` frontmatter is honoured ONLY for the operator's own PRIMARY stash —
368
+ // the assets they authored. Every other source is content pulled from
369
+ // elsewhere and must not name its own tool grant: registry-installed packs, a
370
+ // configured secondary source, and even a git source the operator marked
371
+ // `--writable` to contribute edits upstream (writability is "can I push", not
372
+ // "do I trust this content to grant itself tools"). Drop the policy so dispatch
373
+ // falls back to the parent/default grant. Keys off primary-stash identity —
374
+ // `allSources[0]` is always the primary (search-source.ts) — not a
375
+ // name-derived registryId or the orthogonal `writable` bit. `source` undefined
376
+ // (unresolved path) also fails closed.
377
+ const isPrimaryStash = source !== undefined && source.path === allSources[0]?.path;
378
+ if (response.toolPolicy !== undefined && !isPrimaryStash) {
379
+ delete response.toolPolicy;
380
+ }
366
381
  const editable = isEditable(assetPath, config);
367
382
  const fullResponse = {
368
383
  ...response,
@@ -12,25 +12,24 @@ import { serializeFrontmatter } from "../core/asset/asset-serialize.js";
12
12
  import { toErrorMessage, tryReadStdinText } from "../core/common.js";
13
13
  import { getDefaultLlmConfig, loadConfig } from "../core/config/config.js";
14
14
  import { UsageError } from "../core/errors.js";
15
+ import { DURATION_UNITS, parseDuration as parseDurationSpec } from "../core/time.js";
15
16
  import { warn } from "../core/warn.js";
16
17
  import { SCOPE_KEYS } from "../indexer/passes/metadata.js";
17
18
  import { parseFlagValue } from "../output/context.js";
18
19
  /**
19
20
  * Parse a shorthand duration string to a number of milliseconds.
20
- * Supports: `30d` (days), `12h` (hours), `6m` (months, approximated as 30d).
21
+ * Supports the CLI-wide canonical grammar: `30d` (days), `12h` (hours),
22
+ * `5m` (minutes), `3M` (months, approximated as 30d).
21
23
  */
22
24
  export function parseDuration(s) {
23
- const match = s.trim().match(/^(\d+)([dhm])$/i);
24
- if (!match)
25
- throw new UsageError(`Invalid --expires format "${s}". Use shorthand like 30d, 12h, or 6m.`, "INVALID_FLAG_VALUE");
26
- const n = Number(match[1]);
27
- const unit = match[2].toLowerCase();
28
- if (unit === "d")
29
- return n * 24 * 60 * 60 * 1000;
30
- if (unit === "h")
31
- return n * 60 * 60 * 1000;
32
- // 'm' = months, approximated as 30 days
33
- return n * 30 * 24 * 60 * 60 * 1000;
25
+ // Canonical CLI unit grammar: `m` = minutes, `M` = months. Not lower-cased,
26
+ // so case distinguishes the two (`5m` = 5 minutes, `5M` = 5 months). See
27
+ // core/time.ts DURATION_UNITS.
28
+ const ms = parseDurationSpec(s.trim(), DURATION_UNITS);
29
+ if (ms === null) {
30
+ throw new UsageError(`Invalid --expires format "${s}". Use shorthand like 30d, 12h, 5m, or 3M.`, "INVALID_FLAG_VALUE");
31
+ }
32
+ return ms;
34
33
  }
35
34
  /**
36
35
  * Build a YAML frontmatter block from memory metadata.
@@ -15,7 +15,7 @@ import { loadUserConfig, saveConfig } from "../../core/config/config.js";
15
15
  import { ConfigError } from "../../core/errors.js";
16
16
  import { assertSafeStashDir, getBinDir, getConfigPath, getDefaultStashDir } from "../../core/paths.js";
17
17
  import { ensureRg } from "../../core/ripgrep/install.js";
18
- import { copyStashSkeleton, scaffoldStashMeta } from "./stash-skeleton.js";
18
+ import { copyStashSkeleton, ensureStashGitignore, scaffoldStashMeta } from "./stash-skeleton.js";
19
19
  /**
20
20
  * Refuse to persist a temporary-directory stashDir to the user's config when
21
21
  * running under a test runner AND `--dir <tempdir>` was passed explicitly.
@@ -92,6 +92,10 @@ async function akmInitReal(options) {
92
92
  }
93
93
  // Ensure the default stash is a local git repo (no remote required)
94
94
  ensureGitRepo(stashDir);
95
+ // 08-F1: scaffold a default `.gitignore` that keeps env/ + secrets/ out of git
96
+ // so a `git push` can never leak them. Idempotent + non-clobbering; the user
97
+ // opts into versioning by un-ignoring a path.
98
+ ensureStashGitignore(stashDir);
95
99
  // Run seeding UNCONDITIONALLY (not just when the stash was newly created) so
96
100
  // re-running `akm init` on an existing stash backfills any missing skeleton
97
101
  // files — the README, the per-type SOFT convention templates under
@@ -71,6 +71,40 @@ export function scaffoldStashMeta(stashDir) {
71
71
  // Non-fatal — stash is usable without the .meta orientation doc
72
72
  }
73
73
  }
74
+ /** Marks the akm-authored block in a stash `.gitignore` (idempotency anchor). */
75
+ const STASH_GITIGNORE_MARKER = "# akm: keep secret material out of git by default";
76
+ const STASH_GITIGNORE_BLOCK = [
77
+ STASH_GITIGNORE_MARKER,
78
+ "# env/ and secrets/ assets hold tokens and keys. They are ignored by default",
79
+ "# so `git push` can never leak them. To version a specific one (e.g. for a",
80
+ "# private-remote backup), un-ignore its path below once you accept the risk.",
81
+ "env/",
82
+ "secrets/",
83
+ "",
84
+ ].join("\n");
85
+ /**
86
+ * Ensure the stash `.gitignore` keeps `env/` and `secrets/` out of git by
87
+ * default (08-F1: the v0.8.0 `vaults/` → `env/`+`secrets/` migration never
88
+ * carried the ignore rules forward, and init scaffolded none).
89
+ *
90
+ * Idempotent + non-clobbering: creates the file when absent, appends the akm
91
+ * block when the file exists but lacks it (preserving the user's own rules),
92
+ * and no-ops once the marker is present. The user opts INTO versioning by
93
+ * un-ignoring a path.
94
+ */
95
+ export function ensureStashGitignore(stashDir) {
96
+ try {
97
+ const gitignorePath = path.join(stashDir, ".gitignore");
98
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
99
+ if (existing.includes(STASH_GITIGNORE_MARKER))
100
+ return;
101
+ const gap = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
102
+ fs.writeFileSync(gitignorePath, `${existing}${gap}${STASH_GITIGNORE_BLOCK}`);
103
+ }
104
+ catch {
105
+ // Non-fatal — the stash is usable without the ignore scaffold.
106
+ }
107
+ }
74
108
  const STASH_META_INDEX_TEMPLATE = `---
75
109
  # Optional, human-authored orientation for this stash. Not indexed; surfaced
76
110
  # on demand via \`akm show meta\` (this file) or \`akm show <stash>//meta\`.
@@ -20,8 +20,9 @@ import fs from "node:fs";
20
20
  import os from "node:os";
21
21
  import { akmTasksAdd, akmTasksList } from "./tasks.js";
22
22
  /**
23
- * The canonical default improve task set. `update-stashes` is deliberately NOT
24
- * listed here it ships as an embedded core template and is left unchanged.
23
+ * The canonical default improve task set. The `update-stashes` embedded core
24
+ * template (nightly `akm update --all`) was retired in meta-review 06-M2:
25
+ * third-party stash pulls are on-demand only now, not a scheduled cron.
25
26
  */
26
27
  export const DEFAULT_IMPROVE_TASKS = [
27
28
  {
@@ -8,7 +8,9 @@
8
8
  * (block scalars, multi-line strings, nested objects, flow sequences, escape
9
9
  * sequences) is handled correctly without a brittle hand-rolled state machine.
10
10
  */
11
+ import fs from "node:fs";
11
12
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
13
+ import { assembleAsset } from "./asset-serialize.js";
12
14
  /**
13
15
  * Parse YAML frontmatter from a Markdown (or similar) string.
14
16
  *
@@ -107,6 +109,26 @@ function parseFrontmatterLenient(frontmatter) {
107
109
  }
108
110
  return data;
109
111
  }
112
+ /**
113
+ * Read a file, parse its frontmatter, let `mutator` compute the next
114
+ * frontmatter object, and write the reassembled asset back to disk.
115
+ *
116
+ * This is the shared read→parse→mutate→write primitive. The `mutator` receives
117
+ * the parsed result and returns either the next frontmatter object (to write)
118
+ * or `null` to skip the write entirely (e.g. for idempotent no-ops). The body
119
+ * content is preserved from the parse.
120
+ *
121
+ * @returns `true` if a write occurred, `false` if the mutator returned `null`.
122
+ */
123
+ export function mutateFrontmatter(filePath, mutator) {
124
+ const raw = fs.readFileSync(filePath, "utf8");
125
+ const parsed = parseFrontmatter(raw);
126
+ const nextFrontmatter = mutator(parsed);
127
+ if (nextFrontmatter === null)
128
+ return false;
129
+ fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
130
+ return true;
131
+ }
110
132
  export function parseFrontmatterBlock(raw) {
111
133
  // Handle both LF and CRLF line endings throughout.
112
134
  // The closing --- may be preceded by \r\n; capture and strip trailing \r
@@ -84,14 +84,7 @@ export function writeFileAtomic(target, content, mode) {
84
84
  const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
85
85
  const fd = fs.openSync(tmp, "w", mode ?? 0o600);
86
86
  try {
87
- // fs.writeSync has two non-overlapping overloads (Buffer vs string); branch
88
- // so each call resolves to a single overload. Both write byte-exact.
89
- if (typeof content === "string") {
90
- fs.writeSync(fd, content);
91
- }
92
- else {
93
- fs.writeSync(fd, content);
94
- }
87
+ fs.writeSync(fd, typeof content === "string" ? Buffer.from(content) : content);
95
88
  try {
96
89
  fs.fdatasyncSync(fd);
97
90
  }
@@ -462,13 +455,6 @@ export function asNonEmptyString(value) {
462
455
  return trimmed.length > 0 ? trimmed : undefined;
463
456
  }
464
457
  // ── Generic data utilities ───────────────────────────────────────────────────
465
- /**
466
- * Return the trimmed string if non-empty, otherwise `undefined`.
467
- * Equivalent to `firstString` previously defined in `memory-improve.ts`.
468
- */
469
- export function firstString(value) {
470
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
471
- }
472
458
  /**
473
459
  * Coerce an unknown value to a filtered, trimmed string array.
474
460
  * Non-strings and empty/whitespace-only entries are dropped.
@@ -84,12 +84,21 @@ export function backupExistingConfig(configPath) {
84
84
  if (!fs.existsSync(configPath))
85
85
  return undefined;
86
86
  const backupDir = path.join(getCacheDir(), "config-backups");
87
- fs.mkdirSync(backupDir, { recursive: true });
87
+ // 08-F4: lock the backup dir owner-only up front (0700) — matching the
88
+ // env.ts/secret.ts convention — so no other local user can traverse in during
89
+ // the copy→chmod window. chmod again to tighten a dir from an older version.
90
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
91
+ fs.chmodSync(backupDir, 0o700);
88
92
  const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
89
93
  const timestamped = path.join(backupDir, `config-${timestamp}.json`);
90
94
  const latest = path.join(backupDir, "config.latest.json");
91
95
  fs.copyFileSync(configPath, timestamped);
92
96
  fs.copyFileSync(configPath, latest);
97
+ // 08-F4: a config backup carries the same sensitive fields as the live config
98
+ // (endpoints, tokens). `copyFileSync` inherits the source's (often 0644) mode,
99
+ // so tighten the backups to owner-only — mirrors the env-cli 0600 write floor.
100
+ fs.chmodSync(timestamped, 0o600);
101
+ fs.chmodSync(latest, 0o600);
93
102
  pruneOldBackups(backupDir);
94
103
  return { timestamped, latest };
95
104
  }
@@ -262,12 +262,6 @@ export function migrateConfigShape(raw, opts) {
262
262
  if (typeof llmFeatures.metadata_enhance === "boolean")
263
263
  me.enabled = llmFeatures.metadata_enhance;
264
264
  }
265
- if ("curate_rerank" in llmFeatures) {
266
- const search = getObj(result, "search");
267
- const cr = getObj(search, "curateRerank");
268
- if (typeof llmFeatures.curate_rerank === "boolean")
269
- cr.enabled = llmFeatures.curate_rerank;
270
- }
271
265
  if ("lesson_quality_gate" in llmFeatures) {
272
266
  const distill = getImproveProcess(result, "distill");
273
267
  const qg = getObj(distill, "qualityGate");
@@ -417,17 +411,10 @@ export function migrateConfigShape(raw, opts) {
417
411
  }
418
412
  if (isObj(features.search)) {
419
413
  const fsearch = features.search;
420
- if ("curate_rerank" in fsearch) {
421
- const search = getObj(result, "search");
422
- const cr = getObj(search, "curateRerank");
423
- const val = fsearch.curate_rerank;
424
- if (typeof val === "boolean")
425
- cr.enabled = val;
426
- else if (isObj(val) && typeof val.enabled === "boolean")
427
- cr.enabled = val.enabled;
428
- }
429
414
  // Catch-all: unknown features.search.<key> entries land at
430
415
  // search.<keyAsCamelCase> (preserving { enabled, options } when present).
416
+ // `curate_rerank` is a removed dead feature — listed here so the catch-all
417
+ // skips it and it is simply dropped (not resurrected under search.curateRerank).
431
418
  const knownSearchKeys = new Set(["curate_rerank"]);
432
419
  for (const [legacyKey, legacyVal] of Object.entries(fsearch)) {
433
420
  if (knownSearchKeys.has(legacyKey))
@@ -49,8 +49,21 @@ const nonEmptyString = z
49
49
  const httpUrl = z.string().refine((v) => v.startsWith("http://") || v.startsWith("https://"), {
50
50
  message: "endpoint must start with http:// or https://",
51
51
  });
52
- // ── Feedback failure modes ──────────────────────────────────────────────────
53
- export const FEEDBACK_FAILURE_MODES = ["incorrect", "outdated", "dangerous", "incomplete", "redundant"];
52
+ // ── Feedback failure modes (F-3 / #384) ─────────────────────────────────────
53
+ /**
54
+ * Curated taxonomy of failure modes for negative feedback.
55
+ *
56
+ * Structured failure modes enable aggregation across feedback events so the
57
+ * distill pipeline can detect that "5 assets failed for the same reason" and
58
+ * act on it — free-text strings about the same issue are not aggregatable.
59
+ */
60
+ export const FEEDBACK_FAILURE_MODES = [
61
+ "incorrect", // Factually wrong or logically flawed content
62
+ "outdated", // Correct at some point but now stale
63
+ "dangerous", // Could cause harm if followed (security, safety)
64
+ "incomplete", // Missing key steps, context, or caveats
65
+ "redundant", // Duplicates another asset without adding value
66
+ ];
54
67
  // ── Connection configs (LLM / embedding) ────────────────────────────────────
55
68
  const LlmCapabilitiesSchema = z
56
69
  .object({
@@ -552,7 +565,6 @@ export const SearchConfigSchema = z
552
565
  .object({
553
566
  minScore: nonNegativeNumber.optional(),
554
567
  defaultExcludeTypes: z.array(nonEmptyString).optional(),
555
- curateRerank: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
556
568
  graphBoost: SearchGraphBoostSchema.optional(),
557
569
  })
558
570
  .passthrough();
@@ -13,20 +13,9 @@ import { warn } from "../warn.js";
13
13
  // Canonical harness-id source of truth (#565) — runtime value re-export.
14
14
  export { VALID_HARNESS_IDS } from "./config-types.js";
15
15
  // ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
16
- /**
17
- * Curated taxonomy of failure modes for negative feedback.
18
- *
19
- * Structured failure modes enable aggregation across feedback events so the
20
- * distill pipeline can detect that "5 assets failed for the same reason" and
21
- * act on it — free-text strings about the same issue are not aggregatable.
22
- */
23
- export const FEEDBACK_FAILURE_MODES = [
24
- "incorrect", // Factually wrong or logically flawed content
25
- "outdated", // Correct at some point but now stale
26
- "dangerous", // Could cause harm if followed (security, safety)
27
- "incomplete", // Missing key steps, context, or caveats
28
- "redundant", // Duplicates another asset without adding value
29
- ];
16
+ // Canonical taxonomy lives in the schema/validator layer; re-exported here so
17
+ // existing `../core/config/config` import sites keep working.
18
+ export { FEEDBACK_FAILURE_MODES } from "./config-schema.js";
30
19
  /**
31
20
  * Default value for {@link IndexPassConfig.graphExtractionBatchSize}. Chosen
32
21
  * empirically: 4 amortises the per-call HTTP overhead 4× while keeping the
@@ -205,6 +194,25 @@ export function getDefaultLlmConfig(config) {
205
194
  return undefined;
206
195
  return config.profiles?.llm?.[defaultName];
207
196
  }
197
+ /**
198
+ * Resolve the per-process config section for an improve process,
199
+ * centralizing the deeply-nested lookup
200
+ * `profile?.processes?.<name>` that was previously copy-pasted across the
201
+ * improve command family (20+ call sites).
202
+ *
203
+ * When an `activeProfile` is supplied (the profile resolved for the current
204
+ * `akm improve --profile <name>` run), its per-process override wins; otherwise
205
+ * — and as a fallback when the active profile does not define the section — the
206
+ * lookup falls back to the `"default"` improve profile from the on-disk config.
207
+ * Callers that have not yet threaded the active profile pass only `config` and
208
+ * get the historical default-profile behavior unchanged.
209
+ */
210
+ export function getImproveProcessConfig(config, processName, activeProfile) {
211
+ const fromActiveProfile = activeProfile?.processes?.[processName];
212
+ if (fromActiveProfile !== undefined)
213
+ return fromActiveProfile;
214
+ return config.profiles?.improve?.default?.processes?.[processName];
215
+ }
208
216
  /**
209
217
  * Run `migrateConfigShape` on the raw text and — unless `AKM_NO_AUTO_MIGRATE=1`
210
218
  * is set — persist the migrated result. Returns the (possibly migrated) text
@@ -111,8 +111,8 @@ export function getConfigDir(env = process.env, platform = process.platform) {
111
111
  }
112
112
  return path.join(home, ".config", "akm");
113
113
  }
114
- export function getConfigPath() {
115
- return path.join(getConfigDir(), "config.json");
114
+ export function getConfigPath(env = process.env) {
115
+ return path.join(getConfigDir(env), "config.json");
116
116
  }
117
117
  // ── Cache directory ──────────────────────────────────────────────────────────
118
118
  export function getCacheDir(env = process.env) {
@@ -212,8 +212,8 @@ export function getDataDir(env = process.env, platform = process.platform) {
212
212
  return path.join("/tmp", "akm-data");
213
213
  return path.join(home, ".local", "share", "akm");
214
214
  }
215
- export function getDbPath() {
216
- return path.join(getDataDir(), "index.db");
215
+ export function getDbPath(env = process.env) {
216
+ return path.join(getDataDir(env), "index.db");
217
217
  }
218
218
  export function getIndexWriterLockPath() {
219
219
  return path.join(getDataDir(), "index.db.write.lock");
package/dist/core/time.js CHANGED
@@ -9,6 +9,59 @@
9
9
  * consistently without private re-implementations drifting apart.
10
10
  */
11
11
  import { UsageError } from "./errors.js";
12
+ // ── Duration-shorthand parsing ───────────────────────────────────────────────
13
+ const MINUTE_MS = 60 * 1000;
14
+ const HOUR_MS = 60 * 60 * 1000;
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ /** A month is approximated as 30 days — this shorthand is not calendar-exact. */
17
+ const MONTH_MS = 30 * DAY_MS;
18
+ /**
19
+ * Canonical duration-shorthand unit map shared by every `--since` / `--expires`
20
+ * / `--window-compare` consumer.
21
+ *
22
+ * The grammar is intentionally uniform across the whole CLI:
23
+ * - `m` = MINUTES, `M` = MONTHS (30-day approximation)
24
+ * - `h`/`H` = hours, `d`/`D` = days
25
+ *
26
+ * Matching is CASE-SENSITIVE (see {@link parseDuration}), which is what lets
27
+ * `m` and `M` mean different things. Historically `akm health --since` and
28
+ * `remember --expires` read a case-insensitive `m` as MONTHS while
29
+ * `consolidate` / `--window-compare` read it as MINUTES; that split is now
30
+ * resolved in favour of the conventional `m`=minutes, with `M` reserved for
31
+ * months. Upper-case `H`/`D` aliases are retained so specs that previously
32
+ * relied on the old case-insensitive parsers (e.g. `"7D"`) keep working.
33
+ */
34
+ export const DURATION_UNITS = {
35
+ m: MINUTE_MS,
36
+ M: MONTH_MS,
37
+ h: HOUR_MS,
38
+ H: HOUR_MS,
39
+ d: DAY_MS,
40
+ D: DAY_MS,
41
+ };
42
+ /**
43
+ * Parse a compact duration shorthand (e.g. `"30d"`, `"12h"`, `"5m"`, `"3M"`)
44
+ * into a number of milliseconds using an explicit `units` map (default
45
+ * {@link DURATION_UNITS}), or return `null` when the input does not match
46
+ * `<digits><letter>` or the unit is not in the map.
47
+ *
48
+ * Matching is CASE-SENSITIVE against the map keys, so `m` (minutes) and `M`
49
+ * (months) are distinct — do NOT lower-case the spec before calling, or the
50
+ * two collapse. Amount is parsed with base-10 `parseInt`; `null` is returned
51
+ * rather than throwing so each caller keeps its own error/fallback policy.
52
+ */
53
+ export function parseDuration(spec, units = DURATION_UNITS) {
54
+ const match = spec.trim().match(/^(\d+)([a-zA-Z])$/);
55
+ if (!match)
56
+ return null;
57
+ const amount = Number.parseInt(match[1] ?? "", 10);
58
+ if (!Number.isFinite(amount))
59
+ return null;
60
+ const multiplier = units[match[2] ?? ""];
61
+ if (multiplier === undefined)
62
+ return null;
63
+ return amount * multiplier;
64
+ }
12
65
  // ── Since-flag parsing ───────────────────────────────────────────────────────
13
66
  /**
14
67
  * Parse a user-supplied `--since` value and return an ISO-8601 timestamp