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.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +86 -0
- package/dist/cli.js +1023 -521
- package/dist/commands/agent-dispatch.js +107 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +812 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +218 -43
- 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 +1161 -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 +291 -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 +145 -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/vault-key-rules.js +67 -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 +71 -28
- package/dist/commands/reflect.js +135 -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 +125 -20
- 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 +168 -77
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +100 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +233 -133
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +0 -6
- 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 +731 -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 +403 -54
- 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 +456 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/matchers.js +124 -160
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +196 -197
- 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/builders.js +109 -0
- package/dist/integrations/agent/config.js +203 -3
- package/dist/integrations/agent/index.js +5 -2
- package/dist/integrations/agent/model-aliases.js +63 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +93 -22
- 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 +220 -256
- package/dist/output/shapes.js +101 -93
- package/dist/output/text.js +256 -17
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/registry/resolve.js +8 -16
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/filesystem.js +16 -23
- package/dist/sources/providers/git.js +4 -5
- package/dist/sources/providers/website.js +15 -22
- 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 +5 -2
- 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,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
|
+
}
|