akm-cli 0.7.5 → 0.8.0-rc.6

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 (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -1,3 +1,6 @@
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/.
1
4
  /**
2
5
  * Vault asset type — secret storage backed by `.env` files.
3
6
  *
@@ -15,6 +18,57 @@
15
18
  import fs from "node:fs";
16
19
  import path from "node:path";
17
20
  import dotenv from "dotenv";
21
+ import { writeFileAtomic } from "../core/common";
22
+ import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock";
23
+ // ── Write-lock helper ─────────────────────────────────────────────────────────
24
+ /**
25
+ * Acquire an exclusive lock file for the given vault path, execute `fn`, then
26
+ * release the lock. Uses O_EXCL creation so two concurrent writers cannot
27
+ * both acquire the lock simultaneously (POSIX atomic guarantee).
28
+ *
29
+ * Retry strategy: if the lock is held we spin for up to 5 s, yielding via
30
+ * `Bun.sleepSync` when available (avoids burning 100 % CPU), otherwise falling
31
+ * back to a busy-wait counter. After the deadline we throw rather than
32
+ * silently proceeding — a timeout here is always a programming error or a
33
+ * stale lock left by a crashed process.
34
+ *
35
+ * Stale lock detection: PID is written into the lock file on acquisition
36
+ * and inspected via `probeLock` when a lock-acquire attempt fails.
37
+ */
38
+ function withVaultLock(vaultPath, fn) {
39
+ const lockPath = `${vaultPath}.lock`;
40
+ const deadline = Date.now() + 5000;
41
+ while (!tryAcquireLockSync(lockPath, String(process.pid))) {
42
+ const probe = probeLock(lockPath);
43
+ if (probe.state === "stale") {
44
+ releaseLock(lockPath);
45
+ continue;
46
+ }
47
+ if (Date.now() > deadline) {
48
+ const holderHint = probe.state === "held"
49
+ ? ` Lock file ${lockPath} is held by live PID ${probe.holderPid}.`
50
+ : ` Lock file ${lockPath} could not be inspected.`;
51
+ throw new Error(`Could not acquire vault lock for ${vaultPath} after 5s.${holderHint} Retry once any other akm vault operation finishes, or remove the stale lock file.`);
52
+ }
53
+ // Yield before next attempt
54
+ if (typeof globalThis.Bun?.sleepSync ===
55
+ "function") {
56
+ globalThis.Bun.sleepSync(10);
57
+ }
58
+ else {
59
+ let spin = 0;
60
+ while (spin++ < 100_000) {
61
+ /* yield */
62
+ }
63
+ }
64
+ }
65
+ try {
66
+ return fn();
67
+ }
68
+ finally {
69
+ releaseLock(lockPath);
70
+ }
71
+ }
18
72
  /** Matches a KEY=value assignment line, capturing only the key. */
19
73
  const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
20
74
  /** Scan lines and return KEY names in file order, without duplicates. */
@@ -175,85 +229,106 @@ export function buildShellExportScript(vaultPath) {
175
229
  */
176
230
  export function setKey(vaultPath, key, value, comment) {
177
231
  validateKeyName(key);
232
+ if (comment !== undefined && /[\r\n]/.test(comment)) {
233
+ throw new Error("Vault key comment cannot contain newline characters.");
234
+ }
178
235
  ensureParentDir(vaultPath);
179
- const existing = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, "utf8") : "";
180
- const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
181
- const formatted = `${key}=${quoteValue(value)}`;
182
- let replaced = false;
183
- for (let i = 0; i < lines.length; i++) {
184
- const m = lines[i].match(ASSIGN_RE);
185
- if (m && m[1] === key) {
186
- lines[i] = formatted;
187
- replaced = true;
236
+ withVaultLock(vaultPath, () => {
237
+ const existing = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, "utf8") : "";
238
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
239
+ const formatted = `${key}=${quoteValue(value)}`;
240
+ let replaced = false;
241
+ for (let i = 0; i < lines.length; i++) {
242
+ const m = lines[i].match(ASSIGN_RE);
243
+ if (m && m[1] === key) {
244
+ lines[i] = formatted;
245
+ replaced = true;
246
+ if (comment !== undefined) {
247
+ const commentLine = `# ${comment}`;
248
+ const prevIsComment = i > 0 && lines[i - 1].trimStart().startsWith("#");
249
+ if (prevIsComment) {
250
+ lines[i - 1] = commentLine;
251
+ }
252
+ else {
253
+ lines.splice(i, 0, commentLine);
254
+ }
255
+ }
256
+ break;
257
+ }
258
+ }
259
+ if (!replaced) {
188
260
  if (comment !== undefined) {
189
261
  const commentLine = `# ${comment}`;
190
- const prevIsComment = i > 0 && lines[i - 1].trimStart().startsWith("#");
191
- if (prevIsComment) {
192
- lines[i - 1] = commentLine;
262
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
263
+ lines[lines.length - 1] = commentLine;
264
+ lines.push(formatted);
265
+ lines.push("");
193
266
  }
194
267
  else {
195
- lines.splice(i, 0, commentLine);
268
+ lines.push(commentLine);
269
+ lines.push(formatted);
196
270
  }
197
271
  }
198
- break;
199
- }
200
- }
201
- if (!replaced) {
202
- if (comment !== undefined) {
203
- const commentLine = `# ${comment}`;
204
- if (lines.length > 0 && lines[lines.length - 1] === "") {
205
- lines[lines.length - 1] = commentLine;
206
- lines.push(formatted);
272
+ else if (lines.length > 0 && lines[lines.length - 1] === "") {
273
+ lines[lines.length - 1] = formatted;
207
274
  lines.push("");
208
275
  }
209
276
  else {
210
- lines.push(commentLine);
211
277
  lines.push(formatted);
212
278
  }
213
279
  }
214
- else if (lines.length > 0 && lines[lines.length - 1] === "") {
215
- lines[lines.length - 1] = formatted;
216
- lines.push("");
217
- }
218
- else {
219
- lines.push(formatted);
220
- }
221
- }
222
- let out = lines.join("\n");
223
- if (!out.endsWith("\n"))
224
- out += "\n";
225
- writeFileAtomic(vaultPath, out);
280
+ let out = lines.join("\n");
281
+ if (!out.endsWith("\n"))
282
+ out += "\n";
283
+ writeFileAtomic(vaultPath, out, 0o600);
284
+ });
226
285
  }
227
286
  /** Remove a key from the vault file. Returns true if the key was present. */
228
287
  export function unsetKey(vaultPath, key) {
229
288
  if (!fs.existsSync(vaultPath))
230
289
  return false;
231
- const text = fs.readFileSync(vaultPath, "utf8");
232
- const lines = text.split(/\r?\n/);
233
- const kept = [];
234
- let removed = false;
235
- for (const line of lines) {
236
- const m = line.match(ASSIGN_RE);
237
- if (m && m[1] === key) {
238
- removed = true;
239
- continue;
290
+ return withVaultLock(vaultPath, () => {
291
+ const text = fs.readFileSync(vaultPath, "utf8");
292
+ const lines = text.split(/\r?\n/);
293
+ let keyLineIdx = -1;
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const m = lines[i].match(ASSIGN_RE);
296
+ if (m && m[1] === key) {
297
+ keyLineIdx = i;
298
+ break;
299
+ }
240
300
  }
241
- kept.push(line);
242
- }
243
- if (!removed)
244
- return false;
245
- let out = kept.join("\n");
246
- if (out.length > 0 && !out.endsWith("\n"))
247
- out += "\n";
248
- writeFileAtomic(vaultPath, out);
249
- return true;
301
+ if (keyLineIdx === -1)
302
+ return false;
303
+ // Determine how many consecutive comment lines immediately precede the key.
304
+ // We walk backwards, skipping only comment lines (lines matching /^\s*#/).
305
+ // A blank line between a comment and the key breaks the association — we
306
+ // stop at the first non-comment, non-assignment line (including blank lines).
307
+ let commentStart = keyLineIdx;
308
+ for (let i = keyLineIdx - 1; i >= 0; i--) {
309
+ if (/^\s*#/.test(lines[i])) {
310
+ commentStart = i;
311
+ }
312
+ else {
313
+ // Stop at the first non-comment line (blank or assignment)
314
+ break;
315
+ }
316
+ }
317
+ // Remove from commentStart through keyLineIdx (inclusive).
318
+ lines.splice(commentStart, keyLineIdx - commentStart + 1);
319
+ let out = lines.join("\n");
320
+ if (out.length > 0 && !out.endsWith("\n"))
321
+ out += "\n";
322
+ writeFileAtomic(vaultPath, out, 0o600);
323
+ return true;
324
+ });
250
325
  }
251
326
  /** Create an empty vault file (does nothing if it already exists). */
252
327
  export function createVault(vaultPath) {
253
328
  ensureParentDir(vaultPath);
254
329
  if (fs.existsSync(vaultPath))
255
330
  return;
256
- writeFileAtomic(vaultPath, "");
331
+ writeFileAtomic(vaultPath, "", 0o600);
257
332
  }
258
333
  /**
259
334
  * Characters that are safe in an UNquoted dotenv value AND are not
@@ -302,27 +377,5 @@ function validateKeyName(key) {
302
377
  function ensureParentDir(filePath) {
303
378
  const dir = path.dirname(filePath);
304
379
  if (!fs.existsSync(dir))
305
- fs.mkdirSync(dir, { recursive: true });
306
- }
307
- function writeFileAtomic(filePath, content) {
308
- const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
309
- try {
310
- fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
311
- fs.renameSync(tmp, filePath);
312
- try {
313
- fs.chmodSync(filePath, 0o600);
314
- }
315
- catch {
316
- /* best-effort on platforms without chmod */
317
- }
318
- }
319
- catch (err) {
320
- try {
321
- fs.unlinkSync(tmp);
322
- }
323
- catch {
324
- /* ignore cleanup failure */
325
- }
326
- throw err;
327
- }
380
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
328
381
  }
@@ -0,0 +1,28 @@
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
+ import { defaultRendererRegistry } from "./asset-registry";
5
+ function registryActionContributor(registry) {
6
+ return {
7
+ name: "registry-action-contributor",
8
+ appliesTo(ctx) {
9
+ return registry.actionBuilderFor(ctx.type) !== undefined;
10
+ },
11
+ buildAction(ctx) {
12
+ return registry.actionBuilderFor(ctx.type)?.(ctx.ref);
13
+ },
14
+ };
15
+ }
16
+ export function defaultActionContributors(registry = defaultRendererRegistry) {
17
+ return [registryActionContributor(registry)];
18
+ }
19
+ export function buildActionFromContributors(ctx, contributors = defaultActionContributors()) {
20
+ for (const contributor of contributors) {
21
+ if (!contributor.appliesTo(ctx))
22
+ continue;
23
+ const action = contributor.buildAction(ctx);
24
+ if (action !== undefined)
25
+ return action;
26
+ }
27
+ return undefined;
28
+ }
@@ -1,3 +1,6 @@
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/.
1
4
  import path from "node:path";
2
5
  import { isAssetType } from "./common";
3
6
  import { UsageError } from "./errors";
@@ -67,6 +70,10 @@ function validateName(name) {
67
70
  if (normalized === ".." || normalized.startsWith("../")) {
68
71
  throw new UsageError("Path traversal in asset name.", "MISSING_REQUIRED_ARGUMENT");
69
72
  }
73
+ const segments = normalized.split("/");
74
+ if (segments.some((seg) => seg === "." || seg === "..")) {
75
+ throw new UsageError("Asset name cannot contain relative path segments.", "MISSING_REQUIRED_ARGUMENT");
76
+ }
70
77
  }
71
78
  function normalizeName(name) {
72
79
  return path.posix.normalize(name.replace(/\\/g, "/"));
@@ -1,3 +1,6 @@
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/.
1
4
  /**
2
5
  * Central registry for asset type renderer and action builder maps.
3
6
  *
@@ -18,10 +21,12 @@ export const TYPE_TO_RENDERER = {
18
21
  command: "command-md",
19
22
  agent: "agent-md",
20
23
  knowledge: "knowledge-md",
24
+ lesson: "lesson-md",
21
25
  memory: "memory-md",
22
26
  workflow: "workflow-md",
23
27
  vault: "vault-env",
24
28
  wiki: "wiki-md",
29
+ task: "task-yaml",
25
30
  };
26
31
  /** Map asset types to action builder functions for search results. */
27
32
  export const ACTION_BUILDERS = {
@@ -30,10 +35,12 @@ export const ACTION_BUILDERS = {
30
35
  command: (ref) => `akm show ${ref} -> fill placeholders and dispatch`,
31
36
  agent: (ref) => `akm show ${ref} -> dispatch with full prompt`,
32
37
  knowledge: (ref) => `akm show ${ref} -> read reference material`,
38
+ lesson: (ref) => `akm show ${ref} -> read the lesson and apply when_to_use`,
33
39
  memory: (ref) => `akm show ${ref} -> recall context`,
34
40
  workflow: (ref) => buildWorkflowAction(ref),
35
41
  vault: (ref) => `akm show ${ref} -> inspect keys; source "$(akm vault path ${ref})" -> load values; akm vault run ${ref} -- <command> -> run with injected env`,
36
42
  wiki: (ref) => `akm show ${ref} -> read the wiki page`,
43
+ task: (ref) => `akm tasks show ${ref.replace(/^task:/, "")} -> inspect; akm tasks run <id> -> run now; akm tasks remove <id> -> unschedule`,
37
44
  };
38
45
  /**
39
46
  * Register a type-to-renderer mapping.
@@ -61,19 +68,3 @@ export const defaultRendererRegistry = {
61
68
  return ACTION_BUILDERS[type];
62
69
  },
63
70
  };
64
- /**
65
- * Build a registry from explicit maps. Useful for tests that need to assert
66
- * rendering behavior without touching the global singletons.
67
- */
68
- export function createRendererRegistry(maps) {
69
- const renderers = maps.renderers ?? {};
70
- const actionBuilders = maps.actionBuilders ?? {};
71
- return {
72
- rendererNameFor(type) {
73
- return renderers[type];
74
- },
75
- actionBuilderFor(type) {
76
- return actionBuilders[type];
77
- },
78
- };
79
- }
@@ -0,0 +1,88 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Canonical asset-on-disk serialization.
6
+ *
7
+ * Before this module, 9+ call sites across `src/` independently reimplemented
8
+ * `yamlStringify(fm).trimEnd() + "---\n…\n---\n\n${body}"` to assemble a
9
+ * Markdown asset. The reimplementations drifted (different body normalization,
10
+ * different separator newlines, different trailing-newline policy), which is
11
+ * exactly the kind of silent format-shift the proposal-quality validators end
12
+ * up chasing downstream. This file is the single point of truth for
13
+ * "what does a well-formed AKM asset look like on disk".
14
+ *
15
+ * Two helpers are exported:
16
+ * - `serializeFrontmatter(fm)` — YAML for the frontmatter block only, with
17
+ * no `---` fences and no trailing newline. Single home for quoting style,
18
+ * field-order policy, and trailing-whitespace rules.
19
+ * - `assembleAsset(fm, body)` — frontmatter wrapped in `---` fences with a
20
+ * blank line between the closing fence and the body, and exactly one
21
+ * trailing `\n`. Single home for body normalization and the file-shape
22
+ * contract.
23
+ *
24
+ * Contract (must hold for the dedup to be safe):
25
+ * - Idempotent: `parseFrontmatter(assembleAsset(fm, body))` re-assembled
26
+ * reproduces the same bytes.
27
+ * - Field order: insertion order of `fm` is preserved (the caller controls
28
+ * ordering; the helper never reorders).
29
+ * - Quoting: `yaml.stringify` defaults — no custom quoting logic.
30
+ * - Trailing newline: exactly one `\n` at end of output.
31
+ * - Body normalization: leading newlines are stripped (`/^\n+/`). This
32
+ * collapses the assorted `body.replace(/^\n+/, "")` /
33
+ * `body.startsWith("\n") ? "" : "\n" + body` / bare `${body}` patterns
34
+ * onto the most aggressive existing normalizer.
35
+ */
36
+ import { stringify as yamlStringify } from "yaml";
37
+ /**
38
+ * Serialize a frontmatter object to its on-disk YAML form, without `---`
39
+ * fences and without a trailing newline.
40
+ *
41
+ * Two calls with the same input produce byte-identical output. Field order is
42
+ * preserved from the input object's insertion order — callers control
43
+ * ordering, the helper never reorders.
44
+ */
45
+ export function serializeFrontmatter(frontmatter) {
46
+ return yamlStringify(frontmatter).trimEnd();
47
+ }
48
+ /**
49
+ * Assemble a complete asset file string from a frontmatter object and a body.
50
+ *
51
+ * Output shape: `---\n<yaml>\n---\n\n<body>\n` where:
52
+ * - `<yaml>` is `serializeFrontmatter(frontmatter)`.
53
+ * - `<body>` has any leading `\n` characters stripped.
54
+ * - Exactly one `\n` terminates the file.
55
+ *
56
+ * Idempotent under round-trip through the project's `parseFrontmatter`.
57
+ */
58
+ export function assembleAsset(frontmatter, body) {
59
+ return assembleAssetFromString(serializeFrontmatter(frontmatter), body);
60
+ }
61
+ /**
62
+ * Same fence/body assembly as `assembleAsset` but takes a pre-serialized
63
+ * frontmatter string. Use this when a caller needs its own frontmatter
64
+ * serializer (e.g. defensive single-line flattening for untrusted LLM
65
+ * output, or JSON.stringify-per-value for guaranteed-quoted scalars) while
66
+ * still sharing the canonical fence-and-body template.
67
+ *
68
+ * The `serializedFm` argument must already match `serializeFrontmatter`'s
69
+ * contract: no `---` fences, no trailing newline. Trailing whitespace is
70
+ * trimmed defensively.
71
+ *
72
+ * Output contract — identical to `assembleAsset`:
73
+ * - `---\n<serializedFm>\n---\n\n<body>\n`
74
+ * - body has leading `\n` characters stripped
75
+ * - exactly one `\n` terminates the file
76
+ *
77
+ * This helper is the single point of truth for the fence-and-body template.
78
+ * Three command surfaces (`reflect`, `distill`, `consolidate`) call it
79
+ * directly because their inputs are pre-validated LLM payloads where the
80
+ * full `yamlStringify` may emit shapes (`|`-block scalars, anchors) that
81
+ * the project's hand-rolled `parseFrontmatter` subset parser cannot read.
82
+ */
83
+ export function assembleAssetFromString(serializedFm, body) {
84
+ const yaml = serializedFm.replace(/\s+$/, "");
85
+ const normalizedBody = body.replace(/^\n+/, "");
86
+ const withTrailingNewline = normalizedBody.endsWith("\n") ? normalizedBody : `${normalizedBody}\n`;
87
+ return `---\n${yaml}\n---\n\n${withTrailingNewline}`;
88
+ }
@@ -1,7 +1,11 @@
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/.
1
4
  import path from "node:path";
2
5
  import { buildWorkflowAction } from "../output/renderers";
3
6
  import { registerActionBuilder, registerTypeRenderer } from "./asset-registry";
4
7
  import { toPosix } from "./common";
8
+ const buildTaskAction = (ref) => `akm tasks show ${ref.replace(/^task:/, "")} -> inspect; akm tasks run <id> -> run now; akm tasks remove <id> -> unschedule`;
5
9
  const markdownSpec = {
6
10
  isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".md",
7
11
  toCanonicalName: (typeRoot, filePath) => {
@@ -102,6 +106,24 @@ const ASSET_SPECS_INTERNAL = {
102
106
  rendererName: "lesson-md",
103
107
  actionBuilder: (ref) => `akm show ${ref} -> read the lesson and apply when_to_use`,
104
108
  },
109
+ // Scheduled tasks. A task file pairs a cron-style schedule with a target
110
+ // (workflow ref, prompt, or command) that `akm tasks` registers with the
111
+ // OS-native scheduler (cron / launchd / schtasks). Stored as pure YAML
112
+ // under <stash>/tasks/<id>.yml.
113
+ task: {
114
+ stashDir: "tasks",
115
+ isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".yml",
116
+ toCanonicalName: (typeRoot, filePath) => {
117
+ const rel = toPosix(path.relative(typeRoot, filePath));
118
+ return rel.endsWith(".yml") ? rel.slice(0, -4) : rel;
119
+ },
120
+ toAssetPath: (typeRoot, name) => {
121
+ const withExt = name.endsWith(".yml") ? name : `${name}.yml`;
122
+ return path.join(typeRoot, withExt);
123
+ },
124
+ rendererName: "task-yaml",
125
+ actionBuilder: buildTaskAction,
126
+ },
105
127
  };
106
128
  export const ASSET_SPECS = ASSET_SPECS_INTERNAL;
107
129
  /**
@@ -1,3 +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
+ import crypto from "node:crypto";
1
5
  import fs from "node:fs";
2
6
  import path from "node:path";
3
7
  import { TYPE_DIRS } from "./asset-spec";
@@ -8,6 +12,20 @@ export const IS_WINDOWS = process.platform === "win32";
8
12
  export function isHttpUrl(value) {
9
13
  return !!value && /^https?:\/\//.test(value);
10
14
  }
15
+ /**
16
+ * Returns `true` when `value` looks like a remote URL that a VCS or HTTP
17
+ * fetch can access. Covers http/https, git@, ssh://, and git:// schemes.
18
+ * Consolidates the repeated inline URL-detection pattern in source-manage.ts.
19
+ */
20
+ export function isRemoteUrl(value) {
21
+ if (!value)
22
+ return false;
23
+ return (value.startsWith("http://") ||
24
+ value.startsWith("https://") ||
25
+ value.startsWith("git@") ||
26
+ value.startsWith("ssh://") ||
27
+ value.startsWith("git://"));
28
+ }
11
29
  export function filterNonEmptyStrings(value) {
12
30
  if (!Array.isArray(value))
13
31
  return undefined;
@@ -18,6 +36,56 @@ export function isAssetType(type) {
18
36
  return Object.hasOwn(TYPE_DIRS, type);
19
37
  }
20
38
  // ── Utilities ───────────────────────────────────────────────────────────────
39
+ /**
40
+ * Write content to a file atomically via a temp file + rename.
41
+ * Prevents partial-write corruption on crash.
42
+ * The temp file is opened with the target `mode` (default 0o600) from the
43
+ * start, so it is never world-readable even briefly.
44
+ *
45
+ * Durability: fsync'd against the May 2026 config-clobber incident (#472).
46
+ * On ext4 (data=ordered) and NVMe-with-TRIM, a power-loss inside the kernel
47
+ * writeback window could leave the renamed file truncated to zero — defeating
48
+ * the purpose of the atomic rename. We:
49
+ * 1. fdatasync the temp fd before close, so the data is on disk before the
50
+ * rename observes it.
51
+ * 2. fsync the parent directory after rename, so the directory entry change
52
+ * is durable too. Some filesystems (FAT, certain FUSE mounts) don't
53
+ * support directory fsync; we ignore EINVAL/ENOTSUP so atomic writes
54
+ * don't fail on exotic mounts.
55
+ */
56
+ export function writeFileAtomic(target, content, mode) {
57
+ const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
58
+ const fd = fs.openSync(tmp, "w", mode ?? 0o600);
59
+ try {
60
+ fs.writeSync(fd, content);
61
+ try {
62
+ fs.fdatasyncSync(fd);
63
+ }
64
+ catch {
65
+ // Best-effort: some pseudo-filesystems lack fdatasync. Fall through
66
+ // to closeSync — the rename below still preserves atomicity even if
67
+ // the data isn't durable, and the calling code's retry will recover.
68
+ }
69
+ }
70
+ finally {
71
+ fs.closeSync(fd);
72
+ }
73
+ fs.renameSync(tmp, target);
74
+ try {
75
+ const dirFd = fs.openSync(path.dirname(target), "r");
76
+ try {
77
+ fs.fsyncSync(dirFd);
78
+ }
79
+ finally {
80
+ fs.closeSync(dirFd);
81
+ }
82
+ }
83
+ catch {
84
+ // Directory fsync is unsupported on FAT, some FUSE mounts, and Windows
85
+ // (where directories cannot be opened for read like POSIX). Silently
86
+ // ignore so writeFileAtomic remains portable.
87
+ }
88
+ }
21
89
  /**
22
90
  * Resolve the stash directory using a three-level fallback chain:
23
91
  * 1. AKM_STASH_DIR environment variable (override for CI/scripts)
@@ -329,3 +397,92 @@ function parseRetryAfter(response) {
329
397
  export function toErrorMessage(error) {
330
398
  return error instanceof Error ? error.message : String(error);
331
399
  }
400
+ // ── Date / timestamp utilities ───────────────────────────────────────────────
401
+ /**
402
+ * Return today's date in ISO-8601 format (`YYYY-MM-DD`).
403
+ * Consolidates the `new Date().toISOString().slice(0, 10)` pattern that
404
+ * appears at multiple call sites.
405
+ */
406
+ export function todayIso() {
407
+ return new Date().toISOString().slice(0, 10);
408
+ }
409
+ /**
410
+ * Return a filesystem-safe timestamp string derived from the current instant.
411
+ * Colons and dots are replaced with hyphens so the result is safe as a
412
+ * filename component on all platforms (e.g. `2024-01-15T10-30-00-000Z`).
413
+ */
414
+ export function timestampForFilename() {
415
+ return new Date().toISOString().replace(/[:.]/g, "-");
416
+ }
417
+ // ── String coercion ──────────────────────────────────────────────────────────
418
+ /**
419
+ * Return the trimmed string value if non-empty, otherwise `undefined`.
420
+ * Consolidates `toStringOrUndefined` (frontmatter.ts), `asNonEmptyString`
421
+ * (config.ts), and `firstString` (memory-improve.ts) — all had the same
422
+ * "return a string or undefined" contract with minor semantic differences.
423
+ */
424
+ export function asNonEmptyString(value) {
425
+ if (typeof value !== "string")
426
+ return undefined;
427
+ const trimmed = value.trim();
428
+ return trimmed.length > 0 ? trimmed : undefined;
429
+ }
430
+ // ── Generic data utilities ───────────────────────────────────────────────────
431
+ /**
432
+ * Return the trimmed string if non-empty, otherwise `undefined`.
433
+ * Equivalent to `firstString` previously defined in `memory-improve.ts`.
434
+ */
435
+ export function firstString(value) {
436
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
437
+ }
438
+ /**
439
+ * Coerce an unknown value to a filtered, trimmed string array.
440
+ * Non-strings and empty/whitespace-only entries are dropped.
441
+ */
442
+ export function stringArray(value) {
443
+ if (!Array.isArray(value))
444
+ return [];
445
+ const out = [];
446
+ for (const item of value) {
447
+ if (typeof item === "string" && item.trim().length > 0)
448
+ out.push(item.trim());
449
+ }
450
+ return out;
451
+ }
452
+ /**
453
+ * Group an array of values by a string key derived from each element.
454
+ * Returns a `Map` so insertion order within each group is preserved.
455
+ */
456
+ /**
457
+ * Return true if a process with the given PID is currently alive.
458
+ * Uses `process.kill(pid, 0)` which does not deliver a signal but
459
+ * throws ESRCH when the process does not exist.
460
+ */
461
+ export function isProcessAlive(pid) {
462
+ try {
463
+ process.kill(pid, 0);
464
+ return true;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
470
+ /**
471
+ * Convert a number of days to milliseconds. Consolidates the
472
+ * `N * 24 * 60 * 60 * 1000` pattern used throughout the cooldown logic.
473
+ */
474
+ export function daysToMs(days) {
475
+ return days * 86_400_000;
476
+ }
477
+ export function groupBy(values, keyFn) {
478
+ const groups = new Map();
479
+ for (const value of values) {
480
+ const key = keyFn(value);
481
+ const existing = groups.get(key);
482
+ if (existing)
483
+ existing.push(value);
484
+ else
485
+ groups.set(key, [value]);
486
+ }
487
+ return groups;
488
+ }