akm-cli 0.9.0-beta.2 → 0.9.0-beta.26

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 (111) hide show
  1. package/CHANGELOG.md +614 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +1851 -515
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/index.js +30 -13
  55. package/dist/commands/lint/memory-linter.js +1 -1
  56. package/dist/commands/lint/registry.js +5 -2
  57. package/dist/commands/lint/task-linter.js +3 -3
  58. package/dist/commands/lint/workflow-linter.js +26 -1
  59. package/dist/commands/proposal/drain.js +73 -6
  60. package/dist/commands/proposal/proposal-cli.js +22 -10
  61. package/dist/commands/proposal/proposal.js +17 -1
  62. package/dist/commands/proposal/validators/proposals.js +369 -329
  63. package/dist/commands/read/curate.js +294 -79
  64. package/dist/commands/read/search-cli.js +7 -0
  65. package/dist/commands/read/search.js +1 -0
  66. package/dist/commands/remember.js +6 -2
  67. package/dist/commands/sources/installed-stashes.js +5 -1
  68. package/dist/commands/sources/stash-cli.js +10 -2
  69. package/dist/core/asset/frontmatter.js +166 -167
  70. package/dist/core/asset/markdown.js +8 -0
  71. package/dist/core/config/config-schema.js +241 -0
  72. package/dist/core/config/config.js +2 -2
  73. package/dist/core/logs-db.js +305 -0
  74. package/dist/core/paths.js +3 -0
  75. package/dist/core/state-db.js +706 -42
  76. package/dist/indexer/db/db.js +347 -38
  77. package/dist/indexer/db/graph-db.js +81 -86
  78. package/dist/indexer/ensure-index.js +152 -17
  79. package/dist/indexer/graph/graph-boost.js +51 -41
  80. package/dist/indexer/index-writer-lock.js +99 -0
  81. package/dist/indexer/indexer.js +114 -111
  82. package/dist/indexer/passes/memory-inference.js +71 -25
  83. package/dist/indexer/passes/staleness-detect.js +2 -5
  84. package/dist/indexer/search/db-search.js +15 -4
  85. package/dist/indexer/search/ranking.js +4 -0
  86. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  87. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  88. package/dist/integrations/session-logs/index.js +16 -0
  89. package/dist/llm/client.js +38 -4
  90. package/dist/llm/embedder.js +27 -3
  91. package/dist/llm/embedders/local.js +66 -2
  92. package/dist/llm/graph-extract.js +2 -1
  93. package/dist/llm/memory-infer.js +4 -8
  94. package/dist/llm/metadata-enhance.js +9 -1
  95. package/dist/llm/usage-persist.js +77 -0
  96. package/dist/llm/usage-telemetry.js +103 -0
  97. package/dist/output/context.js +3 -2
  98. package/dist/output/html-render.js +73 -0
  99. package/dist/output/shapes/curate.js +14 -2
  100. package/dist/output/shapes/helpers.js +17 -1
  101. package/dist/output/text/helpers.js +78 -1
  102. package/dist/runtime.js +25 -1
  103. package/dist/scripts/migrate-storage.js +1194 -607
  104. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +455 -270
  105. package/dist/sources/providers/tar-utils.js +16 -8
  106. package/dist/storage/sqlite-pragmas.js +146 -0
  107. package/dist/tasks/runner.js +99 -16
  108. package/dist/workflows/db.js +5 -2
  109. package/dist/workflows/validate-summary.js +2 -7
  110. package/docs/data-and-telemetry.md +1 -0
  111. package/package.json +7 -5
@@ -39,7 +39,7 @@
39
39
  import fs from "node:fs";
40
40
  import path from "node:path";
41
41
  import { getAssetTypes, resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
42
- import { findSafeInsertionPoint } from "./markdown-insertion.js";
42
+ import { findFenceRegions, findSafeInsertionPoint } from "./markdown-insertion.js";
43
43
  // ── Helpers ───────────────────────────────────────────────────────────────────
44
44
  function formatDate(d) {
45
45
  const y = d.getFullYear();
@@ -65,34 +65,69 @@ function checkUnquotedColon(frontmatterText) {
65
65
  return null;
66
66
  }
67
67
  function fixUnquotedColon(raw) {
68
- return raw.replace(/^(description:\s*)(.*)/m, (_match, prefix, value) => {
69
- const trimmed = value.trim();
70
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
71
- return _match;
72
- }
73
- const escaped = trimmed.replace(/"/g, '\\"');
74
- return `${prefix}"${escaped}"`;
75
- });
68
+ const lines = raw.split(/\r?\n/);
69
+ if (lines[0]?.trim() !== "---")
70
+ return raw;
71
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
72
+ if (closeIdx === -1)
73
+ return raw;
74
+ for (let i = 1; i < closeIdx; i++) {
75
+ const m = lines[i].match(/^(description:\s*)(.*)/);
76
+ if (!m)
77
+ continue;
78
+ const prefix = m[1];
79
+ const value = m[2].trim();
80
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
81
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2))
82
+ continue;
83
+ lines[i] = `${prefix}"${value.replace(/"/g, '\\"')}"`;
84
+ break;
85
+ }
86
+ return lines.join("\n");
76
87
  }
77
88
  function checkMissingUpdated(data, frontmatterText) {
78
89
  return frontmatterText !== null && !("updated" in data);
79
90
  }
80
91
  function fixMissingUpdated(raw, mtime) {
81
92
  const dateStr = formatDate(mtime);
82
- return raw.replace(/^(---\n[\s\S]*?)\n---/m, `$1\nupdated: ${dateStr}\n---`);
93
+ const lines = raw.split(/\r?\n/);
94
+ if (lines[0]?.trim() !== "---")
95
+ return raw;
96
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
97
+ if (closeIdx === -1)
98
+ return raw;
99
+ lines.splice(closeIdx, 0, `updated: ${dateStr}`);
100
+ return lines.join("\n");
83
101
  }
84
102
  // ── stale-path helpers ────────────────────────────────────────────────────────
85
103
  function checkStalePath(body) {
86
- const pathRe = /\/home\/[^\s"'`)\]>,]+/g;
104
+ const pathRe = /(?:\/home\/|\/tmp\/|\/var\/|\/root\/|\/opt\/)[^\s"'`)\]>,\n]+/g;
87
105
  let match;
106
+ const stale = [];
88
107
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
89
108
  while ((match = pathRe.exec(body)) !== null) {
90
109
  const candidate = match[0];
91
110
  if (!fs.existsSync(candidate)) {
92
- return candidate;
111
+ stale.push(candidate);
93
112
  }
94
113
  }
95
- return null;
114
+ return stale;
115
+ }
116
+ // ── fence-strip helper ────────────────────────────────────────────────────────
117
+ /**
118
+ * Returns `body` with all fenced code block lines replaced by empty strings,
119
+ * so that ref-shaped tokens inside ``` examples are not flagged as broken refs.
120
+ */
121
+ function stripFencedBlocks(body) {
122
+ const lines = body.split(/\r?\n/);
123
+ const regions = findFenceRegions(lines);
124
+ if (regions.length === 0)
125
+ return body;
126
+ for (const { start, end } of regions) {
127
+ for (let i = start; i <= end; i++)
128
+ lines[i] = "";
129
+ }
130
+ return lines.join("\n");
96
131
  }
97
132
  // ── missing-ref helpers ───────────────────────────────────────────────────────
98
133
  /**
@@ -122,9 +157,8 @@ const REF_RE = new RegExp(`(?:^|[\\s\`"'(])((${buildRefTypeAlternation()}):[^\\s
122
157
  * preserved to keep pre-0.9 behaviour byte-identical:
123
158
  * - `script`: returns null (scripts live in nested dirs with arbitrary
124
159
  * extensions — unresolvable by the slug-based walker, as the contract pins).
125
- * - `task`: the registry stores tasks as `<id>.yml`, but the missing-ref
126
- * linter has always resolved `task:` refs against `tasks/<id>.md`; that
127
- * behaviour is held constant here (non-env/secret behaviour is unchanged).
160
+ * - `task`: M1 fix tasks are stored as `<id>.yml` on disk, so resolve
161
+ * `task:` refs against `tasks/<id>.yml` to match actual on-disk layout.
128
162
  *
129
163
  * Exported for contract testing — see header CONTRACT block.
130
164
  */
@@ -132,9 +166,9 @@ export function refToRelPath(refType, refName) {
132
166
  // script is intentionally unresolvable (contract-pinned).
133
167
  if (refType === "script")
134
168
  return null;
135
- // Preserve the legacy `.md` resolution for tasks.
169
+ // M1: tasks are stored as .yml on disk; resolve task: refs against tasks/<id>.yml.
136
170
  if (refType === "task")
137
- return path.join(TYPE_DIRS.task ?? "tasks", `${refName}.md`);
171
+ return path.join(TYPE_DIRS.task ?? "tasks", `${refName}.yml`);
138
172
  const typeDir = TYPE_DIRS[refType];
139
173
  if (!typeDir)
140
174
  return null; // unknown type — skip
@@ -169,9 +203,11 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
169
203
  try {
170
204
  const knowledgeDir = path.join(root, "knowledge");
171
205
  if (fs.existsSync(knowledgeDir) && fs.statSync(knowledgeDir).isDirectory()) {
172
- const entries = fs.readdirSync(knowledgeDir);
206
+ const entries = fs.readdirSync(knowledgeDir, { withFileTypes: true });
173
207
  for (const entry of entries) {
174
- const subPath = path.join(knowledgeDir, entry, `${refName}.md`);
208
+ if (!entry.isDirectory())
209
+ continue;
210
+ const subPath = path.join(knowledgeDir, entry.name, `${refName}.md`);
175
211
  if (fs.existsSync(subPath))
176
212
  return true;
177
213
  }
@@ -207,8 +243,10 @@ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
207
243
  const missing = [];
208
244
  let match;
209
245
  const re = new RegExp(REF_RE.source, REF_RE.flags);
246
+ // C1: Strip fenced code blocks so example refs inside ``` are not flagged.
247
+ const scanBody = stripFencedBlocks(body);
210
248
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
211
- while ((match = re.exec(body)) !== null) {
249
+ while ((match = re.exec(scanBody)) !== null) {
212
250
  const fullRef = match[1]; // e.g. "workflow:foo" or "local//workflow:foo"
213
251
  // Skip shell variables: memory:$(cmd) or knowledge:${VAR}
214
252
  if (fullRef.includes("$(") || fullRef.includes("${")) {
@@ -241,6 +279,10 @@ function checkMissingRefs(body, stashRoot, extraStashRoots = []) {
241
279
  if (refName.length <= 1 || refName === "**") {
242
280
  continue;
243
281
  }
282
+ // Skip template placeholder refs like skill:<name> or workflow:<my-workflow>
283
+ if (refName.startsWith("<") || refName.includes("<")) {
284
+ continue;
285
+ }
244
286
  const relPath = refToRelPath(refType, refName);
245
287
  if (relPath === null)
246
288
  continue; // type is skipped
@@ -279,7 +321,15 @@ function extractFrontmatterRefs(data, body) {
279
321
  const fromInner = readRefsArray(innerData.refs);
280
322
  if (fromInner !== null)
281
323
  return fromInner;
324
+ // Session-checkpoint bodies are raw transcripts; ref-shaped tokens in the
325
+ // body are literals (grep patterns, JSON, tool output), not live refs.
326
+ // Return [] so missing-ref skips the body scan entirely.
327
+ if (typeof innerData.akm_memory_kind === "string")
328
+ return [];
282
329
  }
330
+ // Same guard for outer frontmatter (e.g. opencode session files).
331
+ if (typeof data.akm_memory_kind === "string")
332
+ return [];
283
333
  return null;
284
334
  }
285
335
  function readRefsArray(value) {
@@ -368,6 +418,47 @@ function parseInnerFrontmatterBlock(body) {
368
418
  * (in practice the base class updates `ctx.raw` in place when `fix` is true).
369
419
  */
370
420
  export class BaseLinter {
421
+ /**
422
+ * Check for missing `name` or `type` fields in frontmatter.
423
+ *
424
+ * Returns a detail string if fields are absent/empty, `null` if all present.
425
+ */
426
+ checkMissingNameOrType(data, frontmatterText) {
427
+ if (!frontmatterText)
428
+ return null;
429
+ const missingFields = [];
430
+ if (!("name" in data) || !data.name)
431
+ missingFields.push("name");
432
+ if (!("type" in data) || !data.type)
433
+ missingFields.push("type");
434
+ if (missingFields.length === 0)
435
+ return null;
436
+ return `missing fields: ${missingFields.join(", ")}`;
437
+ }
438
+ /**
439
+ * Validate that the `type` field value is one of an allowed set.
440
+ *
441
+ * Returns a detail string if the value is present but invalid, `null` if valid or absent.
442
+ */
443
+ checkInvalidTypeValue(data, allowedTypes) {
444
+ if (!("type" in data) || !data.type)
445
+ return null; // absent — covered by checkMissingNameOrType
446
+ const value = String(data.type);
447
+ if (allowedTypes.includes(value))
448
+ return null;
449
+ return `type field has invalid value '${value}'; expected one of: ${allowedTypes.join(", ")}`;
450
+ }
451
+ /**
452
+ * Derive a URL-safe slug from a file path.
453
+ */
454
+ suggestSlug(filePath) {
455
+ return path
456
+ .basename(filePath, ".md")
457
+ .toLowerCase()
458
+ .replace(/[^a-z0-9-]+/g, "-")
459
+ .replace(/-+/g, "-")
460
+ .replace(/^-|-$/g, "");
461
+ }
371
462
  /**
372
463
  * Insert one or more lines into a markdown body at a safe location.
373
464
  *
@@ -401,30 +492,41 @@ export class BaseLinter {
401
492
  const issues = [];
402
493
  let currentRaw = ctx.raw;
403
494
  let modified = false;
495
+ // M8: Parse lint_skip from frontmatter for per-file rule suppression.
496
+ // Accept both an array (`lint_skip: [missing-ref, stale-path]`) and a
497
+ // single scalar (`lint_skip: missing-ref`). Non-string entries are coerced
498
+ // and trimmed so loosely-typed YAML still gates correctly.
499
+ const rawLintSkip = ctx.data?.lint_skip;
500
+ const lintSkip = (Array.isArray(rawLintSkip) ? rawLintSkip : rawLintSkip != null ? [rawLintSkip] : [])
501
+ .map((v) => String(v).trim())
502
+ .filter(Boolean);
503
+ const shouldRun = (issueType) => !lintSkip.includes(issueType);
404
504
  // ── 1. unquoted-colon ──────────────────────────────────────────────────
405
- const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
406
- if (unquotedColonDetail) {
407
- if (ctx.fix) {
408
- currentRaw = fixUnquotedColon(currentRaw);
409
- modified = true;
410
- issues.push({
411
- file: ctx.relPath,
412
- issue: "unquoted-colon",
413
- detail: unquotedColonDetail,
414
- fixed: true,
415
- });
416
- }
417
- else {
418
- issues.push({
419
- file: ctx.relPath,
420
- issue: "unquoted-colon",
421
- detail: unquotedColonDetail,
422
- fixed: false,
423
- });
505
+ if (shouldRun("unquoted-colon")) {
506
+ const unquotedColonDetail = checkUnquotedColon(ctx.frontmatter);
507
+ if (unquotedColonDetail) {
508
+ if (ctx.fix) {
509
+ currentRaw = fixUnquotedColon(currentRaw);
510
+ modified = true;
511
+ issues.push({
512
+ file: ctx.relPath,
513
+ issue: "unquoted-colon",
514
+ detail: unquotedColonDetail,
515
+ fixed: true,
516
+ });
517
+ }
518
+ else {
519
+ issues.push({
520
+ file: ctx.relPath,
521
+ issue: "unquoted-colon",
522
+ detail: unquotedColonDetail,
523
+ fixed: false,
524
+ });
525
+ }
424
526
  }
425
- }
527
+ } // end shouldRun("unquoted-colon")
426
528
  // ── 2. missing-updated ─────────────────────────────────────────────────
427
- if (checkMissingUpdated(ctx.data, ctx.frontmatter)) {
529
+ if (shouldRun("missing-updated") && checkMissingUpdated(ctx.data, ctx.frontmatter)) {
428
530
  if (ctx.fix) {
429
531
  let mtime;
430
532
  try {
@@ -457,14 +559,23 @@ export class BaseLinter {
457
559
  ctx.raw = currentRaw;
458
560
  }
459
561
  // ── 3. stale-path ──────────────────────────────────────────────────────
460
- const stalePathMatch = checkStalePath(ctx.body);
461
- if (stalePathMatch) {
462
- issues.push({
463
- file: ctx.relPath,
464
- issue: "stale-path",
465
- detail: `nonexistent path: ${stalePathMatch}`,
466
- fixed: false,
467
- });
562
+ // M3: checkStalePath returns all stale matches; push one issue per path.
563
+ // M4: Also scan ctx.frontmatter for stale paths (absolute paths in frontmatter).
564
+ if (shouldRun("stale-path")) {
565
+ const staleInBody = checkStalePath(ctx.body);
566
+ const staleInFrontmatter = ctx.frontmatter ? checkStalePath(ctx.frontmatter) : [];
567
+ for (const candidate of [...staleInBody, ...staleInFrontmatter]) {
568
+ // M4: Suggest portable replacement when path is under stashRoot.
569
+ const portableHint = candidate.startsWith(ctx.stashRoot)
570
+ ? ` (portable form: $AKM_STASH_DIR${candidate.slice(ctx.stashRoot.length)})`
571
+ : "";
572
+ issues.push({
573
+ file: ctx.relPath,
574
+ issue: "stale-path",
575
+ detail: `nonexistent path: ${candidate}${portableHint}`,
576
+ fixed: false,
577
+ });
578
+ }
468
579
  }
469
580
  // ── 4. missing-ref ─────────────────────────────────────────────────────
470
581
  // Carve-out for assets that declare an explicit `refs:` array in
@@ -481,16 +592,18 @@ export class BaseLinter {
481
592
  // still run `checkMissingRefs` against the array itself to catch
482
593
  // refs that were valid at capture time but later removed from the
483
594
  // stash.
484
- const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
485
- const refSource = explicitRefs !== null ? explicitRefs.join("\n") : ctx.body;
486
- const missingRefs = checkMissingRefs(refSource, ctx.stashRoot, ctx.extraStashRoots);
487
- for (const { ref, resolvedRelPath } of missingRefs) {
488
- issues.push({
489
- file: ctx.relPath,
490
- issue: "missing-ref",
491
- detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
492
- fixed: false,
493
- });
595
+ if (shouldRun("missing-ref")) {
596
+ const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
597
+ const refSource = explicitRefs !== null ? explicitRefs.join("\n") : ctx.body;
598
+ const missingRefs = checkMissingRefs(refSource, ctx.stashRoot, ctx.extraStashRoots);
599
+ for (const { ref, resolvedRelPath } of missingRefs) {
600
+ issues.push({
601
+ file: ctx.relPath,
602
+ issue: "missing-ref",
603
+ detail: `missing ref: ${ref} (resolved to ${resolvedRelPath})`,
604
+ fixed: false,
605
+ });
606
+ }
494
607
  }
495
608
  return issues;
496
609
  }
@@ -1,22 +1,25 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import path from "node:path";
5
4
  import { BaseLinter } from "./base-linter.js";
5
+ /** Valid values for the `type` field in command frontmatter. */
6
+ const VALID_COMMAND_TYPES = ["command"];
6
7
  /**
7
8
  * Linter for `commands/` assets.
8
9
  *
9
- * Extra check beyond base:
10
+ * Extra checks beyond base:
10
11
  * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
11
12
  * absent. Not auto-fixable; detail includes a suggested slug.
13
+ * - `missing-name-or-type` (invalid value): `type` is present but not a
14
+ * recognised command type value.
12
15
  */
13
16
  export class CommandLinter extends BaseLinter {
14
17
  types = ["commands"];
15
18
  lint(ctx) {
16
19
  const issues = this.runBaseChecks(ctx);
17
- const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
20
+ const missingFieldDetail = this.checkMissingNameOrType(ctx.data, ctx.frontmatter);
18
21
  if (missingFieldDetail) {
19
- const slug = this.#suggestSlug(ctx.filePath);
22
+ const slug = this.suggestSlug(ctx.filePath);
20
23
  issues.push({
21
24
  file: ctx.relPath,
22
25
  issue: "missing-name-or-type",
@@ -24,26 +27,18 @@ export class CommandLinter extends BaseLinter {
24
27
  fixed: false,
25
28
  });
26
29
  }
30
+ else {
31
+ // Only validate the value when the field is actually present.
32
+ const invalidTypeDetail = this.checkInvalidTypeValue(ctx.data, VALID_COMMAND_TYPES);
33
+ if (invalidTypeDetail) {
34
+ issues.push({
35
+ file: ctx.relPath,
36
+ issue: "missing-name-or-type",
37
+ detail: invalidTypeDetail,
38
+ fixed: false,
39
+ });
40
+ }
41
+ }
27
42
  return issues;
28
43
  }
29
- #checkMissingNameOrType(data, frontmatterText) {
30
- if (!frontmatterText)
31
- return null;
32
- const missingFields = [];
33
- if (!("name" in data) || !data.name)
34
- missingFields.push("name");
35
- if (!("type" in data) || !data.type)
36
- missingFields.push("type");
37
- if (missingFields.length === 0)
38
- return null;
39
- return `missing fields: ${missingFields.join(", ")}`;
40
- }
41
- #suggestSlug(filePath) {
42
- return path
43
- .basename(filePath, ".md")
44
- .toLowerCase()
45
- .replace(/[^a-z0-9-]+/g, "-")
46
- .replace(/-+/g, "-")
47
- .replace(/^-|-$/g, "");
48
- }
49
44
  }
@@ -24,6 +24,7 @@
24
24
  * where the operator legitimately wants to set their editor — accept the
25
25
  * FP and bypass with `--allow-insecure` after review.
26
26
  */
27
+ import fs from "node:fs";
27
28
  import { listKeys } from "../env/env.js";
28
29
  // ── Dangerous key set ─────────────────────────────────────────────────────────
29
30
  export const DANGEROUS_VAULT_KEYS = new Set([
@@ -122,16 +123,48 @@ export function isDangerousVaultKey(key) {
122
123
  * @param vaultRef Human-readable vault ref (e.g. `"vault:prod"`) shown in
123
124
  * the finding message.
124
125
  */
126
+ /** Suppression comment token checked case-insensitively on the preceding non-empty line. */
127
+ const SUPPRESSION_COMMENT = "# akm-lint-ok: dangerous-vault-key";
128
+ /**
129
+ * Returns the set of keys suppressed by an inline `# akm-lint-ok: dangerous-vault-key`
130
+ * comment on the line immediately preceding the key assignment in the `.env` file.
131
+ */
132
+ function collectSuppressedKeys(vaultPath) {
133
+ const suppressed = new Set();
134
+ let raw;
135
+ try {
136
+ raw = fs.readFileSync(vaultPath, "utf8");
137
+ }
138
+ catch {
139
+ return suppressed;
140
+ }
141
+ const lines = raw.split(/\r?\n/);
142
+ let prevNonEmpty = "";
143
+ for (const line of lines) {
144
+ const trimmed = line.trim();
145
+ if (trimmed === "")
146
+ continue;
147
+ const keyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
148
+ if (keyMatch && prevNonEmpty.toLowerCase() === SUPPRESSION_COMMENT) {
149
+ suppressed.add(keyMatch[1]);
150
+ }
151
+ prevNonEmpty = trimmed;
152
+ }
153
+ return suppressed;
154
+ }
125
155
  export function checkVaultForDangerousKeys(vaultPath, relPath, vaultRef) {
126
156
  const { keys } = listKeys(vaultPath);
157
+ const suppressed = collectSuppressedKeys(vaultPath);
127
158
  const issues = [];
128
159
  for (const key of keys) {
129
160
  if (!isDangerousVaultKey(key))
130
161
  continue;
162
+ if (suppressed.has(key))
163
+ continue;
131
164
  issues.push({
132
165
  file: relPath,
133
166
  issue: "dangerous-vault-key",
134
- detail: `Env key \`${key}\` can be used to hijack process execution when injected via \`akm env run\`. Ref: ${vaultRef}. Review this file before running \`akm env run\` commands against untrusted stashes.`,
167
+ detail: `Env key \`${key}\` can be used to hijack process execution when injected via \`akm env run\`. Ref: ${vaultRef}. Review this file before running \`akm env run\` commands against untrusted stashes. (suppress with: ${SUPPRESSION_COMMENT} on previous line)`,
135
168
  fixed: false,
136
169
  });
137
170
  }
@@ -85,7 +85,8 @@ export function akmLint(options = {}) {
85
85
  const fix = options.fix ?? false;
86
86
  const fixed = [];
87
87
  const flagged = [];
88
- for (const subdir of STASH_SUBDIRS) {
88
+ const dirsToScan = options.typeFilter ? STASH_SUBDIRS.filter((d) => d === options.typeFilter) : STASH_SUBDIRS;
89
+ for (const subdir of dirsToScan) {
89
90
  const dirPath = path.join(stashRoot, subdir);
90
91
  // Tasks are .yml files; everything else is .md
91
92
  const files = subdir === "tasks" ? collectYamlFiles(dirPath) : collectMarkdownFiles(dirPath);
@@ -97,7 +98,9 @@ export function akmLint(options = {}) {
97
98
  if (entry.isDirectory()) {
98
99
  const subdirIssues = linter.lintDirectory(path.join(dirPath, entry.name), stashRoot);
99
100
  for (const issue of subdirIssues) {
100
- if (issue.fixed) {
101
+ // Tristate-safe: only `true` counts as fixed; `false` and "failed"
102
+ // are both flagged.
103
+ if (issue.fixed === true) {
101
104
  fixed.push(issue);
102
105
  }
103
106
  else {
@@ -108,6 +111,9 @@ export function akmLint(options = {}) {
108
111
  }
109
112
  }
110
113
  for (const filePath of files) {
114
+ // Skip registry-cached read-only files — --fix must not mutate them.
115
+ if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
116
+ continue;
111
117
  const relPath = path.relative(stashRoot, filePath);
112
118
  let raw;
113
119
  try {
@@ -142,10 +148,11 @@ export function akmLint(options = {}) {
142
148
  fileDeleted = true;
143
149
  fixed.push(issue);
144
150
  }
145
- else if (issue.fixed) {
151
+ else if (issue.fixed === true) {
146
152
  fixed.push(issue);
147
153
  }
148
154
  else {
155
+ // fixed === false (not fixable / no fix requested) or "failed" (fix attempted but threw)
149
156
  flagged.push(issue);
150
157
  }
151
158
  }
@@ -159,16 +166,26 @@ export function akmLint(options = {}) {
159
166
  // findings go into `flagged`, never `fixed`.
160
167
  const envRoots = [stashRoot, ...extraStashRoots];
161
168
  for (const root of envRoots) {
162
- const dir = path.join(root, "env");
163
- if (!fs.existsSync(dir))
164
- continue;
165
- for (const envPath of collectEnvFiles(dir)) {
166
- const baseName = path.basename(envPath, ".env");
167
- // "default" (or empty) maps to ".env" → env:default
168
- const ref = baseName === "" ? "env:default" : `env:${baseName}`;
169
- const relPath = path.relative(root, envPath);
170
- for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
171
- flagged.push(issue);
169
+ // The `env` assets live under `env/` (ref prefix `env:`); whole-file
170
+ // `secret` assets live under `secrets/` (canonical ref prefix `secret:`,
171
+ // singular). Map the scan directory to its canonical ref prefix so the
172
+ // finding's `Ref:` field matches what `akm show`/`akm secret` accept.
173
+ for (const { scanSubdir, refPrefix } of [
174
+ { scanSubdir: "env", refPrefix: "env" },
175
+ { scanSubdir: "secrets", refPrefix: "secret" },
176
+ ]) {
177
+ const dir = path.join(root, scanSubdir);
178
+ if (!fs.existsSync(dir))
179
+ continue;
180
+ for (const envPath of collectEnvFiles(dir)) {
181
+ const baseName = path.basename(envPath, ".env");
182
+ // A dotfile literally named `.env` has an empty baseName — use the full
183
+ // basename so it doesn't collide with `default.env` → refPrefix:default.
184
+ const ref = baseName === "" ? `${refPrefix}:.env` : `${refPrefix}:${baseName}`;
185
+ const relPath = path.relative(root, envPath);
186
+ for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
187
+ flagged.push(issue);
188
+ }
172
189
  }
173
190
  }
174
191
  }
@@ -33,7 +33,7 @@ export class MemoryLinter extends BaseLinter {
33
33
  file: ctx.relPath,
34
34
  issue: "orphaned-stub",
35
35
  detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
36
- fixed: false,
36
+ fixed: "failed",
37
37
  });
38
38
  }
39
39
  // Signal caller to skip remaining checks via a sentinel issue
@@ -18,15 +18,18 @@ const LINTERS = [
18
18
  new KnowledgeLinter(),
19
19
  new SkillLinter(),
20
20
  new TaskLinter(),
21
- new DefaultLinter(),
22
21
  ];
22
+ // Single shared DefaultLinter instance — used both as the explicit "lessons"
23
+ // handler and as the fallback for any unrecognised asset type.
24
+ const DEFAULT_LINTER = new DefaultLinter();
23
25
  const LINTER_MAP = new Map();
24
26
  for (const linter of LINTERS) {
25
27
  for (const t of linter.types) {
26
28
  LINTER_MAP.set(t, linter);
27
29
  }
28
30
  }
29
- const DEFAULT_LINTER = new DefaultLinter();
31
+ // Register "lessons" explicitly so there is only one DefaultLinter instance.
32
+ LINTER_MAP.set("lessons", DEFAULT_LINTER);
30
33
  /**
31
34
  * Return the appropriate linter for the given stash subdirectory name.
32
35
  * Falls back to `DefaultLinter` for unknown types.
@@ -28,9 +28,9 @@ export class TaskLinter extends BaseLinter {
28
28
  if (!("schedule" in ctx.data) || typeof ctx.data.schedule !== "string" || ctx.data.schedule.trim() === "") {
29
29
  missing.push("schedule");
30
30
  }
31
- // enabled: must be present (boolean value of false is valid)
32
- if (!("enabled" in ctx.data)) {
33
- missing.push("enabled");
31
+ // enabled: must be present AND a boolean (value of false is valid, but "yes" / 1 are not)
32
+ if (!("enabled" in ctx.data) || typeof ctx.data.enabled !== "boolean") {
33
+ missing.push("enabled (must be a boolean)");
34
34
  }
35
35
  // At least one of: prompt, workflow, or command
36
36
  const hasTarget = "prompt" in ctx.data || "workflow" in ctx.data || "command" in ctx.data;
@@ -2,6 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
+ import { parseWorkflow } from "../../workflows/parser.js";
5
6
  import { BaseLinter } from "./base-linter.js";
6
7
  const PLACEHOLDER_STRINGS = ["Describe what this workflow accomplishes", "Example Workflow"];
7
8
  /**
@@ -32,7 +33,7 @@ export class WorkflowLinter extends BaseLinter {
32
33
  file: ctx.relPath,
33
34
  issue: "placeholder-stub",
34
35
  detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
35
- fixed: false,
36
+ fixed: "failed",
36
37
  });
37
38
  }
38
39
  return issues;
@@ -44,6 +45,30 @@ export class WorkflowLinter extends BaseLinter {
44
45
  fixed: false,
45
46
  });
46
47
  }
48
+ const isReadOnly = ctx.filePath.includes("/.cache/") || ctx.filePath.includes("/registry/");
49
+ if (!isReadOnly) {
50
+ try {
51
+ const result = parseWorkflow(ctx.raw, { path: ctx.filePath });
52
+ if (!result.ok) {
53
+ for (const err of result.errors ?? []) {
54
+ issues.push({
55
+ file: ctx.relPath,
56
+ issue: "invalid-workflow-structure",
57
+ detail: err.message ?? String(err),
58
+ fixed: false,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ catch (e) {
64
+ issues.push({
65
+ file: ctx.relPath,
66
+ issue: "invalid-workflow-structure",
67
+ detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
68
+ fixed: false,
69
+ });
70
+ }
71
+ }
47
72
  return issues;
48
73
  }
49
74
  #checkPlaceholderStub(body) {