akm-cli 0.7.5 → 0.8.0-rc2

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 (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -1,20 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { deriveCanonicalAssetName, deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, } from "../core/asset-spec";
4
- import { isAssetType } from "../core/common";
4
+ import { isAssetType, writeFileAtomic } from "../core/common";
5
5
  import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
6
6
  import { isVerbose, warn } from "../core/warn";
7
7
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "./file-context";
8
+ import { applyMetadataContributors } from "./metadata-contributors";
8
9
  export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
9
10
  // ── Load / Write ────────────────────────────────────────────────────────────
10
11
  const STASH_FILENAME = ".stash.json";
11
12
  // ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
12
13
  /**
13
- * Well-known quality values. `generated` and `curated` are included in
14
+ * Well-known quality values. `generated`, `curated`, and `enriched` are included in
14
15
  * default search; `proposed` is excluded by default and opt-in via
15
16
  * `--include-proposed`. Unknown values warn once and remain searchable.
16
17
  */
17
- export const KNOWN_QUALITY_VALUES = new Set(["generated", "curated", "proposed"]);
18
+ export const KNOWN_QUALITY_VALUES = new Set(["generated", "curated", "enriched", "proposed"]);
18
19
  /** Tracks unknown quality values we've already warned about (one warn per value per process). */
19
20
  const warnedUnknownQualityValues = new Set();
20
21
  /**
@@ -80,20 +81,7 @@ export function loadStashFile(dirPath, options) {
80
81
  }
81
82
  export function writeStashFile(dirPath, stash) {
82
83
  const filePath = stashFilePath(dirPath);
83
- const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
84
- try {
85
- fs.writeFileSync(tmpPath, `${JSON.stringify(stash, null, 2)}\n`, "utf8");
86
- fs.renameSync(tmpPath, filePath);
87
- }
88
- catch (err) {
89
- try {
90
- fs.unlinkSync(tmpPath);
91
- }
92
- catch {
93
- /* ignore cleanup failure */
94
- }
95
- throw err;
96
- }
84
+ writeFileAtomic(filePath, `${JSON.stringify(stash, null, 2)}\n`);
97
85
  }
98
86
  /**
99
87
  * Validate and normalize a raw object into a `StashEntry`.
@@ -199,6 +187,30 @@ export function validateStashEntry(entry) {
199
187
  if (filtered.length > 0)
200
188
  result.sources = filtered;
201
189
  }
190
+ if (typeof e.beliefState === "string" && e.beliefState.trim().length > 0) {
191
+ result.beliefState = e.beliefState.trim();
192
+ }
193
+ if (Array.isArray(e.supersededBy)) {
194
+ const filtered = e.supersededBy
195
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
196
+ .map((s) => s.trim());
197
+ if (filtered.length > 0)
198
+ result.supersededBy = filtered;
199
+ }
200
+ if (Array.isArray(e.contradictedBy)) {
201
+ const filtered = e.contradictedBy
202
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
203
+ .map((s) => s.trim());
204
+ if (filtered.length > 0)
205
+ result.contradictedBy = filtered;
206
+ }
207
+ if (Array.isArray(e.currentBeliefRefs)) {
208
+ const filtered = e.currentBeliefRefs
209
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
210
+ .map((s) => s.trim());
211
+ if (filtered.length > 0)
212
+ result.currentBeliefRefs = filtered;
213
+ }
202
214
  if (typeof e.scope === "object" && e.scope !== null && !Array.isArray(e.scope)) {
203
215
  const scope = normalizeScopeObject(e.scope);
204
216
  if (scope)
@@ -323,6 +335,18 @@ export function applyCuratedFrontmatter(entry, fmData) {
323
335
  const quality = toStringOrUndefined(fmData.quality);
324
336
  if (quality)
325
337
  entry.quality = normalizeQuality(quality);
338
+ const beliefState = toStringOrUndefined(fmData.beliefState);
339
+ if (beliefState)
340
+ entry.beliefState = beliefState;
341
+ const supersededBy = normalizeStringListOrUndefined(fmData.supersededBy);
342
+ if (supersededBy)
343
+ entry.supersededBy = supersededBy;
344
+ const contradictedBy = normalizeStringListOrUndefined(fmData.contradictedBy);
345
+ if (contradictedBy)
346
+ entry.contradictedBy = contradictedBy;
347
+ const currentBeliefRefs = normalizeStringListOrUndefined(fmData.currentBeliefRefs);
348
+ if (currentBeliefRefs)
349
+ entry.currentBeliefRefs = currentBeliefRefs;
326
350
  const intent = normalizeIntent(fmData.intent);
327
351
  if (intent)
328
352
  entry.intent = intent;
@@ -416,6 +440,12 @@ export function shouldIndexStashFile(stashRoot, file, options) {
416
440
  const segments = relPath.split(/[\\/]+/).filter(Boolean);
417
441
  if (segments.length === 0)
418
442
  return true;
443
+ // Skip vault .env files that have a sibling .sensitive marker file.
444
+ if (segments[0] === "vaults" && (file.endsWith(".env") || path.basename(file) === ".env")) {
445
+ const markerPath = file.replace(/\.env$/, ".sensitive");
446
+ if (fs.existsSync(markerPath))
447
+ return false;
448
+ }
419
449
  if (options?.treatStashRootAsWikiRoot) {
420
450
  return !(segments.length === 1 && WIKI_INFRA_FILES.has(segments[0]));
421
451
  }
@@ -681,7 +711,137 @@ function mergeAliases(existing, generated) {
681
711
  const merged = normalizeTerms([...(existing ?? []), ...generated]);
682
712
  return merged.length > 0 ? merged : undefined;
683
713
  }
714
+ // ── Enrichment Completeness ─────────────────────────────────────────────────
715
+ /**
716
+ * Returns `true` when a stash entry already has enough LLM-quality metadata
717
+ * that calling the LLM would produce no meaningful improvement.
718
+ *
719
+ * An entry is considered complete when ALL of the following hold:
720
+ * - `description` is a non-empty string
721
+ * - `tags` is a non-empty array
722
+ * - `searchHints` is a non-empty array
723
+ *
724
+ * This predicate is used by `enhanceDirsWithLlm` to skip the LLM call for
725
+ * entries that were previously enriched and already carry all three fields.
726
+ * Pass `reEnrich = true` in the caller to bypass this check.
727
+ */
728
+ export function isEnrichmentComplete(entry) {
729
+ const hasDescription = typeof entry.description === "string" && entry.description.trim().length > 0;
730
+ const hasTags = Array.isArray(entry.tags) && entry.tags.length > 0;
731
+ const hasSearchHints = Array.isArray(entry.searchHints) && entry.searchHints.length > 0;
732
+ return hasDescription && hasTags && hasSearchHints;
733
+ }
684
734
  // ── Metadata Generation ─────────────────────────────────────────────────────
735
+ /**
736
+ * Shared pipeline (steps 2-6) for building a single StashEntry from a file.
737
+ *
738
+ * Both `generateMetadata` and `generateMetadataFlat` perform identical work
739
+ * once the initial `entry` object has been seeded with type and canonical name.
740
+ * This helper encapsulates that shared pipeline so the two callers only differ
741
+ * in how they determine the asset type and canonical name (step 1):
742
+ *
743
+ * - `generateMetadata` — explicit `assetType` arg + `deriveCanonicalAssetName`
744
+ * - `generateMetadataFlat` — type from `runMatchers()` + `deriveCanonicalAssetNameFromStashRoot`
745
+ *
746
+ * @param file Absolute path to the file being processed.
747
+ * @param assetType Resolved asset type string (already validated by caller).
748
+ * @param canonicalName Resolved canonical name (already computed by caller).
749
+ * @param dirPath Directory containing the file (used for tag fallback).
750
+ * @param pkgMeta Pre-loaded package.json metadata for this directory (may be null/undefined).
751
+ * @param stashRoot Stash root used for renderer search hints context.
752
+ * @param ctx FileContext for the file (may be pre-built by the caller).
753
+ * @param match Pre-resolved MatchResult when available (from `generateMetadataFlat`).
754
+ * @returns The populated entry, or `{ skip: true, warning: string }` when the
755
+ * renderer throws and the file should be dropped.
756
+ */
757
+ async function buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMeta, stashRoot, ctx, match) {
758
+ const ext = path.extname(file).toLowerCase();
759
+ const baseName = path.basename(file, ext);
760
+ const entry = {
761
+ name: canonicalName,
762
+ type: assetType,
763
+ quality: "generated",
764
+ confidence: 0.55,
765
+ source: "filename",
766
+ };
767
+ // Priority 1: Package.json metadata
768
+ if (pkgMeta) {
769
+ if (pkgMeta.description && !entry.description) {
770
+ entry.description = pkgMeta.description;
771
+ entry.source = "package";
772
+ entry.confidence = 0.8;
773
+ }
774
+ if (pkgMeta.keywords && pkgMeta.keywords.length > 0)
775
+ entry.tags = normalizeTerms(pkgMeta.keywords);
776
+ }
777
+ // Priority 2: Frontmatter (for .md files -- overrides package.json description)
778
+ if (ext === ".md") {
779
+ const content = ctx.content();
780
+ const parsed = parseFrontmatter(content);
781
+ applyCuratedFrontmatter(entry, parsed.data);
782
+ // Extract parameters from frontmatter params: key
783
+ const fmParams = extractFrontmatterParameters(parsed.data);
784
+ if (fmParams)
785
+ entry.parameters = fmParams;
786
+ // Pass wiki-pattern frontmatter through onto the entry
787
+ applyWikiFrontmatter(entry, parsed.data);
788
+ // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
789
+ if (entry.type === "command") {
790
+ const cmdParams = extractCommandParameters(parsed.content);
791
+ if (cmdParams) {
792
+ entry.parameters = mergeParameters(entry.parameters, cmdParams);
793
+ }
794
+ }
795
+ }
796
+ // Extract @param from script files.
797
+ // Vault files (.env) are deliberately excluded — their contents are secrets
798
+ // and must never be parsed for @param or any other metadata that could
799
+ // embed a value into the entry.
800
+ if (ext !== ".md" && assetType !== "vault") {
801
+ const content = ctx.content();
802
+ const scriptParams = extractScriptParameters(file, content);
803
+ if (scriptParams)
804
+ entry.parameters = scriptParams;
805
+ applyCommentMetadata(entry, extractCommentMetadata(file, content));
806
+ }
807
+ // Priority 3: Renderer metadata extraction
808
+ // When no pre-resolved match is available (generateMetadata path), run
809
+ // matchers now so the renderer can extract type-specific metadata.
810
+ const resolvedMatch = match ?? (await runMatchers(ctx));
811
+ if (resolvedMatch) {
812
+ const renderer = await getRenderer(resolvedMatch.renderer);
813
+ if (renderer) {
814
+ const renderCtx = buildRenderContext(ctx, resolvedMatch, [stashRoot]);
815
+ try {
816
+ await applyMetadataContributors(entry, {
817
+ rendererName: renderer.name,
818
+ renderContext: renderCtx,
819
+ });
820
+ }
821
+ catch (error) {
822
+ return {
823
+ skip: true,
824
+ warning: buildMetadataSkipWarning(file, assetType, error),
825
+ };
826
+ }
827
+ }
828
+ }
829
+ // Priority 4: Filename heuristics (fallback)
830
+ if (!entry.description) {
831
+ entry.description = fileNameToDescription(baseName);
832
+ entry.source = "filename";
833
+ entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
834
+ }
835
+ if (!entry.tags || entry.tags.length === 0) {
836
+ entry.tags = extractTagsFromPath(file, dirPath);
837
+ }
838
+ entry.tags = normalizeTerms(entry.tags ?? []);
839
+ entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
840
+ // Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
841
+ // Heuristic search hints are too noisy to be useful for search quality
842
+ entry.filename = path.basename(file);
843
+ return entry;
844
+ }
685
845
  export async function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
686
846
  const entries = [];
687
847
  const warnings = [];
@@ -694,84 +854,16 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
694
854
  if (!isRelevantAssetFile(assetType, fileName))
695
855
  continue;
696
856
  const canonicalName = deriveCanonicalAssetName(assetType, typeRoot, file) ?? baseName;
697
- const entry = {
698
- name: canonicalName,
699
- type: assetType,
700
- quality: "generated",
701
- confidence: 0.55,
702
- source: "filename",
703
- };
704
- // Priority 1: Package.json metadata
705
- if (pkgMeta) {
706
- if (pkgMeta.description && !entry.description) {
707
- entry.description = pkgMeta.description;
708
- entry.source = "package";
709
- entry.confidence = 0.8;
710
- }
711
- if (pkgMeta.keywords && pkgMeta.keywords.length > 0)
712
- entry.tags = normalizeTerms(pkgMeta.keywords);
713
- }
714
- // Priority 2: Frontmatter (for .md files -- overrides package.json description)
715
- if (ext === ".md") {
716
- const content = fs.readFileSync(file, "utf8");
717
- const parsed = parseFrontmatter(content);
718
- applyCuratedFrontmatter(entry, parsed.data);
719
- // Extract parameters from frontmatter params: key
720
- const fmParams = extractFrontmatterParameters(parsed.data);
721
- if (fmParams)
722
- entry.parameters = fmParams;
723
- // Pass wiki-pattern frontmatter through onto the entry
724
- applyWikiFrontmatter(entry, parsed.data);
725
- // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
726
- if (entry.type === "command") {
727
- const cmdParams = extractCommandParameters(parsed.content);
728
- if (cmdParams) {
729
- entry.parameters = mergeParameters(entry.parameters, cmdParams);
730
- }
731
- }
732
- }
733
- // Extract @param from script files.
734
- // Vault files (.env) are deliberately excluded — their contents are secrets
735
- // and must never be parsed for @param or any other metadata that could
736
- // embed a value into the entry.
737
- if (ext !== ".md" && assetType !== "vault") {
738
- const content = fs.readFileSync(file, "utf8");
739
- const scriptParams = extractScriptParameters(file, content);
740
- if (scriptParams)
741
- entry.parameters = scriptParams;
742
- applyCommentMetadata(entry, extractCommentMetadata(file, content));
743
- }
744
- // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
857
+ // Build file context with typeRoot as the stash root so renderer context
858
+ // and search hints are scoped to the type directory.
745
859
  const fileCtx = buildFileContext(typeRoot, file);
746
- const match = await runMatchers(fileCtx);
747
- if (match) {
748
- const renderer = await getRenderer(match.renderer);
749
- if (renderer?.extractMetadata) {
750
- const renderCtx = buildRenderContext(fileCtx, match, [typeRoot]);
751
- try {
752
- renderer.extractMetadata(entry, renderCtx);
753
- }
754
- catch (error) {
755
- warnings.push(buildMetadataSkipWarning(file, assetType, error));
756
- continue;
757
- }
758
- }
759
- }
760
- // Priority 4: Filename heuristics (fallback)
761
- if (!entry.description) {
762
- entry.description = fileNameToDescription(baseName);
763
- entry.source = "filename";
764
- entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
765
- }
766
- if (!entry.tags || entry.tags.length === 0) {
767
- entry.tags = extractTagsFromPath(file, dirPath);
860
+ // Step 1: type is explicit; delegate steps 2-6 to the shared pipeline.
861
+ const result = await buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMeta, typeRoot, fileCtx, null);
862
+ if ("skip" in result) {
863
+ warnings.push(result.warning);
864
+ continue;
768
865
  }
769
- entry.tags = normalizeTerms(entry.tags ?? []);
770
- entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
771
- // Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
772
- // Heuristic search hints are too noisy to be useful for search quality
773
- entry.filename = path.basename(file);
774
- entries.push(entry);
866
+ entries.push(result);
775
867
  }
776
868
  return warnings.length > 0 ? { entries, warnings } : { entries };
777
869
  }
@@ -789,6 +881,7 @@ export async function generateMetadataFlat(stashRoot, files) {
789
881
  for (const file of files) {
790
882
  if (!shouldIndexStashFile(stashRoot, file))
791
883
  continue;
884
+ // Step 1: determine type and canonical name via the matcher system.
792
885
  const ctx = buildFileContext(stashRoot, file);
793
886
  const match = await runMatchers(ctx);
794
887
  if (!match)
@@ -802,83 +895,20 @@ export async function generateMetadataFlat(stashRoot, files) {
802
895
  const ext = path.extname(file).toLowerCase();
803
896
  const baseName = path.basename(file, ext);
804
897
  const canonicalName = deriveCanonicalAssetNameFromStashRoot(assetType, stashRoot, file) ?? baseName;
805
- const entry = {
806
- name: canonicalName,
807
- type: assetType,
808
- quality: "generated",
809
- confidence: 0.55,
810
- source: "filename",
811
- };
812
- // Package.json metadata
898
+ // Resolve package.json metadata with a per-directory cache.
813
899
  const dirPath = path.dirname(file);
814
900
  if (!pkgMetaCache.has(dirPath)) {
815
901
  pkgMetaCache.set(dirPath, extractPackageMetadata(dirPath));
816
902
  }
817
903
  const pkgMeta = pkgMetaCache.get(dirPath);
818
- if (pkgMeta) {
819
- if (pkgMeta.description && !entry.description) {
820
- entry.description = pkgMeta.description;
821
- entry.source = "package";
822
- entry.confidence = 0.8;
823
- }
824
- if (pkgMeta.keywords?.length)
825
- entry.tags = normalizeTerms(pkgMeta.keywords);
826
- }
827
- // Frontmatter
828
- if (ext === ".md") {
829
- const content = ctx.content();
830
- const parsed = parseFrontmatter(content);
831
- applyCuratedFrontmatter(entry, parsed.data);
832
- // Extract parameters from frontmatter params: key
833
- const fmParams = extractFrontmatterParameters(parsed.data);
834
- if (fmParams)
835
- entry.parameters = fmParams;
836
- // Pass wiki-pattern frontmatter through onto the entry
837
- applyWikiFrontmatter(entry, parsed.data);
838
- // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
839
- if (entry.type === "command") {
840
- const cmdParams = extractCommandParameters(parsed.content);
841
- if (cmdParams) {
842
- entry.parameters = mergeParameters(entry.parameters, cmdParams);
843
- }
844
- }
845
- }
846
- // Extract @param from script files.
847
- // Vault files (.env) are deliberately excluded — their contents are secrets
848
- // and must never be parsed for @param or any other metadata that could
849
- // embed a value into the entry.
850
- if (ext !== ".md" && assetType !== "vault") {
851
- const content = ctx.content();
852
- const scriptParams = extractScriptParameters(file, content);
853
- if (scriptParams)
854
- entry.parameters = scriptParams;
855
- applyCommentMetadata(entry, extractCommentMetadata(file, content));
856
- }
857
- // Renderer metadata extraction
858
- const renderer = await getRenderer(match.renderer);
859
- if (renderer?.extractMetadata) {
860
- const renderCtx = buildRenderContext(ctx, match, [stashRoot]);
861
- try {
862
- renderer.extractMetadata(entry, renderCtx);
863
- }
864
- catch (error) {
865
- warnings.push(buildMetadataSkipWarning(file, assetType, error));
866
- continue;
867
- }
868
- }
869
- // Filename heuristics fallback
870
- if (!entry.description) {
871
- entry.description = fileNameToDescription(baseName);
872
- entry.source = "filename";
873
- entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
874
- }
875
- if (!entry.tags || entry.tags.length === 0) {
876
- entry.tags = extractTagsFromPath(file, dirPath);
904
+ // Steps 2-6: delegate to the shared pipeline; pass the pre-resolved match
905
+ // so we don't run matchers a second time.
906
+ const result = await buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMeta, stashRoot, ctx, match);
907
+ if ("skip" in result) {
908
+ warnings.push(result.warning);
909
+ continue;
877
910
  }
878
- entry.tags = normalizeTerms(entry.tags ?? []);
879
- entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
880
- entry.filename = path.basename(file);
881
- entries.push(entry);
911
+ entries.push(result);
882
912
  }
883
913
  return warnings.length > 0 ? { entries, warnings } : { entries };
884
914
  }
@@ -980,17 +1010,6 @@ export function extractDescriptionFromComments(filePath) {
980
1010
  return hashLines.join(" ");
981
1011
  return null;
982
1012
  }
983
- export function extractFrontmatterDescription(filePath) {
984
- let content;
985
- try {
986
- content = fs.readFileSync(filePath, "utf8");
987
- }
988
- catch {
989
- return null;
990
- }
991
- const parsed = parseFrontmatter(content);
992
- return toStringOrUndefined(parsed.data.description) ?? null;
993
- }
994
1013
  export function extractPackageMetadata(dirPath) {
995
1014
  const pkgPath = path.join(dirPath, "package.json");
996
1015
  if (!fs.existsSync(pkgPath))
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseAssetRef } from "../core/asset-ref";
4
+ import { resolveAssetPathFromName, TYPE_DIRS } from "../core/asset-spec";
5
+ import { isWithin } from "../core/common";
6
+ import { resolveSourcesForOrigin } from "../registry/origin-resolve";
7
+ import { lookup } from "./indexer";
8
+ import { resolveSourceEntries } from "./search-source";
9
+ function normalizeRef(ref) {
10
+ return typeof ref === "string" ? parseAssetRef(ref) : ref;
11
+ }
12
+ function buildDiskCandidates(sourcePath, ref, preserveDirectNameFallback) {
13
+ const typeDir = path.join(sourcePath, TYPE_DIRS[ref.type] ?? `${ref.type}s`);
14
+ const candidates = [
15
+ resolveAssetPathFromName(ref.type, typeDir, ref.name),
16
+ path.join(sourcePath, ref.type, `${ref.name}.md`),
17
+ path.join(sourcePath, ref.type, ref.name),
18
+ ];
19
+ if (preserveDirectNameFallback) {
20
+ candidates.push(path.join(sourcePath, `${ref.name}.md`), path.join(sourcePath, ref.name));
21
+ }
22
+ return candidates;
23
+ }
24
+ function resolveDirectoryEntry(filePath, directoryIndexNames) {
25
+ let stat;
26
+ try {
27
+ stat = fs.statSync(filePath);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ if (stat.isFile())
33
+ return filePath;
34
+ if (!stat.isDirectory())
35
+ return null;
36
+ for (const indexName of directoryIndexNames) {
37
+ const candidate = path.join(filePath, indexName);
38
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
39
+ return candidate;
40
+ }
41
+ return null;
42
+ }
43
+ async function resolveViaIndex(ref) {
44
+ try {
45
+ const entry = await lookup(ref);
46
+ return entry?.filePath ?? null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function resolveViaDisk(ref, options) {
53
+ let sources = resolveSourceEntries(options.stashDir);
54
+ if (options.honorOrigin !== false) {
55
+ sources = resolveSourcesForOrigin(ref.origin, sources);
56
+ }
57
+ const directoryIndexNames = options.directoryIndexNames ?? ["SKILL.md"];
58
+ const preserveDirectNameFallback = options.preserveDirectNameFallback ?? true;
59
+ for (const source of sources) {
60
+ if (options.writableDirSet && !options.writableDirSet.has(path.resolve(source.path)))
61
+ continue;
62
+ const candidates = buildDiskCandidates(source.path, ref, preserveDirectNameFallback);
63
+ for (const candidate of candidates) {
64
+ if (!fs.existsSync(candidate))
65
+ continue;
66
+ const resolved = resolveDirectoryEntry(candidate, directoryIndexNames);
67
+ if (!resolved)
68
+ continue;
69
+ const resolvedRoot = fs.realpathSync(source.path);
70
+ const realTarget = fs.realpathSync(resolved);
71
+ if (!isWithin(realTarget, resolvedRoot))
72
+ continue;
73
+ return realTarget;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ export async function resolveAssetPath(ref, options = {}) {
79
+ const parsed = normalizeRef(ref);
80
+ const mode = options.mode ?? "index-first";
81
+ if (mode !== "disk-only") {
82
+ const indexed = await resolveViaIndex(parsed);
83
+ if (indexed)
84
+ return indexed;
85
+ if (mode === "index-only")
86
+ return null;
87
+ }
88
+ return resolveViaDisk(parsed, options);
89
+ }