akm-cli 0.7.5 → 0.8.0-rc1

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 (151) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +804 -461
  4. package/dist/commands/agent-dispatch.js +102 -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 +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  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 +1170 -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 +251 -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 +107 -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/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +2 -23
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-registry.js +4 -16
  54. package/dist/core/asset-spec.js +10 -0
  55. package/dist/core/common.js +94 -0
  56. package/dist/core/concurrent.js +22 -0
  57. package/dist/core/config.js +222 -128
  58. package/dist/core/events.js +73 -126
  59. package/dist/core/frontmatter.js +3 -1
  60. package/dist/core/markdown.js +17 -0
  61. package/dist/core/memory-improve.js +678 -0
  62. package/dist/core/parse.js +155 -0
  63. package/dist/core/paths.js +101 -3
  64. package/dist/core/proposal-validators.js +61 -0
  65. package/dist/core/proposals.js +49 -38
  66. package/dist/core/state-db.js +775 -0
  67. package/dist/core/time.js +51 -0
  68. package/dist/core/warn.js +59 -1
  69. package/dist/indexer/db-search.js +52 -238
  70. package/dist/indexer/db.js +377 -1
  71. package/dist/indexer/ensure-index.js +61 -0
  72. package/dist/indexer/graph-boost.js +247 -94
  73. package/dist/indexer/graph-db.js +201 -0
  74. package/dist/indexer/graph-dedup.js +99 -0
  75. package/dist/indexer/graph-extraction.js +409 -76
  76. package/dist/indexer/index-context.js +10 -0
  77. package/dist/indexer/indexer.js +442 -290
  78. package/dist/indexer/llm-cache.js +47 -0
  79. package/dist/indexer/match-contributors.js +141 -0
  80. package/dist/indexer/matchers.js +24 -190
  81. package/dist/indexer/memory-inference.js +63 -29
  82. package/dist/indexer/metadata-contributors.js +26 -0
  83. package/dist/indexer/metadata.js +188 -175
  84. package/dist/indexer/path-resolver.js +89 -0
  85. package/dist/indexer/ranking-contributors.js +204 -0
  86. package/dist/indexer/ranking.js +74 -0
  87. package/dist/indexer/search-hit-enrichers.js +22 -0
  88. package/dist/indexer/search-source.js +24 -9
  89. package/dist/indexer/semantic-status.js +2 -16
  90. package/dist/indexer/walker.js +25 -0
  91. package/dist/integrations/agent/config.js +175 -3
  92. package/dist/integrations/agent/index.js +3 -1
  93. package/dist/integrations/agent/pipeline.js +39 -0
  94. package/dist/integrations/agent/profiles.js +67 -5
  95. package/dist/integrations/agent/prompts.js +77 -72
  96. package/dist/integrations/agent/runners.js +31 -0
  97. package/dist/integrations/agent/sdk-runner.js +120 -0
  98. package/dist/integrations/agent/spawn.js +71 -16
  99. package/dist/integrations/lockfile.js +10 -18
  100. package/dist/integrations/session-logs/index.js +65 -0
  101. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  102. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  103. package/dist/integrations/session-logs/types.js +1 -0
  104. package/dist/llm/call-ai.js +74 -0
  105. package/dist/llm/client.js +61 -122
  106. package/dist/llm/feature-gate.js +27 -16
  107. package/dist/llm/graph-extract.js +297 -62
  108. package/dist/llm/memory-infer.js +49 -71
  109. package/dist/llm/metadata-enhance.js +39 -22
  110. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  111. package/dist/output/cli-hints-full.md +277 -0
  112. package/dist/output/cli-hints-short.md +65 -0
  113. package/dist/output/cli-hints.js +2 -318
  114. package/dist/output/renderers.js +190 -123
  115. package/dist/output/shapes.js +33 -0
  116. package/dist/output/text.js +239 -2
  117. package/dist/registry/providers/skills-sh.js +61 -49
  118. package/dist/registry/providers/static-index.js +44 -48
  119. package/dist/setup/setup.js +510 -11
  120. package/dist/sources/provider-factory.js +2 -1
  121. package/dist/sources/providers/git.js +2 -2
  122. package/dist/sources/website-ingest.js +4 -0
  123. package/dist/tasks/backends/cron.js +200 -0
  124. package/dist/tasks/backends/exec-utils.js +25 -0
  125. package/dist/tasks/backends/index.js +32 -0
  126. package/dist/tasks/backends/launchd-template.xml +19 -0
  127. package/dist/tasks/backends/launchd.js +184 -0
  128. package/dist/tasks/backends/schtasks-template.xml +29 -0
  129. package/dist/tasks/backends/schtasks.js +212 -0
  130. package/dist/tasks/parser.js +198 -0
  131. package/dist/tasks/resolveAkmBin.js +84 -0
  132. package/dist/tasks/runner.js +432 -0
  133. package/dist/tasks/schedule.js +208 -0
  134. package/dist/tasks/schema.js +13 -0
  135. package/dist/tasks/validator.js +59 -0
  136. package/dist/wiki/index-template.md +12 -0
  137. package/dist/wiki/ingest-workflow-template.md +54 -0
  138. package/dist/wiki/log-template.md +8 -0
  139. package/dist/wiki/schema-template.md +61 -0
  140. package/dist/wiki/wiki-templates.js +12 -0
  141. package/dist/wiki/wiki.js +10 -61
  142. package/dist/workflows/authoring.js +5 -25
  143. package/dist/workflows/renderer.js +8 -3
  144. package/dist/workflows/runs.js +59 -91
  145. package/dist/workflows/validator.js +1 -1
  146. package/dist/workflows/workflow-template.md +24 -0
  147. package/docs/README.md +3 -0
  148. package/docs/migration/release-notes/0.7.0.md +1 -1
  149. package/docs/migration/release-notes/0.8.0.md +43 -0
  150. package/package.json +3 -2
  151. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,251 @@
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
+ return path.join("vaults", `${refName}.md`);
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Returns true if `relPath` resolves to a real file (or multi-file directory
90
+ * primary) in ANY of the provided stash roots.
91
+ */
92
+ function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
93
+ for (const root of stashRoots) {
94
+ const absPath = path.join(root, relPath);
95
+ if (fs.existsSync(absPath))
96
+ return true;
97
+ // Multi-file skill layout: directory containing SKILL.md
98
+ const bareDir = absPath.replace(/\.md$/, "");
99
+ if (fs.existsSync(bareDir) && fs.existsSync(path.join(bareDir, "SKILL.md")))
100
+ return true;
101
+ // .derived.md variant for memory refs
102
+ if (refType === "memory") {
103
+ const derivedPath = path.join(root, "memories", `${refName}.derived.md`);
104
+ if (fs.existsSync(derivedPath))
105
+ return true;
106
+ }
107
+ // Fallback: the refName may already encode the full stash-relative path
108
+ // (e.g. knowledge:skills/foo/references/bar where the file lives at
109
+ // <stash>/skills/foo/references/bar.md, not <stash>/knowledge/skills/...).
110
+ const directPath = path.join(root, `${refName}.md`);
111
+ if (fs.existsSync(directPath))
112
+ return true;
113
+ const directDir = path.join(root, refName);
114
+ if (fs.existsSync(directDir) && fs.existsSync(path.join(directDir, "SKILL.md")))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ /**
120
+ * Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
121
+ * body that does not resolve to a real file under any of the provided stash roots.
122
+ */
123
+ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
124
+ const allRoots = [stashRoot, ...extraStashRoots];
125
+ const missing = [];
126
+ let match;
127
+ const re = new RegExp(REF_RE.source, REF_RE.flags);
128
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
129
+ while ((match = re.exec(body)) !== null) {
130
+ const fullRef = match[1]; // e.g. "workflow:foo" or "local//workflow:foo"
131
+ // Strip leading "local//" prefix if present
132
+ let ref = fullRef;
133
+ if (ref.startsWith("local//")) {
134
+ ref = ref.slice("local//".length);
135
+ }
136
+ else if (fullRef.includes("//")) {
137
+ // Has a remote origin prefix (e.g. "npm:", "github:", "owner/repo//") — skip
138
+ continue;
139
+ }
140
+ // Skip refs that start with obvious remote prefixes
141
+ const colonIdx = ref.indexOf(":");
142
+ if (colonIdx === -1)
143
+ continue;
144
+ const refType = ref.slice(0, colonIdx);
145
+ const refName = ref.slice(colonIdx + 1);
146
+ // Guard against empty names or names that look like paths/URLs
147
+ if (!refName || refName.startsWith("/") || refName.startsWith("~") || refName.startsWith("http")) {
148
+ continue;
149
+ }
150
+ const relPath = refToRelPath(refType, refName);
151
+ if (relPath === null)
152
+ continue; // type is skipped
153
+ if (!refExistsInAnyStash(relPath, refType, refName, allRoots)) {
154
+ missing.push({ ref: fullRef, resolvedRelPath: relPath });
155
+ }
156
+ }
157
+ return missing;
158
+ }
159
+ // ── BaseLinter ────────────────────────────────────────────────────────────────
160
+ /**
161
+ * Abstract base class providing the two cross-type checks shared by all asset
162
+ * linters: `unquoted-colon` and `missing-updated`.
163
+ *
164
+ * Subclasses call `runBaseChecks(ctx)` and append any type-specific issues.
165
+ * File mutations triggered by base checks are flushed to disk inside this
166
+ * method; subclasses must re-read `ctx.raw` if they need the post-fix content
167
+ * (in practice the base class updates `ctx.raw` in place when `fix` is true).
168
+ */
169
+ export class BaseLinter {
170
+ runBaseChecks(ctx) {
171
+ const issues = [];
172
+ let currentRaw = ctx.raw;
173
+ let modified = false;
174
+ // ── 1. unquoted-colon ──────────────────────────────────────────────────
175
+ const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
176
+ if (unquotedColonDetail) {
177
+ if (ctx.fix) {
178
+ currentRaw = fixUnquotedColon(currentRaw);
179
+ modified = true;
180
+ issues.push({
181
+ file: ctx.relPath,
182
+ issue: "unquoted-colon",
183
+ detail: unquotedColonDetail,
184
+ fixed: true,
185
+ });
186
+ }
187
+ else {
188
+ issues.push({
189
+ file: ctx.relPath,
190
+ issue: "unquoted-colon",
191
+ detail: unquotedColonDetail,
192
+ fixed: false,
193
+ });
194
+ }
195
+ }
196
+ // ── 2. missing-updated ─────────────────────────────────────────────────
197
+ if (checkMissingUpdated(ctx.data, ctx.frontmatter)) {
198
+ if (ctx.fix) {
199
+ let mtime;
200
+ try {
201
+ mtime = fs.statSync(ctx.filePath).mtime;
202
+ }
203
+ catch {
204
+ mtime = new Date();
205
+ }
206
+ currentRaw = fixMissingUpdated(currentRaw, mtime);
207
+ modified = true;
208
+ issues.push({
209
+ file: ctx.relPath,
210
+ issue: "missing-updated",
211
+ detail: `stamped updated: ${formatDate(mtime)}`,
212
+ fixed: true,
213
+ });
214
+ }
215
+ else {
216
+ issues.push({
217
+ file: ctx.relPath,
218
+ issue: "missing-updated",
219
+ detail: "no updated field in frontmatter",
220
+ fixed: false,
221
+ });
222
+ }
223
+ }
224
+ if (modified) {
225
+ fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
226
+ // Propagate the mutated raw back so subclasses can re-parse if needed
227
+ ctx.raw = currentRaw;
228
+ }
229
+ // ── 3. stale-path ──────────────────────────────────────────────────────
230
+ const stalePathMatch = checkStalePath(ctx.body);
231
+ if (stalePathMatch) {
232
+ issues.push({
233
+ file: ctx.relPath,
234
+ issue: "stale-path",
235
+ detail: `nonexistent path: ${stalePathMatch}`,
236
+ fixed: false,
237
+ });
238
+ }
239
+ // ── 4. missing-ref ─────────────────────────────────────────────────────
240
+ const missingRefs = checkMissingRefs(ctx.body, ctx.stashRoot, ctx.extraStashRoots);
241
+ for (const { ref, resolvedRelPath } of missingRefs) {
242
+ issues.push({
243
+ file: ctx.relPath,
244
+ issue: "missing-ref",
245
+ detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
246
+ fixed: false,
247
+ });
248
+ }
249
+ return issues;
250
+ }
251
+ }
@@ -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,107 @@
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
+ // ── Constants ─────────────────────────────────────────────────────────────────
9
+ const STASH_SUBDIRS = [
10
+ "agents",
11
+ "commands",
12
+ "memories",
13
+ "skills",
14
+ "workflows",
15
+ "lessons",
16
+ "tasks",
17
+ "knowledge",
18
+ ];
19
+ // ── Helpers ───────────────────────────────────────────────────────────────────
20
+ function collectMarkdownFiles(dir) {
21
+ if (!fs.existsSync(dir))
22
+ return [];
23
+ const results = [];
24
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ results.push(...collectMarkdownFiles(full));
28
+ }
29
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
30
+ results.push(full);
31
+ }
32
+ }
33
+ return results;
34
+ }
35
+ /** True when the issue represents a file deletion that was successfully applied. */
36
+ function isFileDeletion(issue) {
37
+ return issue.fixed === true && (issue.issue === "orphaned-stub" || issue.issue === "placeholder-stub");
38
+ }
39
+ // ── Main ──────────────────────────────────────────────────────────────────────
40
+ export function akmLint(options = {}) {
41
+ const stashRoot = options.dir ?? options.config?.stashDir ?? resolveStashDir();
42
+ // Collect secondary stash roots from configured filesystem sources so that
43
+ // cross-stash refs (e.g. referencing assets in dimm-city/agent-stash) are
44
+ // not falsely flagged as missing-ref.
45
+ const cfg = options.config ?? loadConfig();
46
+ const extraStashRoots = resolveSourceEntries(stashRoot, cfg)
47
+ .map((s) => s.path)
48
+ .filter((p) => p !== stashRoot && fs.existsSync(p));
49
+ const fix = options.fix ?? false;
50
+ const fixed = [];
51
+ const flagged = [];
52
+ for (const subdir of STASH_SUBDIRS) {
53
+ const dirPath = path.join(stashRoot, subdir);
54
+ const files = collectMarkdownFiles(dirPath);
55
+ const linter = getLinterForType(subdir);
56
+ // If the linter supports directory-level checks, run them for each direct
57
+ // subdirectory once before the per-file loop.
58
+ if (typeof linter.lintDirectory === "function" && fs.existsSync(dirPath)) {
59
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
60
+ if (entry.isDirectory()) {
61
+ const subdirIssues = linter.lintDirectory(path.join(dirPath, entry.name), stashRoot);
62
+ for (const issue of subdirIssues) {
63
+ if (issue.fixed) {
64
+ fixed.push(issue);
65
+ }
66
+ else {
67
+ flagged.push(issue);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ for (const filePath of files) {
74
+ const relPath = path.relative(stashRoot, filePath);
75
+ let raw;
76
+ try {
77
+ raw = fs.readFileSync(filePath, "utf8");
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ const { data, content: body, frontmatter } = parseFrontmatter(raw);
83
+ const issues = linter.lint({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots });
84
+ let fileDeleted = false;
85
+ for (const issue of issues) {
86
+ if (isFileDeletion(issue)) {
87
+ fileDeleted = true;
88
+ fixed.push(issue);
89
+ }
90
+ else if (issue.fixed) {
91
+ fixed.push(issue);
92
+ }
93
+ else {
94
+ flagged.push(issue);
95
+ }
96
+ }
97
+ if (fileDeleted)
98
+ continue; // file is gone — skip any remaining checks
99
+ }
100
+ }
101
+ return {
102
+ ok: flagged.length === 0,
103
+ fixed,
104
+ flagged,
105
+ summary: { fixed: fixed.length, flagged: flagged.length },
106
+ };
107
+ }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { BaseLinter } from "./base-linter";
2
+ /**
3
+ * Linter for `tasks/` assets.
4
+ *
5
+ * Tasks are `.md` files with YAML frontmatter. In addition to the base checks
6
+ * this linter validates the required task fields:
7
+ *
8
+ * - `schedule` (string, non-empty) — cron expression or `@`-alias
9
+ * - `enabled` (boolean)
10
+ * - At least one of: `prompt` or `workflow` field present
11
+ *
12
+ * All issues are reported as `invalid-task-frontmatter` and are **not**
13
+ * auto-fixable. Cron expression syntax validation is intentionally out of
14
+ * scope (that belongs to `parseSchedule()`).
15
+ */
16
+ export class TaskLinter extends BaseLinter {
17
+ types = ["tasks"];
18
+ lint(ctx) {
19
+ const issues = this.runBaseChecks(ctx);
20
+ // Only validate frontmatter fields when frontmatter is present.
21
+ if (ctx.frontmatter === null)
22
+ return issues;
23
+ const missing = [];
24
+ // schedule: must be present and non-empty
25
+ if (!("schedule" in ctx.data) || typeof ctx.data.schedule !== "string" || ctx.data.schedule.trim() === "") {
26
+ missing.push("schedule");
27
+ }
28
+ // enabled: must be present (boolean — value of false is valid)
29
+ if (!("enabled" in ctx.data)) {
30
+ missing.push("enabled");
31
+ }
32
+ // At least one of: prompt or workflow
33
+ const hasTarget = "prompt" in ctx.data || "workflow" in ctx.data;
34
+ if (!hasTarget) {
35
+ missing.push("prompt or workflow");
36
+ }
37
+ if (missing.length > 0) {
38
+ issues.push({
39
+ file: ctx.relPath,
40
+ issue: "invalid-task-frontmatter",
41
+ detail: `missing required fields: ${missing.join(", ")}`,
42
+ fixed: false,
43
+ });
44
+ }
45
+ return issues;
46
+ }
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import { BaseLinter } from "./base-linter";
3
+ const PLACEHOLDER_STRINGS = ["Describe what this workflow accomplishes", "Example Workflow"];
4
+ /**
5
+ * Linter for `workflows/` assets.
6
+ *
7
+ * Extra check beyond base:
8
+ * - `placeholder-stub`: body contains a known placeholder string.
9
+ * Fix: delete the file.
10
+ */
11
+ export class WorkflowLinter extends BaseLinter {
12
+ types = ["workflows"];
13
+ lint(ctx) {
14
+ const issues = this.runBaseChecks(ctx);
15
+ const placeholderMatch = this.#checkPlaceholderStub(ctx.body);
16
+ if (placeholderMatch) {
17
+ if (ctx.fix) {
18
+ try {
19
+ fs.unlinkSync(ctx.filePath);
20
+ issues.push({
21
+ file: ctx.relPath,
22
+ issue: "placeholder-stub",
23
+ detail: `deleted: found "${placeholderMatch}"`,
24
+ fixed: true,
25
+ });
26
+ }
27
+ catch (e) {
28
+ issues.push({
29
+ file: ctx.relPath,
30
+ issue: "placeholder-stub",
31
+ detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
32
+ fixed: false,
33
+ });
34
+ }
35
+ return issues;
36
+ }
37
+ issues.push({
38
+ file: ctx.relPath,
39
+ issue: "placeholder-stub",
40
+ detail: `placeholder text: "${placeholderMatch}"`,
41
+ fixed: false,
42
+ });
43
+ }
44
+ return issues;
45
+ }
46
+ #checkPlaceholderStub(body) {
47
+ for (const placeholder of PLACEHOLDER_STRINGS) {
48
+ if (body.includes(placeholder))
49
+ return placeholder;
50
+ }
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export { akmLint } from "./lint/index";