akm-cli 0.7.5 → 0.8.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +86 -0
  3. package/dist/cli.js +1023 -521
  4. package/dist/commands/agent-dispatch.js +107 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +812 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +218 -43
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1161 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +291 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +145 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/vault-key-rules.js +67 -0
  38. package/dist/commands/lint/workflow-linter.js +53 -0
  39. package/dist/commands/lint.js +1 -0
  40. package/dist/commands/proposal.js +8 -7
  41. package/dist/commands/propose.js +71 -28
  42. package/dist/commands/reflect.js +135 -35
  43. package/dist/commands/registry-search.js +2 -2
  44. package/dist/commands/remember.js +54 -0
  45. package/dist/commands/schema-repair.js +130 -0
  46. package/dist/commands/search.js +21 -5
  47. package/dist/commands/show.js +125 -20
  48. package/dist/commands/source-add.js +10 -10
  49. package/dist/commands/source-manage.js +11 -19
  50. package/dist/commands/tasks.js +385 -0
  51. package/dist/commands/url-checker.js +39 -0
  52. package/dist/commands/vault.js +168 -77
  53. package/dist/core/action-contributors.js +25 -0
  54. package/dist/core/asset-ref.js +4 -0
  55. package/dist/core/asset-registry.js +4 -16
  56. package/dist/core/asset-spec.js +10 -0
  57. package/dist/core/common.js +100 -0
  58. package/dist/core/concurrent.js +22 -0
  59. package/dist/core/config.js +233 -133
  60. package/dist/core/events.js +73 -126
  61. package/dist/core/frontmatter.js +0 -6
  62. package/dist/core/markdown.js +17 -0
  63. package/dist/core/memory-improve.js +678 -0
  64. package/dist/core/parse.js +155 -0
  65. package/dist/core/paths.js +101 -3
  66. package/dist/core/proposal-validators.js +61 -0
  67. package/dist/core/proposals.js +49 -38
  68. package/dist/core/state-db.js +731 -0
  69. package/dist/core/time.js +51 -0
  70. package/dist/core/warn.js +59 -1
  71. package/dist/indexer/db-search.js +52 -238
  72. package/dist/indexer/db.js +403 -54
  73. package/dist/indexer/ensure-index.js +61 -0
  74. package/dist/indexer/graph-boost.js +247 -94
  75. package/dist/indexer/graph-db.js +201 -0
  76. package/dist/indexer/graph-dedup.js +99 -0
  77. package/dist/indexer/graph-extraction.js +409 -76
  78. package/dist/indexer/index-context.js +10 -0
  79. package/dist/indexer/indexer.js +456 -290
  80. package/dist/indexer/llm-cache.js +47 -0
  81. package/dist/indexer/matchers.js +124 -160
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +196 -197
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/builders.js +109 -0
  93. package/dist/integrations/agent/config.js +203 -3
  94. package/dist/integrations/agent/index.js +5 -2
  95. package/dist/integrations/agent/model-aliases.js +63 -0
  96. package/dist/integrations/agent/profiles.js +67 -5
  97. package/dist/integrations/agent/prompts.js +77 -72
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +93 -22
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +220 -256
  116. package/dist/output/shapes.js +101 -93
  117. package/dist/output/text.js +256 -17
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/registry/resolve.js +8 -16
  121. package/dist/setup/setup.js +510 -11
  122. package/dist/sources/provider-factory.js +2 -1
  123. package/dist/sources/providers/filesystem.js +16 -23
  124. package/dist/sources/providers/git.js +4 -5
  125. package/dist/sources/providers/website.js +15 -22
  126. package/dist/sources/website-ingest.js +4 -0
  127. package/dist/tasks/backends/cron.js +200 -0
  128. package/dist/tasks/backends/exec-utils.js +25 -0
  129. package/dist/tasks/backends/index.js +32 -0
  130. package/dist/tasks/backends/launchd-template.xml +19 -0
  131. package/dist/tasks/backends/launchd.js +184 -0
  132. package/dist/tasks/backends/schtasks-template.xml +29 -0
  133. package/dist/tasks/backends/schtasks.js +212 -0
  134. package/dist/tasks/parser.js +198 -0
  135. package/dist/tasks/resolveAkmBin.js +84 -0
  136. package/dist/tasks/runner.js +432 -0
  137. package/dist/tasks/schedule.js +208 -0
  138. package/dist/tasks/schema.js +13 -0
  139. package/dist/tasks/validator.js +59 -0
  140. package/dist/wiki/index-template.md +12 -0
  141. package/dist/wiki/ingest-workflow-template.md +54 -0
  142. package/dist/wiki/log-template.md +8 -0
  143. package/dist/wiki/schema-template.md +61 -0
  144. package/dist/wiki/wiki-templates.js +12 -0
  145. package/dist/wiki/wiki.js +10 -61
  146. package/dist/workflows/authoring.js +5 -25
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +59 -91
  149. package/dist/workflows/validator.js +1 -1
  150. package/dist/workflows/workflow-template.md +24 -0
  151. package/docs/README.md +5 -2
  152. package/docs/migration/release-notes/0.7.0.md +1 -1
  153. package/docs/migration/release-notes/0.8.0.md +43 -0
  154. package/package.json +3 -2
  155. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,291 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // ── Helpers ───────────────────────────────────────────────────────────────────
4
+ function formatDate(d) {
5
+ const y = d.getFullYear();
6
+ const m = String(d.getMonth() + 1).padStart(2, "0");
7
+ const day = String(d.getDate()).padStart(2, "0");
8
+ return `${y}-${m}-${day}`;
9
+ }
10
+ function checkUnquotedColon(frontmatterText) {
11
+ if (!frontmatterText)
12
+ return null;
13
+ for (const line of frontmatterText.split(/\r?\n/)) {
14
+ const match = line.match(/^description:\s*(.*)/);
15
+ if (!match)
16
+ continue;
17
+ const value = match[1].trim();
18
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
19
+ return null;
20
+ }
21
+ if (value.includes(":")) {
22
+ return `description value contains unquoted colon: ${value}`;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ function fixUnquotedColon(raw) {
28
+ return raw.replace(/^(description:\s*)(.*)/m, (_match, prefix, value) => {
29
+ const trimmed = value.trim();
30
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
31
+ return _match;
32
+ }
33
+ const escaped = trimmed.replace(/"/g, '\\"');
34
+ return `${prefix}"${escaped}"`;
35
+ });
36
+ }
37
+ function checkMissingUpdated(data, frontmatterText) {
38
+ return frontmatterText !== null && !("updated" in data);
39
+ }
40
+ function fixMissingUpdated(raw, mtime) {
41
+ const dateStr = formatDate(mtime);
42
+ return raw.replace(/^(---\n[\s\S]*?)\n---/m, `$1\nupdated: ${dateStr}\n---`);
43
+ }
44
+ // ── stale-path helpers ────────────────────────────────────────────────────────
45
+ function checkStalePath(body) {
46
+ const pathRe = /\/home\/[^\s"'`)\]>,]+/g;
47
+ let match;
48
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
49
+ while ((match = pathRe.exec(body)) !== null) {
50
+ const candidate = match[0];
51
+ if (!fs.existsSync(candidate)) {
52
+ return candidate;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ // ── missing-ref helpers ───────────────────────────────────────────────────────
58
+ const REF_RE = /(?:^|[\s`"'(])((agent|command|knowledge|memory|script|skill|workflow|lesson|task|wiki|vault):[^\s"'`)\]>,\n]+)/gm;
59
+ /** Map from ref type to relative path pattern within stashRoot. Returns null to skip. */
60
+ function refToRelPath(refType, refName) {
61
+ switch (refType) {
62
+ case "agent":
63
+ return path.join("agents", `${refName}.md`);
64
+ case "command":
65
+ return path.join("commands", `${refName}.md`);
66
+ case "knowledge":
67
+ return path.join("knowledge", `${refName}.md`);
68
+ case "memory":
69
+ return path.join("memories", `${refName}.md`);
70
+ case "script":
71
+ return null; // scripts live in nested dirs — skip
72
+ case "skill":
73
+ return path.join("skills", refName, "SKILL.md");
74
+ case "workflow":
75
+ return path.join("workflows", `${refName}.md`);
76
+ case "lesson":
77
+ return path.join("lessons", `${refName}.md`);
78
+ case "task":
79
+ return path.join("tasks", `${refName}.md`);
80
+ case "wiki":
81
+ return path.join("wikis", `${refName}.md`);
82
+ case "vault":
83
+ // Vaults are .env files. The canonical name "default" (or empty) maps to
84
+ // ".env"; any other name maps to "<name>.env". This mirrors the vault
85
+ // asset-spec toAssetPath logic in src/core/asset-spec.ts.
86
+ if (!refName || refName === "default") {
87
+ return path.join("vaults", ".env");
88
+ }
89
+ return path.join("vaults", `${refName}.env`);
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Returns true if `relPath` resolves to a real file (or multi-file directory
96
+ * primary) in ANY of the provided stash roots.
97
+ */
98
+ function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
99
+ for (const root of stashRoots) {
100
+ const absPath = path.join(root, relPath);
101
+ if (fs.existsSync(absPath))
102
+ return true;
103
+ // Multi-file skill layout: directory containing SKILL.md
104
+ const bareDir = absPath.replace(/\.md$/, "");
105
+ if (fs.existsSync(bareDir) && fs.existsSync(path.join(bareDir, "SKILL.md")))
106
+ return true;
107
+ // .derived.md variant for memory refs
108
+ if (refType === "memory") {
109
+ const derivedPath = path.join(root, "memories", `${refName}.derived.md`);
110
+ if (fs.existsSync(derivedPath))
111
+ return true;
112
+ }
113
+ // Knowledge-specific: search subdirectories like knowledge/projects/, knowledge/tools/, etc.
114
+ if (refType === "knowledge") {
115
+ try {
116
+ const knowledgeDir = path.join(root, "knowledge");
117
+ if (fs.existsSync(knowledgeDir) && fs.statSync(knowledgeDir).isDirectory()) {
118
+ const entries = fs.readdirSync(knowledgeDir);
119
+ for (const entry of entries) {
120
+ const subPath = path.join(knowledgeDir, entry, `${refName}.md`);
121
+ if (fs.existsSync(subPath))
122
+ return true;
123
+ }
124
+ }
125
+ }
126
+ catch {
127
+ // Ignore errors reading directory
128
+ }
129
+ }
130
+ // Fallback: the refName may already encode the full stash-relative path
131
+ // (e.g. knowledge:skills/foo/references/bar where the file lives at
132
+ // <stash>/skills/foo/references/bar.md, not <stash>/knowledge/skills/...).
133
+ const directPath = path.join(root, `${refName}.md`);
134
+ if (fs.existsSync(directPath))
135
+ return true;
136
+ const directDir = path.join(root, refName);
137
+ if (fs.existsSync(directDir) && fs.existsSync(path.join(directDir, "SKILL.md")))
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+ /**
143
+ * Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
144
+ * body that does not resolve to a real file under any of the provided stash roots.
145
+ *
146
+ * Skips false-positive patterns:
147
+ * - Shell variables: memory:$(cmd) or knowledge:${VAR}
148
+ * - ACP type notation: agent::Type (double colons are C++/ACP syntax)
149
+ * - Incomplete/placeholder refs: slug is single character or "**"
150
+ */
151
+ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
152
+ const allRoots = [stashRoot, ...extraStashRoots];
153
+ const missing = [];
154
+ let match;
155
+ const re = new RegExp(REF_RE.source, REF_RE.flags);
156
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
157
+ while ((match = re.exec(body)) !== null) {
158
+ const fullRef = match[1]; // e.g. "workflow:foo" or "local//workflow:foo"
159
+ // Skip shell variables: memory:$(cmd) or knowledge:${VAR}
160
+ if (fullRef.includes("$(") || fullRef.includes("${")) {
161
+ continue;
162
+ }
163
+ // Skip ACP type notation: agent::Type (double colons)
164
+ if (fullRef.includes("::")) {
165
+ continue;
166
+ }
167
+ // Strip leading "local//" prefix if present
168
+ let ref = fullRef;
169
+ if (ref.startsWith("local//")) {
170
+ ref = ref.slice("local//".length);
171
+ }
172
+ else if (fullRef.includes("//")) {
173
+ // Has a remote origin prefix (e.g. "npm:", "github:", "owner/repo//") — skip
174
+ continue;
175
+ }
176
+ // Skip refs that start with obvious remote prefixes
177
+ const colonIdx = ref.indexOf(":");
178
+ if (colonIdx === -1)
179
+ continue;
180
+ const refType = ref.slice(0, colonIdx);
181
+ const refName = ref.slice(colonIdx + 1);
182
+ // Guard against empty names or names that look like paths/URLs
183
+ if (!refName || refName.startsWith("/") || refName.startsWith("~") || refName.startsWith("http")) {
184
+ continue;
185
+ }
186
+ // Skip placeholder/incomplete refs: single character slug or "**"
187
+ if (refName.length <= 1 || refName === "**") {
188
+ continue;
189
+ }
190
+ const relPath = refToRelPath(refType, refName);
191
+ if (relPath === null)
192
+ continue; // type is skipped
193
+ if (!refExistsInAnyStash(relPath, refType, refName, allRoots)) {
194
+ missing.push({ ref: fullRef, resolvedRelPath: relPath });
195
+ }
196
+ }
197
+ return missing;
198
+ }
199
+ // ── BaseLinter ────────────────────────────────────────────────────────────────
200
+ /**
201
+ * Abstract base class providing the two cross-type checks shared by all asset
202
+ * linters: `unquoted-colon` and `missing-updated`.
203
+ *
204
+ * Subclasses call `runBaseChecks(ctx)` and append any type-specific issues.
205
+ * File mutations triggered by base checks are flushed to disk inside this
206
+ * method; subclasses must re-read `ctx.raw` if they need the post-fix content
207
+ * (in practice the base class updates `ctx.raw` in place when `fix` is true).
208
+ */
209
+ export class BaseLinter {
210
+ runBaseChecks(ctx) {
211
+ const issues = [];
212
+ let currentRaw = ctx.raw;
213
+ let modified = false;
214
+ // ── 1. unquoted-colon ──────────────────────────────────────────────────
215
+ const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
216
+ if (unquotedColonDetail) {
217
+ if (ctx.fix) {
218
+ currentRaw = fixUnquotedColon(currentRaw);
219
+ modified = true;
220
+ issues.push({
221
+ file: ctx.relPath,
222
+ issue: "unquoted-colon",
223
+ detail: unquotedColonDetail,
224
+ fixed: true,
225
+ });
226
+ }
227
+ else {
228
+ issues.push({
229
+ file: ctx.relPath,
230
+ issue: "unquoted-colon",
231
+ detail: unquotedColonDetail,
232
+ fixed: false,
233
+ });
234
+ }
235
+ }
236
+ // ── 2. missing-updated ─────────────────────────────────────────────────
237
+ if (checkMissingUpdated(ctx.data, ctx.frontmatter)) {
238
+ if (ctx.fix) {
239
+ let mtime;
240
+ try {
241
+ mtime = fs.statSync(ctx.filePath).mtime;
242
+ }
243
+ catch {
244
+ mtime = new Date();
245
+ }
246
+ currentRaw = fixMissingUpdated(currentRaw, mtime);
247
+ modified = true;
248
+ issues.push({
249
+ file: ctx.relPath,
250
+ issue: "missing-updated",
251
+ detail: `stamped updated: ${formatDate(mtime)}`,
252
+ fixed: true,
253
+ });
254
+ }
255
+ else {
256
+ issues.push({
257
+ file: ctx.relPath,
258
+ issue: "missing-updated",
259
+ detail: "no updated field in frontmatter",
260
+ fixed: false,
261
+ });
262
+ }
263
+ }
264
+ if (modified) {
265
+ fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
266
+ // Propagate the mutated raw back so subclasses can re-parse if needed
267
+ ctx.raw = currentRaw;
268
+ }
269
+ // ── 3. stale-path ──────────────────────────────────────────────────────
270
+ const stalePathMatch = checkStalePath(ctx.body);
271
+ if (stalePathMatch) {
272
+ issues.push({
273
+ file: ctx.relPath,
274
+ issue: "stale-path",
275
+ detail: `nonexistent path: ${stalePathMatch}`,
276
+ fixed: false,
277
+ });
278
+ }
279
+ // ── 4. missing-ref ─────────────────────────────────────────────────────
280
+ const missingRefs = checkMissingRefs(ctx.body, ctx.stashRoot, ctx.extraStashRoots);
281
+ for (const { ref, resolvedRelPath } of missingRefs) {
282
+ issues.push({
283
+ file: ctx.relPath,
284
+ issue: "missing-ref",
285
+ detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
286
+ fixed: false,
287
+ });
288
+ }
289
+ return issues;
290
+ }
291
+ }
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import { BaseLinter } from "./base-linter";
3
+ /**
4
+ * Linter for `commands/` assets.
5
+ *
6
+ * Extra check beyond base:
7
+ * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
8
+ * absent. Not auto-fixable; detail includes a suggested slug.
9
+ */
10
+ export class CommandLinter extends BaseLinter {
11
+ types = ["commands"];
12
+ lint(ctx) {
13
+ const issues = this.runBaseChecks(ctx);
14
+ const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
15
+ if (missingFieldDetail) {
16
+ const slug = this.#suggestSlug(ctx.filePath);
17
+ issues.push({
18
+ file: ctx.relPath,
19
+ issue: "missing-name-or-type",
20
+ detail: `${missingFieldDetail}; suggested slug: ${slug}`,
21
+ fixed: false,
22
+ });
23
+ }
24
+ return issues;
25
+ }
26
+ #checkMissingNameOrType(data, frontmatterText) {
27
+ if (!frontmatterText)
28
+ return null;
29
+ const missingFields = [];
30
+ if (!("name" in data) || !data.name)
31
+ missingFields.push("name");
32
+ if (!("type" in data) || !data.type)
33
+ missingFields.push("type");
34
+ if (missingFields.length === 0)
35
+ return null;
36
+ return `missing fields: ${missingFields.join(", ")}`;
37
+ }
38
+ #suggestSlug(filePath) {
39
+ return path
40
+ .basename(filePath, ".md")
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9-]+/g, "-")
43
+ .replace(/-+/g, "-")
44
+ .replace(/^-|-$/g, "");
45
+ }
46
+ }
@@ -0,0 +1,13 @@
1
+ import { BaseLinter } from "./base-linter";
2
+ /**
3
+ * Default linter for asset types that have no type-specific rules beyond the
4
+ * base checks (`unquoted-colon`, `missing-updated`).
5
+ *
6
+ * Covers: `lessons`.
7
+ */
8
+ export class DefaultLinter extends BaseLinter {
9
+ types = ["lessons"];
10
+ lint(ctx) {
11
+ return this.runBaseChecks(ctx);
12
+ }
13
+ }
@@ -0,0 +1,145 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveStashDir } from "../../core/common";
4
+ import { loadConfig } from "../../core/config";
5
+ import { parseFrontmatter } from "../../core/frontmatter";
6
+ import { resolveSourceEntries } from "../../indexer/search-source";
7
+ import { getLinterForType } from "./registry";
8
+ import { checkVaultForDangerousKeys } from "./vault-key-rules";
9
+ // ── Constants ─────────────────────────────────────────────────────────────────
10
+ const STASH_SUBDIRS = [
11
+ "agents",
12
+ "commands",
13
+ "memories",
14
+ "skills",
15
+ "workflows",
16
+ "lessons",
17
+ "tasks",
18
+ "knowledge",
19
+ ];
20
+ // ── Helpers ───────────────────────────────────────────────────────────────────
21
+ function collectMarkdownFiles(dir) {
22
+ if (!fs.existsSync(dir))
23
+ return [];
24
+ const results = [];
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ const full = path.join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ results.push(...collectMarkdownFiles(full));
29
+ }
30
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
31
+ results.push(full);
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+ function collectEnvFiles(dir) {
37
+ const results = [];
38
+ try {
39
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40
+ const full = path.join(dir, entry.name);
41
+ if (entry.isDirectory())
42
+ results.push(...collectEnvFiles(full));
43
+ else if (entry.isFile() && entry.name.endsWith(".env"))
44
+ results.push(full);
45
+ }
46
+ }
47
+ catch {
48
+ /* dir may not exist */
49
+ }
50
+ return results;
51
+ }
52
+ /** True when the issue represents a file deletion that was successfully applied. */
53
+ function isFileDeletion(issue) {
54
+ return issue.fixed === true && (issue.issue === "orphaned-stub" || issue.issue === "placeholder-stub");
55
+ }
56
+ // ── Main ──────────────────────────────────────────────────────────────────────
57
+ export function akmLint(options = {}) {
58
+ const stashRoot = options.dir ?? options.config?.stashDir ?? resolveStashDir();
59
+ // Collect secondary stash roots from configured filesystem sources so that
60
+ // cross-stash refs (e.g. referencing assets in dimm-city/agent-stash) are
61
+ // not falsely flagged as missing-ref.
62
+ const cfg = options.config ?? loadConfig();
63
+ const extraStashRoots = resolveSourceEntries(stashRoot, cfg)
64
+ .map((s) => s.path)
65
+ .filter((p) => p !== stashRoot && fs.existsSync(p));
66
+ const fix = options.fix ?? false;
67
+ const fixed = [];
68
+ const flagged = [];
69
+ for (const subdir of STASH_SUBDIRS) {
70
+ const dirPath = path.join(stashRoot, subdir);
71
+ const files = collectMarkdownFiles(dirPath);
72
+ const linter = getLinterForType(subdir);
73
+ // If the linter supports directory-level checks, run them for each direct
74
+ // subdirectory once before the per-file loop.
75
+ if (typeof linter.lintDirectory === "function" && fs.existsSync(dirPath)) {
76
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
77
+ if (entry.isDirectory()) {
78
+ const subdirIssues = linter.lintDirectory(path.join(dirPath, entry.name), stashRoot);
79
+ for (const issue of subdirIssues) {
80
+ if (issue.fixed) {
81
+ fixed.push(issue);
82
+ }
83
+ else {
84
+ flagged.push(issue);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ for (const filePath of files) {
91
+ const relPath = path.relative(stashRoot, filePath);
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(filePath, "utf8");
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ const { data, content: body, frontmatter } = parseFrontmatter(raw);
100
+ const issues = linter.lint({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots });
101
+ let fileDeleted = false;
102
+ for (const issue of issues) {
103
+ if (isFileDeletion(issue)) {
104
+ fileDeleted = true;
105
+ fixed.push(issue);
106
+ }
107
+ else if (issue.fixed) {
108
+ fixed.push(issue);
109
+ }
110
+ else {
111
+ flagged.push(issue);
112
+ }
113
+ }
114
+ if (fileDeleted)
115
+ continue; // file is gone — skip any remaining checks
116
+ }
117
+ }
118
+ // ── Vault dangerous-key pass ───────────────────────────────────────────────
119
+ // Scan every `.env` file under <stashRoot>/vaults/ (and secondary stash
120
+ // roots) for keys that are known to enable process-execution hijacking.
121
+ // This is a warn-only pass — findings go into `flagged`, never `fixed`.
122
+ const vaultRoots = [stashRoot, ...extraStashRoots];
123
+ for (const root of vaultRoots) {
124
+ const vaultsDir = path.join(root, "vaults");
125
+ if (!fs.existsSync(vaultsDir))
126
+ continue;
127
+ const envFiles = collectEnvFiles(vaultsDir);
128
+ for (const vaultPath of envFiles) {
129
+ const baseName = path.basename(vaultPath, ".env");
130
+ // canonical vault ref: "default" (or empty) maps to ".env" → vault:default
131
+ const vaultRef = baseName === "" ? "vault:default" : `vault:${baseName}`;
132
+ const relPath = path.relative(root, vaultPath);
133
+ const issues = checkVaultForDangerousKeys(vaultPath, relPath, vaultRef);
134
+ for (const issue of issues) {
135
+ flagged.push(issue);
136
+ }
137
+ }
138
+ }
139
+ return {
140
+ ok: flagged.length === 0,
141
+ fixed,
142
+ flagged,
143
+ summary: { fixed: fixed.length, flagged: flagged.length },
144
+ };
145
+ }
@@ -0,0 +1,13 @@
1
+ import { BaseLinter } from "./base-linter";
2
+ /**
3
+ * Linter for `knowledge/` assets.
4
+ *
5
+ * All checks are inherited from BaseLinter (`unquoted-colon`, `missing-updated`,
6
+ * `stale-path`, `missing-ref`). No type-specific rules needed.
7
+ */
8
+ export class KnowledgeLinter extends BaseLinter {
9
+ types = ["knowledge"];
10
+ lint(ctx) {
11
+ return this.runBaseChecks(ctx);
12
+ }
13
+ }
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import { BaseLinter } from "./base-linter";
3
+ /**
4
+ * Linter for `memories/` assets.
5
+ *
6
+ * Extra check beyond base:
7
+ * - `orphaned-stub`: `inferenceProcessed: true` in frontmatter AND body < 100
8
+ * chars AND no sibling `.derived.md` file. Fix: delete the stub file.
9
+ */
10
+ export class MemoryLinter extends BaseLinter {
11
+ types = ["memories"];
12
+ lint(ctx) {
13
+ const issues = this.runBaseChecks(ctx);
14
+ // After base checks the file might have been mutated; re-parse body from
15
+ // ctx.raw which was updated in place by BaseLinter when fix === true.
16
+ const body = ctx.body;
17
+ if (this.#isOrphanedStub(ctx.data, body, ctx.filePath)) {
18
+ if (ctx.fix) {
19
+ try {
20
+ fs.unlinkSync(ctx.filePath);
21
+ issues.push({
22
+ file: ctx.relPath,
23
+ issue: "orphaned-stub",
24
+ detail: "deleted orphaned stub",
25
+ fixed: true,
26
+ });
27
+ }
28
+ catch (e) {
29
+ issues.push({
30
+ file: ctx.relPath,
31
+ issue: "orphaned-stub",
32
+ detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
33
+ fixed: false,
34
+ });
35
+ }
36
+ // Signal caller to skip remaining checks via a sentinel issue
37
+ // (caller must handle the deletion path; we mark the file as gone)
38
+ return issues;
39
+ }
40
+ issues.push({
41
+ file: ctx.relPath,
42
+ issue: "orphaned-stub",
43
+ detail: "inferenceProcessed stub with no derived sibling",
44
+ fixed: false,
45
+ });
46
+ }
47
+ return issues;
48
+ }
49
+ #isOrphanedStub(data, body, filePath) {
50
+ if (data.inferenceProcessed !== true)
51
+ return false;
52
+ if (body.trim().length >= 100)
53
+ return false;
54
+ const baseName = filePath.replace(/\.md$/, "");
55
+ const derivedPath = `${baseName}.derived.md`;
56
+ return !fs.existsSync(derivedPath);
57
+ }
58
+ }
@@ -0,0 +1,33 @@
1
+ import { AgentLinter } from "./agent-linter";
2
+ import { CommandLinter } from "./command-linter";
3
+ import { DefaultLinter } from "./default-linter";
4
+ import { KnowledgeLinter } from "./knowledge-linter";
5
+ import { MemoryLinter } from "./memory-linter";
6
+ import { SkillLinter } from "./skill-linter";
7
+ import { TaskLinter } from "./task-linter";
8
+ import { WorkflowLinter } from "./workflow-linter";
9
+ // Singleton instances — one per type, shared across all lint runs.
10
+ const LINTERS = [
11
+ new AgentLinter(),
12
+ new MemoryLinter(),
13
+ new WorkflowLinter(),
14
+ new CommandLinter(),
15
+ new KnowledgeLinter(),
16
+ new SkillLinter(),
17
+ new TaskLinter(),
18
+ new DefaultLinter(),
19
+ ];
20
+ const LINTER_MAP = new Map();
21
+ for (const linter of LINTERS) {
22
+ for (const t of linter.types) {
23
+ LINTER_MAP.set(t, linter);
24
+ }
25
+ }
26
+ const DEFAULT_LINTER = new DefaultLinter();
27
+ /**
28
+ * Return the appropriate linter for the given stash subdirectory name.
29
+ * Falls back to `DefaultLinter` for unknown types.
30
+ */
31
+ export function getLinterForType(subdir) {
32
+ return LINTER_MAP.get(subdir) ?? DEFAULT_LINTER;
33
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BaseLinter } from "./base-linter";
4
+ /**
5
+ * Linter for `skills/` assets.
6
+ *
7
+ * Skills are **directory bundles**: each skill lives at `skills/<name>/` and
8
+ * must contain a `SKILL.md` entry-point file.
9
+ *
10
+ * Directory-level check (via `lintDirectory`):
11
+ * - `missing-skill-md`: a skill subdirectory has no `SKILL.md`. Not
12
+ * auto-fixable — flagged with detail `"no SKILL.md in skills/<name>/"`.
13
+ *
14
+ * Per-file check:
15
+ * - Base checks (`unquoted-colon`, `missing-updated`) are run against any
16
+ * `.md` files found inside skill subdirectories.
17
+ */
18
+ export class SkillLinter extends BaseLinter {
19
+ types = ["skills"];
20
+ /**
21
+ * Called once per direct subdirectory of `skills/`. Reports a
22
+ * `missing-skill-md` issue when the directory does not contain a `SKILL.md`.
23
+ */
24
+ lintDirectory(subdirPath, stashRoot) {
25
+ const skillMdPath = path.join(subdirPath, "SKILL.md");
26
+ if (!fs.existsSync(skillMdPath)) {
27
+ const relDir = path.relative(stashRoot, subdirPath);
28
+ return [
29
+ {
30
+ file: relDir,
31
+ issue: "missing-skill-md",
32
+ detail: `no SKILL.md in ${relDir}/`,
33
+ fixed: false,
34
+ },
35
+ ];
36
+ }
37
+ return [];
38
+ }
39
+ lint(ctx) {
40
+ return this.runBaseChecks(ctx);
41
+ }
42
+ }