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.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +804 -461
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +251 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +2 -23
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +377 -1
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- 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";
|