akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -27,7 +27,7 @@ import { listTopLevelConfigKeys } from "../core/config/config-schema.js";
27
27
  import { deepMergeConfig, isPlainObject } from "../core/config/deep-merge.js";
28
28
  import { ConfigError, UsageError } from "../core/errors.js";
29
29
  import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
30
- import { warn } from "../core/warn.js";
30
+ import { warn, warnOnce } from "../core/warn.js";
31
31
  import { akmIndex } from "../indexer/indexer.js";
32
32
  import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent/index.js";
33
33
  import { defaultProfileName } from "../integrations/harnesses/index.js";
@@ -51,32 +51,25 @@ import { stepStashDir } from "./steps/stashdir.js";
51
51
  import { stepScheduledTasks } from "./steps/tasks.js";
52
52
  // ── Setup sandbox guard ─────────────────────────────────────────────────────
53
53
  /**
54
- * Refuse to persist an explicit `--dir /tmp/...` as the default bundle path.
55
- * The OS may reap the directory at any time, leaving the next run with a
56
- * default bundle that points at a deleted path. Mirrors the
57
- * `assertInitSandbox` check in commands/init.ts, but
58
- * fires under all runtimes (not just `bun test`) because `akm setup --dir
59
- * /tmp/X` is a documented isolation pattern that has been observed to
60
- * silently clobber the host config (the 2026-05-23 setup-clobbers-user-config
61
- * incident).
62
- *
63
- * Escape hatch: set `AKM_FORCE_SETUP_TMP_STASH=1` to override. When the
64
- * escape hatch is on, `applyStashIsolationToEnv` below also pre-sets
65
- * `AKM_BUNDLE_DIR` so that the `getConfigDir` / `getCacheDir` isolation
66
- * rules fire and config + cache writes route into `$stashDir/.akm/`
67
- * instead of the user's host `~/.config/akm`.
54
+ * Warn when an explicit `--dir /tmp/...` is persisted as the default bundle
55
+ * path `akm setup --dir $(mktemp -d)` resolves under this family on every
56
+ * macOS machine (`/var/folders/...`), and used to be refused outright with
57
+ * no discoverable remedy. The OS may reap the directory at any time, so the
58
+ * operator is told up front; `applyStashIsolationToEnv` below still pre-sets
59
+ * `AKM_BUNDLE_DIR` so the `getConfigDir` / `getCacheDir` isolation rules
60
+ * (`core/paths.ts`) route config + cache writes into `$stashDir/.akm/`
61
+ * instead of the user's host `~/.config/akm`, which is what actually
62
+ * protects the host config (the 2026-05-23 setup-clobbers-user-config
63
+ * incident this guard traces to).
68
64
  */
69
65
  export function assertSetupSandbox(stashDir, dirExplicitlyProvided) {
70
66
  if (!dirExplicitlyProvided)
71
67
  return;
72
- if (process.env.AKM_FORCE_SETUP_TMP_STASH === "1")
73
- return;
74
68
  if (!isTransientStashPath(stashDir))
75
69
  return;
76
- throw new ConfigError(`refusing to run \`akm setup --dir ${stashDir}\`: the path is in a transient/sandbox directory family the OS may reap. ` +
77
- "Persisting it as the default bundle would leave the next run pointing at a deleted path. " +
78
- "Use a persistent directory, OR set AKM_FORCE_SETUP_TMP_STASH=1 if you intentionally want a sandbox setup " +
79
- "(setup will also auto-isolate config + cache writes into $stashDir/.akm/ so the host config is preserved).", "SETUP_TMP_STASH_REFUSED");
70
+ warnOnce(`setup-tmp-stash:${stashDir}`, `\`akm setup --dir ${stashDir}\` targets a transient/sandbox directory the OS may reap at any time; ` +
71
+ `the next run would then point at a deleted bundle. Config and cache writes are isolated into ` +
72
+ `${stashDir}/.akm/ so the host config is unaffected, but treat this stash as disposable.`);
80
73
  }
81
74
  /**
82
75
  * Propagate the explicit `--dir <stashDir>` choice to the env so that the
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { isWithin } from "../core/common.js";
7
+ import { warnOnce } from "../core/warn.js";
7
8
  // ── Helpers ─────────────────────────────────────────────────────────────────
8
9
  /** Key to check in package.json for akm include configuration. */
9
10
  const INCLUDE_CONFIG_KEYS = ["akm"];
@@ -64,51 +65,180 @@ export function findNearestIncludeConfig(startDir, boundary) {
64
65
  }
65
66
  return undefined;
66
67
  }
68
+ /**
69
+ * Does `entry` use glob syntax (`*`/`?`)? Everything else is a literal path,
70
+ * resolved and copied exactly as before — this only changes behavior for
71
+ * entries that actually contain a wildcard.
72
+ */
73
+ function isIncludeEntryPattern(entry) {
74
+ return /[*?]/.test(entry);
75
+ }
76
+ /**
77
+ * Translate one `akm.include` entry to a regular expression matched against a
78
+ * `/`-joined path relative to `sourceDir`. Supports the subset package.json
79
+ * authors reach for in practice: `*` (any run of characters within one path
80
+ * segment), `?` (one character), and `**` (any run of characters across
81
+ * segments, including none) — e.g. `skills/*` or `skills/**\/*.md`. Not a
82
+ * full glob implementation (no brace expansion, character classes, or
83
+ * negation); those fall through to being treated as a literal, almost
84
+ * certainly non-matching path, same as an ordinary typo would.
85
+ */
86
+ function includeEntryToRegExp(entry) {
87
+ const normalized = entry.split(path.sep).join("/");
88
+ let pattern = "";
89
+ for (let i = 0; i < normalized.length;) {
90
+ if (normalized.startsWith("**", i)) {
91
+ const consumesSlash = normalized[i + 2] === "/";
92
+ pattern += consumesSlash ? "(?:.*/)?" : ".*";
93
+ i += consumesSlash ? 3 : 2;
94
+ continue;
95
+ }
96
+ const char = normalized[i];
97
+ if (char === "*")
98
+ pattern += "[^/]*";
99
+ else if (char === "?")
100
+ pattern += "[^/]";
101
+ else
102
+ pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
103
+ i += 1;
104
+ }
105
+ return new RegExp(`^${pattern}$`);
106
+ }
107
+ /** A symlink whose lstat succeeds but whose target does not exist — `copyPath` reports this case with its own specific warning. */
108
+ function isBrokenSymlink(candidate) {
109
+ try {
110
+ return fs.lstatSync(candidate).isSymbolicLink();
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ /** Every path under `dir`, `/`-joined and relative to `base`, without following symlinked directories. */
117
+ function listAllRelativePaths(dir, base) {
118
+ const out = [];
119
+ let entries;
120
+ try {
121
+ entries = fs.readdirSync(dir, { withFileTypes: true });
122
+ }
123
+ catch {
124
+ return out;
125
+ }
126
+ for (const entry of entries) {
127
+ if (entry.name === ".git")
128
+ continue;
129
+ const absolute = path.join(dir, entry.name);
130
+ out.push(path.relative(base, absolute).split(path.sep).join("/"));
131
+ // `isDirectory()` on a `Dirent` reflects lstat, so a symlinked directory
132
+ // is listed as an entry (and handled by copyPath's own symlink handling)
133
+ // but never recursed into here.
134
+ if (entry.isDirectory())
135
+ out.push(...listAllRelativePaths(absolute, base));
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * Resolve one `akm.include` entry to the absolute candidate paths it names.
141
+ * A literal entry resolves to itself, whether or not it exists on disk — the
142
+ * caller checks that (after re-verifying containment, which needs to see
143
+ * the literal candidate regardless). A pattern entry expands to every
144
+ * existing matching path under `sourceDir`, so matching nothing already
145
+ * naturally comes back as an empty array.
146
+ */
147
+ function resolveIncludeEntry(entry, sourceDir) {
148
+ if (!isIncludeEntryPattern(entry))
149
+ return [path.resolve(sourceDir, entry)];
150
+ const pattern = includeEntryToRegExp(entry);
151
+ return listAllRelativePaths(sourceDir, sourceDir)
152
+ .filter((relative) => pattern.test(relative))
153
+ .map((relative) => path.join(sourceDir, relative));
154
+ }
67
155
  /**
68
156
  * Copy each glob/path in `includeGlobs` from `sourceDir` to `destDir`.
69
157
  *
70
158
  * Uses `isWithin()` to prevent path-traversal attacks: any entry that escapes
71
- * `sourceDir` throws immediately rather than silently being skipped.
159
+ * `sourceDir` throws immediately this is the operator's own config
160
+ * (`akm.include` in a package.json under a source they added), not
161
+ * discovered content, so a deliberate `../` here is treated as the mistake
162
+ * (or attack) it looks like, not skipped.
72
163
  *
73
- * @throws {Error} if an include path escapes `sourceDir` or does not exist on disk.
164
+ * A missing entry, or a pattern that matches nothing, is skipped with a
165
+ * warning instead of failing the whole install — one bad entry in a large
166
+ * `include` list should not leave zero assets installed.
74
167
  */
75
168
  export function copyIncludedPaths(includeGlobs, sourceDir, destDir) {
76
169
  for (const entry of includeGlobs) {
77
- const resolvedSource = path.resolve(sourceDir, entry);
78
- if (!isWithin(resolvedSource, sourceDir)) {
79
- throw new Error(`Path in akm.include escapes the package root: ${entry}`);
80
- }
81
- if (!fs.existsSync(resolvedSource)) {
82
- throw new Error(`Path in akm.include does not exist: ${entry}`);
83
- }
84
- if (path.basename(resolvedSource) === ".git") {
170
+ const resolvedPaths = resolveIncludeEntry(entry, sourceDir);
171
+ if (resolvedPaths.length === 0) {
172
+ warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
85
173
  continue;
86
174
  }
87
- const relativePath = path.relative(sourceDir, resolvedSource);
88
- if (!relativePath || relativePath === ".") {
89
- copyDirectoryContents(sourceDir, destDir);
90
- continue;
175
+ for (const resolvedSource of resolvedPaths) {
176
+ if (!isWithin(resolvedSource, sourceDir)) {
177
+ throw new Error(`Path in akm.include escapes the package root: ${entry}`);
178
+ }
179
+ if (!fs.existsSync(resolvedSource) && !isBrokenSymlink(resolvedSource)) {
180
+ warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
181
+ continue;
182
+ }
183
+ if (path.basename(resolvedSource) === ".git")
184
+ continue;
185
+ const relativePath = path.relative(sourceDir, resolvedSource);
186
+ if (!relativePath || relativePath === ".") {
187
+ copyDirectoryContents(sourceDir, destDir, sourceDir);
188
+ continue;
189
+ }
190
+ copyPath(resolvedSource, path.join(destDir, relativePath), sourceDir);
91
191
  }
92
- copyPath(resolvedSource, path.join(destDir, relativePath));
93
192
  }
94
193
  }
95
194
  // ── Private helpers ─────────────────────────────────────────────────────────
96
- function copyDirectoryContents(sourceDir, destinationDir) {
195
+ function copyDirectoryContents(sourceDir, destinationDir, containmentRoot) {
97
196
  for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
98
197
  if (entry.name === ".git")
99
198
  continue;
100
- copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name));
199
+ copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name), containmentRoot);
101
200
  }
102
201
  }
103
- function copyPath(sourcePath, destinationPath) {
202
+ /**
203
+ * Copy one path. A symlink is followed and its TARGET's content copied
204
+ * (never re-materialized as a symlink) rather than refused outright — a
205
+ * published package with, say, `README.md -> docs/README.md` is ordinary,
206
+ * not an attack. The resolved target must still stay within
207
+ * `containmentRoot`: this is the same containment guarantee `isWithin`
208
+ * enforces on every literal `akm.include` entry, just applied to a target we
209
+ * discovered by following a link rather than one the operator typed — a
210
+ * symlink escaping the package (to `/etc/passwd`, say) is skipped with a
211
+ * warning, not copied.
212
+ */
213
+ function copyPath(sourcePath, destinationPath, containmentRoot) {
104
214
  const stat = fs.lstatSync(sourcePath);
105
215
  if (stat.isSymbolicLink()) {
106
- throw new Error(`Path in akm.include must not be a symlink: ${sourcePath}`);
216
+ let resolved;
217
+ try {
218
+ resolved = fs.realpathSync(sourcePath);
219
+ }
220
+ catch {
221
+ warnOnce(`akm-include-broken-symlink:${sourcePath}`, `[akm] Skipping broken symlink in akm.include: ${path.relative(containmentRoot, sourcePath)}`);
222
+ return;
223
+ }
224
+ if (!isWithin(resolved, containmentRoot)) {
225
+ warnOnce(`akm-include-symlink-escape:${sourcePath}`, `[akm] Skipping symlink in akm.include that points outside the package: ${path.relative(containmentRoot, sourcePath)}`);
226
+ return;
227
+ }
228
+ const resolvedStat = fs.statSync(resolved);
229
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
230
+ if (resolvedStat.isDirectory()) {
231
+ fs.mkdirSync(destinationPath, { recursive: true });
232
+ copyDirectoryContents(resolved, destinationPath, containmentRoot);
233
+ return;
234
+ }
235
+ fs.copyFileSync(resolved, destinationPath);
236
+ return;
107
237
  }
108
238
  fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
109
239
  if (stat.isDirectory()) {
110
240
  fs.mkdirSync(destinationPath, { recursive: true });
111
- copyDirectoryContents(sourcePath, destinationPath);
241
+ copyDirectoryContents(sourcePath, destinationPath, containmentRoot);
112
242
  return;
113
243
  }
114
244
  fs.copyFileSync(sourcePath, destinationPath);
@@ -350,25 +350,27 @@ export function syncExistingWritableCheckout(parsed, resolved, contentRoot, sync
350
350
  targetRevision = fallbackTarget.stdout.trim();
351
351
  }
352
352
  const relation = gitRelation(repoDir, targetRevision);
353
- if (relation.ahead > 0) {
354
- throw new UsageError(`Writable Git install at ${root} has local commits that are not in the requested upstream revision; push or reconcile them before update.`);
355
- }
356
- assertNoIgnoredPathOverwrite(repoDir, targetRevision);
357
353
  const rootsToPreserve = [...new Set([root, ...requiredRoots.map((candidate) => path.resolve(candidate))])];
358
354
  for (const requiredRoot of rootsToPreserve) {
359
355
  if (!isWithin(requiredRoot, repoDir)) {
360
356
  throw new UsageError(`Configured Git component root ${requiredRoot} resolves outside ${repoDir}.`);
361
357
  }
362
- const relative = path.relative(repoDir, requiredRoot).replaceAll(path.sep, "/");
363
- if (!relative)
364
- continue;
365
- const tree = runGit(["-C", repoDir, "ls-tree", "-d", "-z", "--name-only", targetRevision, "--", relative]);
366
- const names = tree.stdout.split("\0").filter(Boolean);
367
- if (tree.status !== 0 || !names.includes(relative)) {
368
- throw new UsageError(`Writable Git update would remove configured content root ${requiredRoot}; the existing checkout was left unchanged.`);
369
- }
370
358
  }
371
359
  if (relation.behind > 0) {
360
+ if (relation.ahead > 0) {
361
+ throw new UsageError(`Writable Git install at ${root} has local commits that are not in the requested upstream revision; push or reconcile them before update.`);
362
+ }
363
+ assertNoIgnoredPathOverwrite(repoDir, targetRevision);
364
+ for (const requiredRoot of rootsToPreserve) {
365
+ const relative = path.relative(repoDir, requiredRoot).replaceAll(path.sep, "/");
366
+ if (!relative)
367
+ continue;
368
+ const tree = runGit(["-C", repoDir, "ls-tree", "-d", "-z", "--name-only", targetRevision, "--", relative]);
369
+ const names = tree.stdout.split("\0").filter(Boolean);
370
+ if (tree.status !== 0 || !names.includes(relative)) {
371
+ throw new UsageError(`Writable Git update would remove configured content root ${requiredRoot}; the existing checkout was left unchanged.`);
372
+ }
373
+ }
372
374
  const statusBeforeMerge = runGit(["-C", repoDir, "status", "--porcelain"]);
373
375
  if (statusBeforeMerge.status !== 0 || statusBeforeMerge.stdout.trim()) {
374
376
  throw new UsageError(`Writable Git install at ${root} changed while its update was prepared; local work was preserved.`);
@@ -127,10 +127,10 @@ function pullRepo(repoDir) {
127
127
  throw new UsageError(`Writable Git source at ${repoDir} has uncommitted changes; refusing to update it.`);
128
128
  }
129
129
  const relation = inspectGitUpstream(repoDir);
130
- if (relation.ahead > 0) {
131
- throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`);
132
- }
133
130
  if (relation.behind > 0 && relation.upstream) {
131
+ if (relation.ahead > 0) {
132
+ throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`);
133
+ }
134
134
  const statusBeforeMerge = runGit(["-C", repoDir, "status", "--porcelain"]);
135
135
  if (statusBeforeMerge.status !== 0 || statusBeforeMerge.stdout.trim()) {
136
136
  throw new UsageError(`Writable Git source at ${repoDir} changed while its update was prepared; refusing to merge.`);
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fetchWithRetry, isWithin, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir, todayIso, } from "../../core/common.js";
8
8
  import { ConfigError, UsageError } from "../../core/errors.js";
9
- import { warn, warnVerbose } from "../../core/warn.js";
9
+ import { warn, warnOnce, warnVerbose } from "../../core/warn.js";
10
10
  import { withFreshnessCache } from "../freshness.js";
11
11
  import { sanitizeString } from "../providers/provider-utils.js";
12
12
  import { getWebsiteCachePaths, normalizeSiteUrl, validateWebsiteInputUrl, validateWebsiteUrl, } from "../website-url.js";
@@ -324,19 +324,20 @@ async function scrapeWebsiteToStash(startUrl, stashDir, options) {
324
324
  export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
325
325
  const normalizedUrl = validateWebsiteInputUrl(rawUrl, { allowPrivateHosts: options?.allowPrivateHosts });
326
326
  const parsedUrl = new URL(normalizedUrl);
327
+ const allowPrivateHosts = await resolveAllowPrivateStartHost(parsedUrl, options?.allowPrivateHosts);
327
328
  const stashDir = resolveFetcherStashDir(options?.stashDir);
328
329
  const context = {
329
330
  stashDir: stashDir ?? "",
330
331
  timeoutMs: options?.timeoutMs ?? 15_000,
331
332
  signal: options?.signal,
332
333
  ...(options?.resolveSecret ? { resolveSecret: options.resolveSecret } : {}),
333
- ...(options?.allowPrivateHosts ? { allowPrivateHosts: true } : {}),
334
+ ...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
334
335
  };
335
336
  const snapshot = await dispatchSnapshotFetchers(parsedUrl, context, stashDir);
336
337
  if (snapshot)
337
338
  return websiteMarkdownSnapshotFromResult(snapshot);
338
339
  const fetchedResponse = await fetchWebsiteResponse(normalizedUrl, 0, {
339
- allowPrivateHosts: options?.allowPrivateHosts,
340
+ allowPrivateHosts,
340
341
  signal: options?.signal,
341
342
  });
342
343
  const finalUrl = normalizeCrawlUrl(fetchedResponse.finalUrl) ?? normalizedUrl;
@@ -355,7 +356,7 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
355
356
  }
356
357
  }
357
358
  const fetched = await websitePageFromResponse(fetchedResponse, normalizedUrl, {
358
- allowPrivateHosts: options?.allowPrivateHosts,
359
+ allowPrivateHosts,
359
360
  signal: options?.signal,
360
361
  });
361
362
  if (!fetched)
@@ -465,9 +466,12 @@ async function resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl) {
465
466
  }
466
467
  /**
467
468
  * C-02/C-03: fail fast, before any page fetch, when the start URL itself is
468
- * off-limits. A 5xx robots.txt (RobotsPolicy caches `DISALLOW_ALL_RULES` for
469
- * that case) gets a distinct message calling out the server error, per spec
470
- * §4.6.
469
+ * off-limits per an actually-parsed robots.txt rule. A 5xx on robots.txt
470
+ * itself no longer reaches this as `disallowAll`: `loadRobotsTxt` reports it the same way a
471
+ * 4xx is reported (`ALLOW_ALL_RULES`), with its own warning, rather than
472
+ * treating the site's own transient error as a Disallow. The check below
473
+ * stays as defense-in-depth for a `RobotsPolicy` built over some other
474
+ * loader that does still report `disallowAll`.
471
475
  *
472
476
  * Checks both `start`'s (normalized) URL and `rawStartUrl` — the URL exactly
473
477
  * as the user supplied it in config, before `validateWebsiteUrl` ->
@@ -506,9 +510,37 @@ async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
506
510
  `source to bypass robots.txt.`);
507
511
  }
508
512
  }
513
+ /**
514
+ * Decide whether to treat a fetch's start host — the URL an operator directly
515
+ * typed or configured (`akm bundle add`, `akm knowledge add`, a crawl's own
516
+ * start URL), never a link discovered while crawling — as private for this
517
+ * operation. `requested` (already true when the URL itself was
518
+ * loopback/private-literal, per `shouldAllowPrivateWebsiteUrlForTests`) wins
519
+ * outright. Otherwise, resolve the host: a name that only turns out to be
520
+ * private via DNS (a corporate wiki behind split-horizon DNS, a VPN hostname)
521
+ * would otherwise abort with no escape hatch even though the operator named
522
+ * this exact host. Callers scope the resulting bypass to same-origin work
523
+ * only — a link discovered mid-crawl is restricted to the start URL's origin
524
+ * before it ever reaches a guard, so this never extends trust to a host the
525
+ * fetched content merely points at.
526
+ */
527
+ async function resolveAllowPrivateStartHost(start, requested) {
528
+ if (requested)
529
+ return true;
530
+ try {
531
+ await assertResolvedHostAllowed(start.hostname);
532
+ return false;
533
+ }
534
+ catch {
535
+ warnOnce(`website-private-start-host:${start.origin}`, `[akm] ${start.origin} is not a publicly routable host, but you added it as a source directly — proceeding. ` +
536
+ "Links to other hosts found in its content are still checked.");
537
+ return true;
538
+ }
539
+ }
509
540
  async function crawlWebsite(startUrl, options) {
510
541
  const start = new URL(normalizeSiteUrl(startUrl));
511
542
  const allowedOrigin = start.origin;
543
+ const allowPrivateHosts = await resolveAllowPrivateStartHost(start, options.allowPrivateHosts);
512
544
  const queue = [
513
545
  { url: start.toString(), rawUrl: options.rawStartUrl ?? start.toString(), depth: 0, deferrals: 0 },
514
546
  ];
@@ -532,7 +564,7 @@ async function crawlWebsite(startUrl, options) {
532
564
  const crawlSignal = abortController.signal;
533
565
  const robots = options.respectRobots === false
534
566
  ? createAllowAllRobotsPolicy()
535
- : createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts: options.allowPrivateHosts, signal: crawlSignal }));
567
+ : createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts, signal: crawlSignal }));
536
568
  await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
537
569
  // llms.txt fast path: an increasing number of doc sites publish a curated,
538
570
  // deduplicated link list at `/llms.txt` specifically for tools like this
@@ -545,7 +577,7 @@ async function crawlWebsite(startUrl, options) {
545
577
  // whole site's manifest.
546
578
  if (isOriginRootUrl(start)) {
547
579
  const manifest = await fetchLlmsManifest(start, robots, {
548
- allowPrivateHosts: options.allowPrivateHosts,
580
+ allowPrivateHosts,
549
581
  signal: crawlSignal,
550
582
  });
551
583
  if (manifest) {
@@ -623,7 +655,7 @@ async function crawlWebsite(startUrl, options) {
623
655
  deferred.delete(normalized);
624
656
  fetchAttempts++;
625
657
  const fetched = await fetchWebsitePage(decision.fetchUrl, {
626
- allowPrivateHosts: options.allowPrivateHosts,
658
+ allowPrivateHosts,
627
659
  robots,
628
660
  signal: crawlSignal,
629
661
  });
@@ -851,12 +883,18 @@ export async function loadRobotsTxt(robotsUrl, options) {
851
883
  }
852
884
  if (response.status >= 500 && response.status < 600) {
853
885
  await response.body?.cancel().catch(() => undefined);
854
- // RFC 9309 §2.3.1.4: an unreachable robots.txt is a full disallow, not
855
- // an allow-all. `fetchWithRetry` already retried this once, so a
856
- // transient blip does not trip it.
857
- warn("[akm] robots.txt at %s returned %d; treating the crawl as fully disallowed until it recovers. " +
858
- "Set respectRobots: false on this website source to bypass robots.txt.", robotsUrl, response.status);
859
- return { kind: "unreachable" };
886
+ // Guard-audit finding 12: RFC 9309 §2.3.1.4 recommends treating an
887
+ // unreachable robots.txt as a full disallow, but for a personal tool
888
+ // crawling a site the operator explicitly chose, a transient 5xx on
889
+ // the site's OWN robots.txt is not a site owner's Disallow it is the
890
+ // site being flaky. `fetchWithRetry` already retried this once, so
891
+ // this is not a single blip; still, blocking the crawl outright over
892
+ // it serves the operator worse than proceeding unrestricted. Reported
893
+ // as "unavailable" (the same outcome a 4xx gets), which resolves to
894
+ // ALLOW_ALL_RULES rather than DISALLOW_ALL_RULES.
895
+ warn("[akm] robots.txt at %s returned %d; proceeding as if no robots.txt were published, rather than treating " +
896
+ "the crawl as fully disallowed. Set respectRobots: false on this website source to silence this warning.", robotsUrl, response.status);
897
+ return { kind: "unavailable" };
860
898
  }
861
899
  // 4xx (404 is the common, silent case), and any other non-2xx/5xx status.
862
900
  await response.body?.cancel().catch(() => undefined);
@@ -12,20 +12,28 @@
12
12
  import { createHash } from "node:crypto";
13
13
  import path from "node:path";
14
14
  import { ConfigError, UsageError } from "../core/errors.js";
15
+ import { classifyNetworkHostname } from "../core/network-policy.js";
15
16
  import { getRegistryIndexCacheDir } from "../core/paths.js";
16
- import { assertWebsiteRequestUrl, isLoopbackWebsiteHostname, } from "./snapshot-fetchers/host-guard.js";
17
+ import { warnOnce } from "../core/warn.js";
18
+ import { assertWebsiteRequestUrl } from "./snapshot-fetchers/host-guard.js";
17
19
  export function shouldAllowPrivateWebsiteHostsForTests() {
18
20
  return process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
19
21
  }
20
22
  export function shouldAllowPrivateWebsiteUrlForTests(rawUrl) {
21
- if (!shouldAllowPrivateWebsiteHostsForTests())
22
- return false;
23
+ let hostname;
23
24
  try {
24
- return isLoopbackWebsiteHostname(new URL(rawUrl).hostname.toLowerCase());
25
+ hostname = new URL(rawUrl).hostname.toLowerCase();
25
26
  }
26
27
  catch {
27
28
  return false;
28
29
  }
30
+ if (classifyNetworkHostname(hostname) === "public")
31
+ return false;
32
+ if (!shouldAllowPrivateWebsiteHostsForTests()) {
33
+ warnOnce(`website-private-host:${hostname}`, `[akm] "${hostname}" is not a publicly routable host, but you added it as a source directly — proceeding. ` +
34
+ "Links discovered elsewhere are still checked.");
35
+ }
36
+ return true;
29
37
  }
30
38
  export function getWebsiteCachePaths(siteUrl, cacheRootOverride) {
31
39
  const key = createHash("sha256").update(normalizeSiteUrl(siteUrl)).digest("hex").slice(0, 16);
@@ -39,17 +39,28 @@ function inspectLedgerAgainst(db, registryIds) {
39
39
  const migrationIds = rows.map((row) => row.id);
40
40
  for (const [index, row] of rows.entries()) {
41
41
  const expectedId = registryIds[index];
42
+ // Every id this binary knows matched in order and the ledger carries more:
43
+ // the database was migrated by a newer akm. Nothing here is applicable —
44
+ // this binary's whole registry is already applied — so this is version
45
+ // skew, not divergence.
42
46
  if (!expectedId) {
43
- return { status: "newer", migrationIds, detail: `unknown migration ID ${row.id}` };
47
+ return {
48
+ status: "newer",
49
+ migrationIds,
50
+ detail: `applied migration ID${rows.length - registryIds.length === 1 ? "" : "s"} ${migrationIds
51
+ .slice(registryIds.length)
52
+ .join(", ")} unknown to this akm`,
53
+ };
44
54
  }
55
+ // A mismatch at a position this binary has a migration for is divergence,
56
+ // whether or not the id is one this binary knows later: this binary's
57
+ // migration at `index` was never applied, and something else was.
45
58
  if (row.id !== expectedId) {
46
- const knownLater = registryIds.includes(row.id);
47
59
  return {
48
- status: knownLater ? "inconsistent" : "newer",
60
+ status: "inconsistent",
49
61
  migrationIds,
50
- detail: knownLater
51
- ? `migration ledger is not an exact ordered prefix at position ${index + 1}`
52
- : `unknown migration ID ${row.id}`,
62
+ detail: `migration ledger is not an exact ordered prefix at position ${index + 1} (found '${row.id}', expected '${expectedId}'). ` +
63
+ `Applied, in order: [${migrationIds.join(", ")}]. This akm's expected order: [${registryIds.join(", ")}].`,
53
64
  };
54
65
  }
55
66
  }
@@ -62,13 +73,32 @@ export function inspectMigrationLedger(db, migrations) {
62
73
  assertMigrationRegistry(migrations);
63
74
  return inspectLedgerAgainst(db, migrations.map((migration) => migration.id));
64
75
  }
76
+ /**
77
+ * Reject only a ledger this binary cannot reason about at all.
78
+ *
79
+ * A `newer` ledger — an exact ordered prefix of this binary's registry plus
80
+ * migrations a later akm added — is NOT rejected. Two akm versions sharing one
81
+ * data directory is a supported deployment (a bundled CLI alongside a newer
82
+ * global install), and refusing the open bricked the older one for every
83
+ * command while protecting nothing: its entire registry is already applied, so
84
+ * it has no pending migration to run. Callers that want to tell an operator
85
+ * about the skew read {@link MigrationLedgerState.status}.
86
+ *
87
+ * A `inconsistent` ledger is different: this binary has a migration that was
88
+ * never applied and something else was applied in its place, so running the
89
+ * pending set could conflict with schema it cannot see. That still refuses.
90
+ */
65
91
  export function assertMigrationLedger(db, migrations) {
66
92
  const state = inspectMigrationLedger(db, migrations);
67
- if (state.status === "newer") {
68
- throw new Error(`Refusing to open a database with a newer migration ledger: ${state.detail}.`);
69
- }
70
93
  if (state.status === "inconsistent") {
71
- throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail}.`);
94
+ throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail} ` +
95
+ "Applying this binary's missing migration now could run it against a schema a later migration already " +
96
+ "changed underneath it, which is a real risk of producing a wrong schema — not something akm can guess " +
97
+ "its way out of safely. This usually means the database was migrated by an incompatible akm build or " +
98
+ "fork, or schema_migrations was edited by hand. Restore this file from a backup taken before the " +
99
+ "divergence, or — if there is no backup and the data is not needed — delete it and let akm rebuild it " +
100
+ "from scratch (a derived index.db regenerates from your sources on the next 'akm index'; state.db loses " +
101
+ "durable history such as improve/proposal state and must be treated as a last resort).");
72
102
  }
73
103
  return state;
74
104
  }
@@ -0,0 +1,7 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /** Escape `%`, `_`, and `\\` so a LIKE pattern matches `text` literally; pair with `ESCAPE '\\'`. */
5
+ export function escapeLikePattern(text) {
6
+ return text.replace(/[\\%_]/g, (ch) => `\\${ch}`);
7
+ }
@@ -73,6 +73,29 @@ export function getLastExtractRunAt(db, harness) {
73
73
  const ms = Date.parse(row.last);
74
74
  return Number.isFinite(ms) ? ms : null;
75
75
  }
76
+ /**
77
+ * #914: outcome counts from the `extract_sessions_seen` ledger since
78
+ * `sinceIso`, grouped by harness/outcome/skipReason/engine. This is what lets
79
+ * `akm health`'s `session-extraction` check derive its verdict from what
80
+ * standalone `akm proposal extract` actually recorded — the only write path
81
+ * the hook-driven SessionEnd extraction uses (`improve_runs` is never
82
+ * populated by it). `skipReason`/`engine` are read from `metadata_json` via
83
+ * `json_extract` and come back `null` both for a non-skip outcome and for a
84
+ * row a release before #912/#913 wrote (no key at that path) — callers must
85
+ * treat a `null` reason as "skipped, reason unknown", not as evidence of a
86
+ * specific cause.
87
+ */
88
+ export function getExtractOutcomeCountsSince(db, sinceIso) {
89
+ return db
90
+ .prepare(`SELECT harness, outcome,
91
+ json_extract(metadata_json, '$.skipReason') AS skipReason,
92
+ json_extract(metadata_json, '$.engine') AS engine,
93
+ COUNT(*) AS count
94
+ FROM extract_sessions_seen
95
+ WHERE processed_at >= ?
96
+ GROUP BY harness, outcome, skipReason, engine`)
97
+ .all(sinceIso);
98
+ }
76
99
  /**
77
100
  * Decide whether a session should be skipped because the extractor has already
78
101
  * processed BYTE-IDENTICAL content (#602). The skip authority is the content