akm-cli 0.9.0-beta.50 → 0.9.0-beta.52

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 (51) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/wiki/ingest-workflow-template.md +34 -12
  6. package/dist/assets/wiki/schema-template.md +4 -4
  7. package/dist/cli/parse-args.js +46 -1
  8. package/dist/cli.js +12 -6
  9. package/dist/commands/config-cli.js +18 -2
  10. package/dist/commands/env/child-env.js +47 -0
  11. package/dist/commands/env/env-cli.js +17 -2
  12. package/dist/commands/env/secret-cli.js +24 -2
  13. package/dist/commands/health/checks.js +1 -1
  14. package/dist/commands/improve/improve-auto-accept.js +30 -2
  15. package/dist/commands/improve/improve-cli.js +1 -1
  16. package/dist/commands/improve/improve-result-file.js +9 -2
  17. package/dist/commands/improve/preparation.js +10 -2
  18. package/dist/commands/improve/recombine.js +52 -15
  19. package/dist/commands/lint/env-key-rules.js +4 -0
  20. package/dist/commands/read/knowledge.js +5 -2
  21. package/dist/commands/read/search-cli.js +2 -4
  22. package/dist/commands/read/search.js +9 -6
  23. package/dist/commands/read/show.js +19 -5
  24. package/dist/commands/sources/init.js +13 -8
  25. package/dist/commands/sources/installed-stashes.js +6 -2
  26. package/dist/commands/sources/schema-repair.js +33 -47
  27. package/dist/commands/sources/source-add.js +7 -3
  28. package/dist/commands/tasks/tasks.js +38 -10
  29. package/dist/core/asset/asset-registry.js +1 -1
  30. package/dist/core/asset/asset-spec.js +4 -2
  31. package/dist/core/config/config-migration.js +12 -11
  32. package/dist/indexer/passes/memory-inference.js +3 -2
  33. package/dist/indexer/search/db-search.js +6 -4
  34. package/dist/indexer/search/search-source.js +15 -2
  35. package/dist/integrations/agent/prompts.js +1 -1
  36. package/dist/llm/memory-infer-impl.js +138 -0
  37. package/dist/llm/memory-infer.js +1 -135
  38. package/dist/migrate-storage-node.mjs +8 -0
  39. package/dist/output/renderers.js +1 -1
  40. package/dist/scripts/migrate-storage.js +463 -347
  41. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +99 -99
  42. package/dist/sources/include.js +6 -2
  43. package/dist/sources/providers/git-install.js +10 -6
  44. package/dist/sources/providers/provider-utils.js +13 -7
  45. package/dist/sources/providers/website.js +8 -3
  46. package/dist/sources/website-ingest.js +136 -20
  47. package/dist/text-import-hook.mjs +0 -0
  48. package/dist/wiki/wiki.js +15 -11
  49. package/docs/data-and-telemetry.md +2 -2
  50. package/docs/migration/release-notes/0.9.0.md +39 -0
  51. package/package.json +8 -8
@@ -572,7 +572,7 @@ async function runSessionExtractPass(args) {
572
572
  * LLM schema repair, and returns the still-failing ref set + the repair records.
573
573
  */
574
574
  async function runValidationAndRepairPass(args) {
575
- const { postCleanupRefs, options, startMs, budgetMs } = args;
575
+ const { postCleanupRefs, options, startMs, budgetMs, primaryStashDir } = args;
576
576
  const validationFailures = [];
577
577
  for (const candidate of postCleanupRefs) {
578
578
  try {
@@ -617,7 +617,14 @@ async function runValidationAndRepairPass(args) {
617
617
  startMs,
618
618
  budgetMs,
619
619
  llmConfig: llmCfg,
620
- stashDir: options.stashDir,
620
+ // #591/#379 regression: options.stashDir is the raw, unresolved CLI
621
+ // flag (only set when --stash-dir is passed explicitly — never true
622
+ // for the scheduled tasks). primaryStashDir is the already-resolved
623
+ // source path and is what runSchemaRepairPass's `stashDir` param
624
+ // documents itself as needing ("proposal-queue writes"). Passing
625
+ // options.stashDir here made every schema-repair attempt throw
626
+ // `runSchemaRepairPass requires stashDir` on every cron invocation.
627
+ stashDir: primaryStashDir,
621
628
  findFilePath: findAssetFilePath,
622
629
  isLessonCandidateFn: isLessonCandidate,
623
630
  });
@@ -737,6 +744,7 @@ export async function runImprovePreparationStage(args) {
737
744
  options,
738
745
  startMs,
739
746
  budgetMs,
747
+ primaryStashDir,
740
748
  });
741
749
  // Phase 0.5 — structural hygiene pass
742
750
  let lintSummary;
@@ -51,7 +51,7 @@ import { warn } from "../../core/warn.js";
51
51
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
52
52
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
53
53
  import { chatCompletion } from "../../llm/client.js";
54
- import { validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
54
+ import { isValidDescription, isValidWhenToUse, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
55
55
  import { archiveProposal, createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
56
56
  import { isConsolidationEligibleMemoryName, isSessionCaptureMemoryName } from "./consolidate.js";
57
57
  const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
@@ -438,6 +438,20 @@ export function deriveRecombineLessonRef(cluster) {
438
438
  const hash = createHash("sha256").update(memberKey, "utf8").digest("hex").slice(0, 8);
439
439
  return `lesson:recombined/${slug || "cluster"}-${hash}`;
440
440
  }
441
+ function validatePromotedLessonFrontmatter(ref, frontmatter) {
442
+ const descCheck = isValidDescription(frontmatter.description, ref);
443
+ if (!descCheck.ok)
444
+ return { ok: false, reason: descCheck.reason };
445
+ const whenToUseCheck = isValidWhenToUse(frontmatter.when_to_use, ref);
446
+ if (!whenToUseCheck.ok)
447
+ return { ok: false, reason: whenToUseCheck.reason };
448
+ if (typeof frontmatter.description === "string" &&
449
+ typeof frontmatter.when_to_use === "string" &&
450
+ frontmatter.description.trim().toLowerCase() === frontmatter.when_to_use.trim().toLowerCase()) {
451
+ return { ok: false, reason: "description and when_to_use are identical" };
452
+ }
453
+ return { ok: true };
454
+ }
441
455
  /**
442
456
  * The membership fingerprint of a cluster: its member entryKeys sorted and
443
457
  * joined. Single source of truth shared by {@link deriveRecombineLessonRef}'s
@@ -652,9 +666,18 @@ export async function akmRecombine(opts) {
652
666
  : undefined;
653
667
  const lessonRef = matchedRow?.hypothesis_ref ?? derivedRef;
654
668
  const sourceRefs = cluster.members.map((m) => `memory:${m.entry.name}`);
669
+ const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
670
+ const alreadyPromoted = priorRow?.promoted_at != null;
671
+ const nextCount = stateDb == null
672
+ ? 0
673
+ : priorRow == null
674
+ ? 1
675
+ : priorRow.last_run === sourceRun
676
+ ? priorRow.consecutive_count
677
+ : priorRow.consecutive_count + 1;
655
678
  // Quality gate (always-run): the frontmatter description must be present
656
- // and non-truncated. This runs BEFORE createProposal on BOTH the
657
- // hypothesis and the promotion paths never bypassed.
679
+ // and non-truncated. Promotion adds the full lesson frontmatter check so
680
+ // `when_to_use` never bypasses validation on the promote=true path.
658
681
  const fmCheck = validateProposalFrontmatter({ description: generalization.description });
659
682
  if (!fmCheck.ok) {
660
683
  appendEvent({
@@ -670,29 +693,43 @@ export async function akmRecombine(opts) {
670
693
  }, opts.ctx);
671
694
  continue;
672
695
  }
696
+ const promote = stateDb != null && !alreadyPromoted && nextCount >= confirmThreshold;
697
+ if (promote) {
698
+ const lessonFmCheck = validatePromotedLessonFrontmatter(lessonRef, {
699
+ description: generalization.description,
700
+ when_to_use: generalization.when_to_use,
701
+ });
702
+ if (!lessonFmCheck.ok) {
703
+ appendEvent({
704
+ eventType: "recombine_invoked",
705
+ ref: lessonRef,
706
+ metadata: {
707
+ signal: cluster.signature,
708
+ memberCount: cluster.members.length,
709
+ outcome: "quality_rejected",
710
+ reason: lessonFmCheck.reason,
711
+ sourceRun,
712
+ },
713
+ }, opts.ctx);
714
+ continue;
715
+ }
716
+ }
673
717
  // A defensible generalization was produced this run — record it so it is
674
718
  // NOT decayed by the unseen sweep below.
675
719
  seenThisRun.add(lessonRef);
676
- // #625/#633 — record the re-induction and read the prior promotion state.
677
- // `lessonRef` is the matched row's ref (overlap match) or the freshly
678
- // derived member-set ref (first/non-overlapping induction). The induction
679
- // refreshes the row's `member_key` to the current membership so the
680
- // overlap window slides with the drifting cluster.
681
- const nowIso = new Date().toISOString();
682
- const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
683
- const alreadyPromoted = priorRow?.promoted_at != null;
720
+ // #625/#633 — record the re-induction only AFTER the quality gate passed.
721
+ // Quality-rejected outputs must not advance the confirmation streak.
684
722
  const count = stateDb
685
723
  ? recordRecombineInduction(stateDb, {
686
724
  hypothesisRef: lessonRef,
687
725
  signature: cluster.signature,
688
726
  memberKey,
689
- seenAt: nowIso,
727
+ seenAt: new Date().toISOString(),
690
728
  run: sourceRun,
691
729
  })
692
730
  : 0;
693
731
  // Promote to a `type: lesson` proposal when the confirmation streak
694
732
  // reaches the threshold AND the hypothesis has not already been promoted.
695
- const promote = stateDb != null && !alreadyPromoted && count >= confirmThreshold;
696
733
  const proposalType = promote ? "lesson" : "hypothesis";
697
734
  const frontmatter = {
698
735
  type: proposalType,
@@ -716,7 +753,7 @@ export async function akmRecombine(opts) {
716
753
  ref: lessonRef,
717
754
  source: "recombine",
718
755
  sourceRun,
719
- payload: { content, frontmatter: { description: generalization.description } },
756
+ payload: { content, frontmatter },
720
757
  eligibilitySource,
721
758
  // The promotion is a distinct asset (lesson) for the same ref; force
722
759
  // past the duplicate-pending guard (the stale hypothesis was just
@@ -738,7 +775,7 @@ export async function akmRecombine(opts) {
738
775
  continue;
739
776
  }
740
777
  if (promote && stateDb) {
741
- markRecombineHypothesisPromoted(stateDb, lessonRef, nowIso);
778
+ markRecombineHypothesisPromoted(stateDb, lessonRef, new Date().toISOString());
742
779
  lessonsPromoted += 1;
743
780
  appendEvent({
744
781
  eventType: "recombine_invoked",
@@ -96,6 +96,10 @@ export const DANGEROUS_VAULT_KEY_PATTERNS = [
96
96
  pattern: /^BASH_FUNC_/,
97
97
  reason: "Shellshock-class bash function injection (CVE-2014-6271)",
98
98
  },
99
+ {
100
+ pattern: /^GIT_CONFIG_/,
101
+ reason: "Git config injection through environment override variables",
102
+ },
99
103
  ];
100
104
  /**
101
105
  * Returns `true` if the given key name is dangerous — either by literal match
@@ -16,7 +16,7 @@ import { isHttpUrl, isWithin, tryReadStdinText } from "../../core/common.js";
16
16
  import { loadConfig } from "../../core/config/config.js";
17
17
  import { UsageError } from "../../core/errors.js";
18
18
  import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
19
- import { fetchWebsiteMarkdownSnapshot } from "../../sources/website-ingest.js";
19
+ import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
20
20
  const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
21
21
  // ── Asset-name normalisation ─────────────────────────────────────────────────
22
22
  /**
@@ -104,7 +104,10 @@ export function readKnowledgeContent(source) {
104
104
  export async function readKnowledgeInput(source, options) {
105
105
  if (!isHttpUrl(source))
106
106
  return readKnowledgeContent(source);
107
- const snapshot = await fetchWebsiteMarkdownSnapshot(source, { stashDir: options?.stashDir });
107
+ const snapshot = await fetchWebsiteMarkdownSnapshot(source, {
108
+ stashDir: options?.stashDir,
109
+ allowPrivateHosts: options?.allowPrivateHosts ?? shouldAllowPrivateWebsiteUrlForTests(source),
110
+ });
108
111
  return { content: snapshot.content, preferredName: snapshot.preferredName };
109
112
  }
110
113
  // ── Asset writing ────────────────────────────────────────────────────────────
@@ -85,10 +85,6 @@ export const searchCommand = defineJsonCommand({
85
85
  const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
86
86
  const noProjectContext = getHyphenatedBoolean(args, "no-project-context");
87
87
  const includeSessions = getHyphenatedBoolean(args, "include-sessions");
88
- // --no-project-context sets env so searchDatabase picks it up without
89
- // threading the flag through the entire call stack.
90
- if (noProjectContext)
91
- process.env.AKM_DISABLE_PROJECT_CONTEXT = "1";
92
88
  const result = await akmSearch({
93
89
  query,
94
90
  type,
@@ -98,6 +94,8 @@ export const searchCommand = defineJsonCommand({
98
94
  includeProposed,
99
95
  belief,
100
96
  includeSessions,
97
+ disableProjectContext: noProjectContext,
98
+ disableScopedUtility: noProjectContext,
101
99
  eventSource: resolveEventSource(),
102
100
  });
103
101
  output("search", result);
@@ -109,6 +109,8 @@ export async function akmSearch(input) {
109
109
  // would leak hits from sources the caller did not request.
110
110
  restrictToSources: namedSourceName !== undefined,
111
111
  includeExcludedTypes: input.includeSessions === true,
112
+ disableProjectContext: input.disableProjectContext === true,
113
+ disableScopedUtility: input.disableScopedUtility === true,
112
114
  });
113
115
  const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
114
116
  if (source === "stash") {
@@ -123,8 +125,9 @@ export async function akmSearch(input) {
123
125
  warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
124
126
  timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
125
127
  };
126
- if (!input.skipLogging)
127
- logSearchEvent(query, response, localResult?.mode ?? "keyword", input.eventSource);
128
+ if (!input.skipLogging) {
129
+ logSearchEvent(query, response, localResult?.mode ?? "keyword", input.eventSource, input.disableScopedUtility === true);
130
+ }
128
131
  return response;
129
132
  }
130
133
  const registryHits = (registryResult?.hits ?? []).map((hit) => {
@@ -159,7 +162,7 @@ export async function akmSearch(input) {
159
162
  timing: { totalMs: Date.now() - t0 },
160
163
  };
161
164
  if (!input.skipLogging)
162
- logSearchEvent(query, response, undefined, input.eventSource);
165
+ logSearchEvent(query, response, undefined, input.eventSource, input.disableScopedUtility === true);
163
166
  return response;
164
167
  }
165
168
  // source === "both"
@@ -177,7 +180,7 @@ export async function akmSearch(input) {
177
180
  timing: { totalMs: Date.now() - t0 },
178
181
  };
179
182
  if (!input.skipLogging)
180
- logSearchEvent(query, response, undefined, input.eventSource);
183
+ logSearchEvent(query, response, undefined, input.eventSource, input.disableScopedUtility === true);
181
184
  return response;
182
185
  }
183
186
  /**
@@ -212,7 +215,7 @@ function resolveEntryIds(db, hits) {
212
215
  * Per-entry events are recorded only for stash hits because registry hits
213
216
  * have no local entry_id to reference.
214
217
  */
215
- function logSearchEvent(query, response, mode = "keyword", eventSource = "user") {
218
+ function logSearchEvent(query, response, mode = "keyword", eventSource = "user", disableScopedUtility = false) {
216
219
  // Emit a structured event to events.jsonl so workflow-trace consumers
217
220
  // detect akm search invocations without relying on stdout scraping.
218
221
  const stashHits = response.hits.filter((h) => h.type !== "registry");
@@ -242,7 +245,7 @@ function logSearchEvent(query, response, mode = "keyword", eventSource = "user")
242
245
  let scopeKey;
243
246
  try {
244
247
  const stashPath = response.stashDir;
245
- const disabled = process.env.AKM_DISABLE_SCOPED_UTILITY === "1" || (stashPath && isTransientStashPath(stashPath));
248
+ const disabled = disableScopedUtility || (stashPath && isTransientStashPath(stashPath));
246
249
  scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
247
250
  }
248
251
  catch {
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import { findCittyTopLevelCommandIndex } from "../../cli/parse-args.js";
21
22
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
22
23
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
23
24
  import { META_DIR, parseMetaRef, resolveMetaFilePath } from "../../core/asset/stash-meta.js";
@@ -523,6 +524,14 @@ function buildSummaryResponse(full, assetPath) {
523
524
  }
524
525
  // ── argv normalisation ───────────────────────────────────────────────────────
525
526
  const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);
527
+ const SHOW_ARGV_TOP_LEVEL_ARGS = {
528
+ format: { type: "string" },
529
+ output: { type: "string" },
530
+ detail: { type: "string" },
531
+ shape: { type: "string" },
532
+ quiet: { type: "boolean", alias: "q" },
533
+ verbose: { type: "boolean" },
534
+ };
526
535
  /**
527
536
  * Normalize argv so positional view-mode arguments after the asset ref
528
537
  * are rewritten into internal flags that citty can parse.
@@ -536,15 +545,20 @@ const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines
536
545
  * Returns a new array; the input is never modified.
537
546
  */
538
547
  export function normalizeShowArgv(argv) {
539
- // argv[0]=bun argv[1]=script argv[2]=subcommand argv[3]=ref argv[4..]=rest
540
- if (argv[2] !== "show")
548
+ const rawArgs = argv.slice(2);
549
+ const commandIndex = findCittyTopLevelCommandIndex(rawArgs, SHOW_ARGV_TOP_LEVEL_ARGS);
550
+ if (commandIndex < 0 || rawArgs[commandIndex] !== "show")
541
551
  return argv;
542
- if (argv.includes("--view") || argv.includes("--heading") || argv.includes("--start") || argv.includes("--end")) {
552
+ const commandArgs = rawArgs.slice(commandIndex + 1);
553
+ if (commandArgs.includes("--view") ||
554
+ commandArgs.includes("--heading") ||
555
+ commandArgs.includes("--start") ||
556
+ commandArgs.includes("--end")) {
543
557
  throw new UsageError('Legacy show flags are no longer supported. Use positional syntax like `akm show knowledge:guide toc` or `akm show knowledge:guide section "Auth"`.');
544
558
  }
545
559
  // Separate global flags from positional/show-specific args
546
- const prefix = argv.slice(0, 3); // [bun, script, show]
547
- const rest = argv.slice(3);
560
+ const prefix = [...argv.slice(0, 2), ...rawArgs.slice(0, commandIndex + 1)];
561
+ const rest = commandArgs;
548
562
  const globalFlags = [];
549
563
  const showArgs = [];
550
564
  for (let i = 0; i < rest.length; i++) {
@@ -37,7 +37,7 @@ import { copyStashSkeleton, scaffoldStashMeta } from "./stash-skeleton.js";
37
37
  function assertInitSandbox(stashDir, dirExplicitlyProvided) {
38
38
  if (!dirExplicitlyProvided)
39
39
  return; // Only guard explicit --dir, not default HOME resolution.
40
- const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
40
+ const isUnderTest = isUnderTestRunner();
41
41
  if (!isUnderTest)
42
42
  return;
43
43
  if (process.env.AKM_FORCE_INIT_TMP_STASH === "1")
@@ -52,6 +52,9 @@ function assertInitSandbox(stashDir, dirExplicitlyProvided) {
52
52
  return;
53
53
  throw new ConfigError(`refusing to persist --dir stashDir to a temporary path while under test runner; set AKM_FORCE_INIT_TMP_STASH=1 if you really mean it (stashDir=${stashDir})`, "INIT_TMP_STASH_REFUSED");
54
54
  }
55
+ function isUnderTestRunner() {
56
+ return process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
57
+ }
55
58
  export async function akmInit(options) {
56
59
  const dirExplicitlyProvided = options?.dir != null;
57
60
  const setDefault = options?.setDefault === true;
@@ -114,13 +117,15 @@ export async function akmInit(options) {
114
117
  }
115
118
  // Ensure ripgrep is available (install to cache/bin if needed)
116
119
  let ripgrep;
117
- try {
118
- const binDir = getBinDir();
119
- const rgResult = ensureRg(binDir);
120
- ripgrep = rgResult;
121
- }
122
- catch {
123
- // Non-fatal: ripgrep is optional, search works without it
120
+ if (!isUnderTestRunner()) {
121
+ try {
122
+ const binDir = getBinDir();
123
+ const rgResult = ensureRg(binDir);
124
+ ripgrep = rgResult;
125
+ }
126
+ catch {
127
+ // Non-fatal: ripgrep is optional, search works without it
128
+ }
124
129
  }
125
130
  return { stashDir, created, configPath, defaultStashUpdated, previousStashDir, ripgrep };
126
131
  }
@@ -17,7 +17,7 @@ import { removeLockEntry, upsertLockEntry } from "../../integrations/lockfile.js
17
17
  import { parseRegistryRef } from "../../registry/resolve.js";
18
18
  import { parseGitRepoUrl, syncMirroredRepo } from "../../sources/providers/git.js";
19
19
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
20
- import { ensureWebsiteMirror } from "../../sources/website-ingest.js";
20
+ import { ensureWebsiteMirror, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
21
21
  import { listWikis, resolveWikisRoot } from "../../wiki/wiki.js";
22
22
  import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./source-add.js";
23
23
  import { removeStash } from "./source-manage.js";
@@ -194,7 +194,11 @@ async function updateGitSource(stashDir, target, all, gitSource) {
194
194
  /** Re-crawl a website source and return an UpdateResponse. */
195
195
  async function updateWebsiteSource(stashDir, target, all, websiteSource) {
196
196
  // TODO: full incremental re-crawl with delta tracking (#19)
197
- await ensureWebsiteMirror(websiteSource, { requireStashDir: true, force: true });
197
+ await ensureWebsiteMirror(websiteSource, {
198
+ requireStashDir: true,
199
+ force: true,
200
+ ...(shouldAllowPrivateWebsiteUrlForTests(websiteSource.url ?? "") ? { allowPrivateHosts: true } : {}),
201
+ });
198
202
  return buildUpdateResponse(stashDir, target, all, []);
199
203
  }
200
204
  /** Sync a single installed registry entry and return the processed record. */
@@ -20,7 +20,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
20
20
  import { authoringRulesForType } from "../../core/authoring-rules.js";
21
21
  import { appendEvent, readEvents } from "../../core/events.js";
22
22
  import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
23
- import { info, warn } from "../../core/warn.js";
23
+ import { info } from "../../core/warn.js";
24
24
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
25
25
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
26
26
  import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
@@ -46,6 +46,9 @@ export async function runSchemaRepairPass(failures, options) {
46
46
  const repairs = [];
47
47
  const repairedRefs = new Set();
48
48
  const { startMs, budgetMs, llmConfig, stashDir, findFilePath = defaultFindFilePath, isLessonCandidateFn = defaultIsLessonCandidate, chatFn = chatCompletion, } = options;
49
+ if (!stashDir) {
50
+ throw new Error("runSchemaRepairPass requires stashDir so repairs route through the proposal queue");
51
+ }
49
52
  for (const failure of failures) {
50
53
  if (Date.now() - startMs >= budgetMs)
51
54
  break;
@@ -97,9 +100,9 @@ export async function runSchemaRepairPass(failures, options) {
97
100
  info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
98
101
  const bodyPreview = (fm.content ?? raw).slice(0, 2000);
99
102
  // Standards "rulebook" for this target — wiki schema (wiki page) or stash
100
- // convention/meta facts (non-wiki asset); empty when neither fires or no
101
- // stash dir is available. `resolveStandardsContext` dispatches on the ref.
102
- const standardsContext = stashDir ? resolveStandardsContext(failure.ref, stashDir) : "";
103
+ // convention/meta facts (non-wiki asset). `resolveStandardsContext`
104
+ // dispatches on the ref.
105
+ const standardsContext = resolveStandardsContext(failure.ref, stashDir);
103
106
  const standardsSection = standardsContext.trim()
104
107
  ? `\n\nStandards to follow (the rulebook for this target):\n${standardsContext.trim()}`
105
108
  : "";
@@ -139,50 +142,33 @@ export async function runSchemaRepairPass(failures, options) {
139
142
  // them human-reviewable before they affect search ranking and curate hints.
140
143
  // mem0 open gaps (arXiv:2504.19413) — any LLM write to a memory field
141
144
  // should be human-reviewable.
142
- if (stashDir) {
143
- const proposalResult = createProposal(stashDir, {
144
- ref: failure.ref,
145
- source: "schema-repair",
146
- payload: {
147
- content: newContent,
148
- ...(Object.keys(newFm).length > 0 ? { frontmatter: newFm } : {}),
149
- },
150
- });
151
- if (isProposalSkipped(proposalResult)) {
152
- info(`[improve] schema-repair proposal skipped for ${failure.ref}: ${proposalResult.message}`);
153
- repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
154
- continue;
155
- }
156
- info(`[improve] schema-repair queued: ${failure.ref} (proposal id: ${proposalResult.id})`);
157
- appendEvent({
158
- eventType: "schema_repair_invoked",
159
- ref: failure.ref,
160
- metadata: { outcome: "queued", reason: failure.reason, proposalId: proposalResult.id },
161
- });
162
- repairs.push({
163
- ref: failure.ref,
164
- reason: failure.reason,
165
- outcome: "queued",
166
- proposalId: proposalResult.id,
167
- });
168
- // Mark as repaired so the caller removes it from the validation-failure set.
169
- repairedRefs.add(failure.ref);
170
- }
171
- else {
172
- // Fallback: no stash dir available — write directly (legacy path).
173
- // This should not occur in production; stashDir is always provided by
174
- // `runSchemaRepairPass` callers in improve.ts.
175
- warn(`[improve] schema-repair: no stashDir available for ${failure.ref}, falling back to direct write`);
176
- fs.writeFileSync(filePath, newContent, "utf8");
177
- info(`[improve] schema-repair written: ${failure.ref}`);
178
- appendEvent({
179
- eventType: "schema_repair_invoked",
180
- ref: failure.ref,
181
- metadata: { outcome: "written", reason: failure.reason },
182
- });
183
- repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "written" });
184
- repairedRefs.add(failure.ref);
145
+ const proposalResult = createProposal(stashDir, {
146
+ ref: failure.ref,
147
+ source: "schema-repair",
148
+ payload: {
149
+ content: newContent,
150
+ ...(Object.keys(newFm).length > 0 ? { frontmatter: newFm } : {}),
151
+ },
152
+ });
153
+ if (isProposalSkipped(proposalResult)) {
154
+ info(`[improve] schema-repair proposal skipped for ${failure.ref}: ${proposalResult.message}`);
155
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
156
+ continue;
185
157
  }
158
+ info(`[improve] schema-repair queued: ${failure.ref} (proposal id: ${proposalResult.id})`);
159
+ appendEvent({
160
+ eventType: "schema_repair_invoked",
161
+ ref: failure.ref,
162
+ metadata: { outcome: "queued", reason: failure.reason, proposalId: proposalResult.id },
163
+ });
164
+ repairs.push({
165
+ ref: failure.ref,
166
+ reason: failure.reason,
167
+ outcome: "queued",
168
+ proposalId: proposalResult.id,
169
+ });
170
+ // Mark as repaired so the caller removes it from the validation-failure set.
171
+ repairedRefs.add(failure.ref);
186
172
  }
187
173
  catch (e) {
188
174
  appendEvent({
@@ -11,7 +11,7 @@ import { upsertLockEntry } from "../../integrations/lockfile.js";
11
11
  import { parseRegistryRef } from "../../registry/resolve.js";
12
12
  import { detectStashRoot } from "../../sources/providers/provider-utils.js";
13
13
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
14
- import { ensureWebsiteMirror, validateWebsiteInputUrl } from "../../sources/website-ingest.js";
14
+ import { ensureWebsiteMirror, shouldAllowPrivateWebsiteUrlForTests, validateWebsiteInputUrl, } from "../../sources/website-ingest.js";
15
15
  import { ensureWikiNameAvailable, validateWikiName } from "../../wiki/wiki.js";
16
16
  const VALID_OVERRIDE_TYPES = new Set(["wiki"]);
17
17
  export async function akmAdd(input) {
@@ -126,7 +126,8 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
126
126
  };
127
127
  }
128
128
  async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
129
- const normalizedUrl = validateWebsiteInputUrl(ref);
129
+ const allowPrivateHosts = shouldAllowPrivateWebsiteUrlForTests(ref);
130
+ const normalizedUrl = validateWebsiteInputUrl(ref, { allowPrivateHosts });
130
131
  const config = loadUserConfig();
131
132
  const sources = [...getSources(config)];
132
133
  let entry = sources.find((stash) => stash.type === "website" && stash.url === normalizedUrl);
@@ -154,7 +155,10 @@ async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
154
155
  if (changed)
155
156
  saveConfig({ ...config, sources });
156
157
  }
157
- const cachePaths = await ensureWebsiteMirror(entry, { requireStashDir: true });
158
+ const cachePaths = await ensureWebsiteMirror(entry, {
159
+ requireStashDir: true,
160
+ ...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
161
+ });
158
162
  const index = await akmIndex({ stashDir });
159
163
  const updatedConfig = loadConfig();
160
164
  return {
@@ -16,6 +16,7 @@ import { isWithin, resolveStashDir } from "../../core/common.js";
16
16
  import { loadConfig } from "../../core/config/config.js";
17
17
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
18
18
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
19
+ import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
19
20
  import { listAgentProfileNames } from "../../integrations/agent/index.js";
20
21
  import { resolveAssetPath } from "../../sources/resolve.js";
21
22
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
@@ -38,7 +39,8 @@ export async function akmTasksAdd(input) {
38
39
  // Validate the schedule for the active backend before writing anything.
39
40
  const backend = backendNameForPlatform();
40
41
  parseSchedule(input.schedule, backend);
41
- const stashDir = resolveStashDir();
42
+ const target = resolveTaskWriteTarget();
43
+ const stashDir = target.source.path;
42
44
  const typeRoot = path.join(stashDir, "tasks");
43
45
  fs.mkdirSync(typeRoot, { recursive: true });
44
46
  const assetPath = resolveAssetPathFromName("task", typeRoot, id);
@@ -64,7 +66,8 @@ export async function akmTasksAdd(input) {
64
66
  });
65
67
  const task = parseTaskDocument({ yaml, filePath: assetPath, id });
66
68
  await validateTaskDocument(task, { backend, stashDir });
67
- fs.writeFileSync(assetPath, yaml.endsWith("\n") ? yaml : `${yaml}\n`, "utf8");
69
+ const ref = taskAssetRef(id);
70
+ await writeAssetToSource(target.source, target.config, ref, yaml);
68
71
  // Install in the OS scheduler. If install fails after the file was written,
69
72
  // delete the file so the on-disk state never claims a task is registered
70
73
  // when it isn't.
@@ -74,13 +77,14 @@ export async function akmTasksAdd(input) {
74
77
  }
75
78
  catch (err) {
76
79
  try {
77
- fs.rmSync(assetPath, { force: true });
80
+ await deleteAssetFromSource(target.source, target.config, ref);
78
81
  }
79
82
  catch {
80
83
  /* ignore */
81
84
  }
82
85
  throw err;
83
86
  }
87
+ commitWriteTargetBoundary(target, `Update task:${id}`);
84
88
  return {
85
89
  id,
86
90
  ref: `task:${id}`,
@@ -192,30 +196,47 @@ export async function akmTasksShow(id) {
192
196
  }
193
197
  export async function akmTasksRemove(id) {
194
198
  const normalised = normaliseTaskId(id);
195
- const stashDir = resolveStashDir();
199
+ const target = resolveTaskWriteTarget();
200
+ const stashDir = target.source.path;
196
201
  const typeRoot = path.join(stashDir, "tasks");
197
202
  if (fs.existsSync(typeRoot))
198
203
  warnLegacyMdTaskFiles(typeRoot);
199
- const filePath = await resolveAssetPath(stashDir, "task", normalised);
204
+ await resolveAssetPath(stashDir, "task", normalised);
205
+ const ref = taskAssetRef(normalised);
200
206
  const sched = selectBackend();
207
+ let uninstallError;
208
+ let deleteError;
201
209
  try {
202
210
  await sched.uninstall(normalised);
203
211
  }
204
- finally {
205
- fs.rmSync(filePath, { force: true });
212
+ catch (err) {
213
+ uninstallError = err;
214
+ }
215
+ try {
216
+ await deleteAssetFromSource(target.source, target.config, ref);
206
217
  }
218
+ catch (err) {
219
+ deleteError = err;
220
+ }
221
+ if (uninstallError !== undefined)
222
+ throw uninstallError;
223
+ if (deleteError !== undefined)
224
+ throw deleteError;
225
+ commitWriteTargetBoundary(target, `Remove task:${normalised}`);
207
226
  return { id: normalised, removed: true, backend: sched.name };
208
227
  }
209
228
  export async function akmTasksSetEnabled(id, enabled) {
210
229
  const normalised = normaliseTaskId(id);
211
- const stashDir = resolveStashDir();
230
+ const target = resolveTaskWriteTarget();
231
+ const stashDir = target.source.path;
212
232
  const typeRoot = path.join(stashDir, "tasks");
213
233
  if (fs.existsSync(typeRoot))
214
234
  warnLegacyMdTaskFiles(typeRoot);
215
235
  const filePath = await resolveAssetPath(stashDir, "task", normalised);
216
236
  const yaml = fs.readFileSync(filePath, "utf8");
217
237
  const updated = setEnabledInYaml(yaml, enabled);
218
- fs.writeFileSync(filePath, updated, "utf8");
238
+ const ref = taskAssetRef(normalised);
239
+ await writeAssetToSource(target.source, target.config, ref, updated);
219
240
  const sched = selectBackend();
220
241
  try {
221
242
  // Reinstall from the (just-updated) definition rather than only toggling
@@ -229,9 +250,10 @@ export async function akmTasksSetEnabled(id, enabled) {
229
250
  catch (err) {
230
251
  // Roll the file back so the YAML source-of-truth and the OS
231
252
  // scheduler don't diverge silently when the backend call fails.
232
- fs.writeFileSync(filePath, yaml, "utf8");
253
+ await writeAssetToSource(target.source, target.config, ref, yaml);
233
254
  throw err;
234
255
  }
256
+ commitWriteTargetBoundary(target, `Update task:${normalised}`);
235
257
  return { id: normalised, enabled, backend: sched.name };
236
258
  }
237
259
  export async function akmTasksRun(id) {
@@ -392,6 +414,12 @@ function normaliseTaskId(raw) {
392
414
  }
393
415
  return id;
394
416
  }
417
+ function taskAssetRef(id) {
418
+ return { type: "task", name: id };
419
+ }
420
+ function resolveTaskWriteTarget() {
421
+ return resolveWriteTarget(loadConfig());
422
+ }
395
423
  function renderTaskYaml(input) {
396
424
  const obj = { schedule: input.schedule };
397
425
  if (input.workflow) {