akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
600 lines (599 loc) • 28.9 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* The AKM workspace's own `akm` adapter — akm 0.9.0 chunk-2, WI-B.
*
* Implements `docs/architecture/specs/akm-0.9.0-bundle-adapter-spec.md` §5.1 (BINDING) +
* §6 + §7 (the `akm` row) EXACTLY: a BEHAVIOR-PRESERVING PORT that reproduces
* TODAY'S classification and placement VERBATIM by REUSING the existing matcher
* stack. It is NOT re-derived onto frontmatter `type` (that is the sibling
* `okf` adapter's job, §5), NOT split per-`type` (the 14 AKM formats are `type`
* VALUES the single `akm` adapter emits, §6/§0.2), and introduces NO new
* positional/directory heuristics of its own.
*
* ── recognize (§5.1): the sync `runMatchers` reproduction ──
*
* `file-context.ts#runMatchers` (`:242-265`) is async ONLY because of its lazy
* `ensureBuiltinsRegistered()` dynamic import; its ARBITRATION is pure and
* synchronous. We reproduce it here by importing the SAME four builtin matcher
* functions (`matchers.ts` `extensionMatcher` / `directoryMatcher` /
* `parentDirHintMatcher` / `smartMdMatcher`) in the
* SAME order they are registered, collecting every non-null
* `MatchResult` and picking the winner by **specificity descending, ties broken
* by later-registered (higher index) winning** — byte-identical to
* `runMatchers`'s `b.specificity - a.specificity` then `b.index - a.index`.
* Because the matcher functions (which resolve renderer names via the static
* `TYPE_PRESENTATION` table in `type-presentation.ts`, all built-in types
* present at load) are the exact ones `runMatchers` uses, {@link recognizeMatch}
* agrees with the async `runMatchers` on `type` / `specificity` / `renderer` for
* every input. This is "use the existing matchers" literally — same functions,
* same arbitration.
*
* The winning `MatchResult.renderer` is carried on `documentJson.renderer` so
* WI-C can wire presentation via `TYPE_PRESENTATION` without a new
* `IndexDocument` field (WI-C owns presentation).
*
* ── conceptId (§5.1 + ref-grammar decision D-R2): the QUALIFIED spelling ──
*
* conceptId = `<stash-subdir>/<canonical-name>` — the placement stash-subdir
* (`stashDirFor(type)`) followed by the winning type's canonical name
* (`deriveCanonicalAssetNameFromStashRoot`: skill = its dir, script keeps its
* extension, Markdown strips `.md`, workflow strips its peer `.md`/`.yml`
* extension, env/task strip their ext, and secret/session keep the natural
* path). For Markdown types this IS the OKF concept ID (path − `.md`); it is
* the same spelling {@link placeNew} consumes, so
* recognize/place share one identity (D-R2 resolved the earlier split). The
* `type` is carried separately on `IndexDocument.type`, per §0.2 (type ≠
* identity), and `entry.name` keeps the BARE canonical name for FTS parity.
*
* ── placeNew / directoryList / looksLikeRoot: type-driven placement ──
*
* Placement is type-driven (`path-resolver.ts#buildDiskCandidates`: the primary
* candidate is `assetPathForName(ref.type, join(root, stashDirFor(ref.type)),
* ref.name)`). §5.1 removes `type` from the ref, so {@link placeNew} recovers
* it from the conceptId's LEADING path segment — the qualified `<stash-subdir>/
* <name>` form of §1.3 — reverse-mapping that segment against the placement
* stash-subdir map and then delegating to `assetPathForName`. See the method
* doc for the recognize/place conceptId-spelling note.
*
* ── validate: base checks only (WI-B) ──
*
* Base validate checks only for this WI (shared `validateChangesWithBaseChecks`
* — the ported unquoted-colon / missing-updated / stale-path / missing-ref).
* The per-type linters (SkillLinter/WorkflowLinter/TaskLinter/…) are WI-C.
*
* ── Cycle-safety (chunk-3 cutover, baseline 18) ──
*
* This module VALUE-imports `matchers.ts` (indexer/walk) for classification and
* `asset-placement.ts` (core/asset) for placement — delegating to the existing
* logic, not re-implementing it. Neither is a taxonomy import-cycle (SCC)
* participant: `matchers.ts` resolves renderers via `type-presentation.ts`, and
* `asset-placement.ts` is a pure leaf (only Node builtins + `recognition-util`).
* Nothing in `src/` imports this adapter back except the `adapters/index.ts`
* barrel — which is NOT test-only: `core/adapter/registry.ts:33` imports it
* in production for the frozen `BUILTIN_ADAPTERS` list
* (`installations.ts#detectAdapterId`, `provider-utils.ts#detectStashRoot`).
* The adapter is still itself a leaf, since neither the barrel nor the
* registry import anything that imports back into this file — verified: `bun
* scripts/lint-import-cycles.ts` reports 0 cycle participants and this module
* is NOT one.
*/
import fs from "node:fs";
import path from "node:path";
import { applyPostContributorFields, applyPreContributorFields, extractPackageMetadata, getMarkdownFragmentContent, hasMarkdownFragmentContent, setMarkdownFragmentContent, } from "../../../indexer/passes/metadata.js";
import { assetPathCandidatesForName, assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
import { parseFrontmatter } from "../../asset/frontmatter.js";
import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js";
import { recognizeMatch } from "../recognize-match.js";
import { perTypeValidateChecks, skillDirectoryDiagnostics, workflowYamlSourceDiagnostics } from "./akm-lint.js";
import { applyFoldedMetadata, foldRecognizedMetadata } from "./akm-metadata.js";
import { hashContent, RESERVED_FILES, runBaseValidateChecks } from "./shared.js";
// `recognizeMatch` + the builtin matcher list moved to the cycle-free leaf
// `../recognize-match` (Chunk 5 M-b) so both this adapter AND the indexer
// metadata pass import it without closing a metadata ↔ adapter cycle. Re-exported
// here so existing `akm-adapter` importers (tests, sibling modules) are
// unaffected.
export { recognizeMatch } from "../recognize-match.js";
/**
* OKF reserved structural files (ref-grammar decision D-R6, spec §5.1 /
* adapter-spec §5.1/§6): `index.md` (directory listing) and `log.md` (update
* history) are bundle structure at EVERY depth — never a concept document. The
* `okf` / `llm-wiki` adapters already exclude them (`shared.ts`'s
* `RESERVED_FILES`); this brings the `akm` adapter into conformance so a
* `knowledge/index.md` never classifies as a `knowledge` item. Case-insensitive,
* matched on the bare filename so the exclusion holds at any depth.
*/
/** True when `name` (a bare file name) is an OKF reserved file, case-insensitively (D-R6). */
function isReservedFileName(name) {
return RESERVED_FILES.has(name.toLowerCase());
}
/** Wiki-directory infrastructure files excluded at a `wikis/<name>/` root (ported from the retired `shouldIndexStashFile`). */
const WIKI_INFRA_FILES = new Set(["schema.md", "index.md", "log.md"]);
/**
* AKM-stash indexing policy, moved here from the indexer's `shouldIndexStashFile`
* pre-filter (owner ruling 2026-07-21 — adapter-owned filtering). Each adapter
* abstains on its own bundle's files; the core walk keeps only universal hygiene.
*
* PATH/STAT-based ONLY — never reads `file.content()`, so the bytes-never-read
* invariant for sensitive env/secret files holds by construction. Returns `true`
* when the `akm` adapter must ABSTAIN (recognize → null):
* - an `env/…` `.env` file with a sibling `.sensitive` marker;
* - a `secrets/` `.sensitive`/`.lock` marker, or a secret with a sibling
* `<name>.sensitive` marker;
* - a `wikis/<name>/` root-level infra file (schema.md/index.md/log.md).
*/
function akmStashAbstains(root, absPath) {
const relPath = path.relative(root, absPath);
if (!relPath || relPath.startsWith("..") || path.isAbsolute(relPath))
return false;
const segments = relPath.split(/[\\/]+/).filter(Boolean);
if (segments.length === 0)
return false;
// A bundle's root README describes the bundle itself; it is not an AKM
// knowledge asset. Nested README files remain adapter-owned documentation
// and are handled by the normal typed-directory matcher policy.
if (segments.length === 1 && segments[0]?.toLowerCase() === "readme.md")
return true;
// Skip env `.env` files that have a sibling `.sensitive` marker file.
if (segments[0] === "env" && (absPath.endsWith(".env") || path.basename(absPath) === ".env")) {
if (fs.existsSync(absPath.replace(/\.env$/, ".sensitive")))
return true;
}
// Skip secret files that are themselves a `.sensitive` marker, or that have a
// sibling `<name>.sensitive` marker. Secrets are otherwise indexed by name
// only (their bytes are never read for classification here).
if (segments[0] === "secrets") {
if (absPath.endsWith(".sensitive") || absPath.endsWith(".lock"))
return true;
if (fs.existsSync(`${absPath}.sensitive`))
return true;
}
// Inside a `wikis/<name>/` directory, the root-level infrastructure files
// schema.md / index.md / log.md are excluded.
const wikisIdx = segments.indexOf("wikis");
if (wikisIdx < 0 || wikisIdx + 1 >= segments.length)
return false;
const wikiRelativeSegments = segments.slice(wikisIdx + 2);
return wikiRelativeSegments.length === 1 && WIKI_INFRA_FILES.has(wikiRelativeSegments[0]);
}
/** Reverse the placement map (stash subdir → akm type). Built per call so a runtime-registered custom type is honored (live-delegation, not a snapshot). */
function stashDirToType(stashDir) {
for (const type of placementTypes()) {
if (stashDirFor(type) === stashDir)
return type;
}
return undefined;
}
/**
* The search-surface `IndexDocument` fields that have NO first-class `IndexDocument`
* home (spec §3) and therefore ride `documentJson` so the persist-time fold
* (`search-fields.ts:28-33`) can still reach them — the exact set
* `buildSearchFields` folds into the `hints`/`content` FTS columns beyond the
* first-class name/description/tags/aliases/searchHints, plus the provenance the
* WI-C tests read (renderer/source). Kept as a named list so the fold surface and
* the mapping stay in lockstep.
*/
const DOCUMENT_JSON_CARRIED_FIELDS = [
"examples",
"usage",
"intent",
"xrefs",
"pageKind",
"whenToUse",
"toc",
"parameters",
"source",
"category",
"supersededBy",
"contradictedBy",
"run",
"setup",
"cwd",
"wikiRole",
"sources",
"evidenceSources",
// #935: a workflow's step targets and a task's target, read as declared links.
"uses",
// D2 (#730): the OKF v0.2 provenance `promoteProposal` stamps onto AKM-native
// writes (generated/verified/sources, namespaced — see `types.ts`'s
// `OkfProvenance` doc). Carried so the akm adapter rereads what it wrote and
// `akm show` surfaces it, exactly like every other field in this list.
"provenance",
];
/**
* Map the fully-assembled `IndexDocument` (produced by the shared metadata
* pipeline) onto an `IndexDocument` (spec §3). First-class ranking/embedding
* fields land on named IndexDocument fields; every other search-surface or
* signal field rides `documentJson` (opaque adapter extras) so nothing the
* ranking/embedding/filter inputs read is lost — the M-b shadow-parity gate.
* The winning renderer name is carried on `documentJson.renderer` (WI-C contract).
*/
function indexDocumentFromEntry(entry, base, rendererName) {
const extras = { renderer: rendererName };
for (const field of DOCUMENT_JSON_CARRIED_FIELDS) {
const value = entry[field];
if (value !== undefined)
extras[field] = value;
}
const doc = {
...base,
name: entry.name,
documentJson: extras,
};
// First-class search + signal fields (only when present).
if (entry.description !== undefined)
doc.description = entry.description;
if (entry.tags !== undefined)
doc.tags = entry.tags;
if (entry.content !== undefined)
doc.content = entry.content;
if (entry.contentTruncated !== undefined)
doc.contentTruncated = entry.contentTruncated;
if (entry.aliases !== undefined)
doc.aliases = entry.aliases;
if (entry.searchHints !== undefined)
doc.searchHints = entry.searchHints;
if (entry.quality !== undefined)
doc.quality = entry.quality;
if (entry.confidence !== undefined)
doc.confidence = entry.confidence;
if (entry.beliefState !== undefined)
doc.beliefState = entry.beliefState;
if (entry.currentBeliefRefs !== undefined)
doc.currentBeliefRefs = entry.currentBeliefRefs;
if (entry.scope !== undefined)
doc.scope = entry.scope;
if (entry.captureMode !== undefined)
doc.captureMode = entry.captureMode;
if (entry.lessonStrength !== undefined)
doc.lessonStrength = entry.lessonStrength;
if (entry.derivedFrom !== undefined)
doc.derivedFrom = entry.derivedFrom;
// Internal fragment substrate follows the recognition projection without
// becoming an IndexDocument field or serialized search payload.
if (hasMarkdownFragmentContent(entry))
setMarkdownFragmentContent(doc, getMarkdownFragmentContent(entry));
return doc;
}
function conceptIdForRecognizedType(root, filePath, type) {
const canonicalName = deriveCanonicalAssetNameFromStashRoot(type, root, filePath);
if (canonicalName === undefined)
return undefined;
const stashDir = stashDirFor(type);
return stashDir !== undefined ? `${stashDir}/${canonicalName}` : canonicalName;
}
function recognize(c, file) {
// D-R6 (spec §5.1): `index.md` / `log.md` are OKF reserved structural files at
// every depth — never items. Excluded BEFORE classification so a directory
// listing or update log never becomes a `knowledge` (or other) concept.
if (isReservedFileName(file.fileName))
return null;
// Adapter-owned filtering (owner ruling 2026-07-21): AKM-stash policy that used
// to live in the indexer's `shouldIndexStashFile` pre-filter. Path/stat-based —
// `file.content()` is never read to reach this abstention.
if (akmStashAbstains(c.root, file.absPath))
return null;
const match = recognizeMatch(file);
if (match === null)
return null;
// Canonical name = the winning type's per-type canonical name (§5.1).
// Invalid placements are not projected onto a different identity.
const conceptId = conceptIdForRecognizedType(c.root, file.absPath, match.type);
if (conceptId === undefined)
return null;
const stashDir = stashDirFor(match.type);
const canonicalName = stashDir !== undefined ? conceptId.slice(stashDir.length + 1) : conceptId;
// conceptId = the QUALIFIED `<stash-subdir>/<canonical-name>` spelling
// (ref-grammar decision D-R2): the same form `placeNew` consumes; Markdown
// types use the OKF concept ID (path − .md), while workflows strip either
// peer source extension. Both branches feed a
// BARE canonicalName (the abstain fallback is the basename, above), so the
// stash-subdir is prefixed uniformly — a flat `skills/x.md` yields
// `skills/x`, never the un-prefixed `x` or the double-prefixed
// `skills/skills/x`. `entry.name` below keeps the BARE canonical name —
// identity ≠ search text.
const dirPath = path.dirname(file.absPath);
// Chunk 5 M-b: recognize now carries the FULL index-time metadata surface
// (spec §2/§3), reproducing `buildEntryFromFile`'s flat-walk metadata
// output by SHARING its P1/P2/P4 assembly and substituting the synchronous
// `foldRecognizedMetadata` (+ `applyFoldedMetadata`, which replicates the
// in-place contributor precedence) for the async P3 renderer contributors.
// `entry.name` = the BARE canonical name (conceptId minus the D-R2 stash-subdir
// prefix) so the FTS `name` column
// matches the pre-0.9.0 indexer (search-behavior parity), NOT the frontmatter
// title. Parity is by construction — the two paths differ only in the P3 step,
// pinned equal by the akm-adapter fold-parity test.
const entry = {
name: canonicalName,
type: match.type,
quality: "generated",
confidence: 0.55,
source: "filename",
};
applyPreContributorFields(entry, file.absPath, file, extractPackageMetadata(dirPath));
applyFoldedMetadata(entry, foldRecognizedMetadata(match.renderer, file));
applyPostContributorFields(entry, file.absPath, canonicalName, dirPath);
return indexDocumentFromEntry(entry, {
ref: `${c.id}//${conceptId}`,
bundle: c.id,
component: c.id,
conceptId,
path: file.absPath,
// Hash over the full raw bytes (incrementality/fingerprints, `types.ts`
// hash doc comment) — an opaque digest, not indexed content.
hash: hashContent(file.content()),
adapterId: "akm",
type: match.type,
}, match.renderer);
}
/** Render AKM-native command/agent Markdown into the shared execution source. */
function renderExecutionSource(c, file) {
const document = recognize(c, file);
if (document?.type !== "command" && document?.type !== "agent")
return null;
if (!document.ref)
return null;
const raw = file.content();
return renderMarkdownExecutionSource({
kind: document.type === "command" ? "command" : "persona",
raw,
identity: {
ref: document.ref,
bundle: c.id,
adapter: "akm",
file: file.relPath,
},
defaults: (data) => executionDefaultsFromFrontmatter(data, {
kind: document.type === "command" ? "command" : "persona",
allowTopLevelEngine: true,
toolsKeys: ["tools"],
}),
});
}
/**
* A read-only {@link FileContext} backed by an OVERLAY string (a pending
* change's content) instead of the live filesystem — the `validate` contract
* ({@link BundleAdapter.validate} doc comment / adapter-spec §12.1) forbids
* reading the live FS. It mirrors `buildFileContext`'s eager path-field
* derivation exactly (so the matchers classify identically) and serves the
* overlay bytes from `content()`/`frontmatter()`. `stat()` throws: no matcher
* calls it, and there is no snapshot mtime to serve.
*/
function buildOverlayContext(root, relPathInput, raw) {
const absPath = path.join(root, relPathInput);
const relPath = path.relative(root, absPath).replace(/\\/g, "/");
const ext = path.extname(absPath).toLowerCase();
const fileName = path.basename(absPath);
const parentDirAbs = path.dirname(absPath);
const parentDir = path.basename(parentDirAbs);
const relDir = path.dirname(relPath).replace(/\\/g, "/");
const ancestorDirs = relDir === "." ? [] : relDir.split("/").filter((seg) => seg.length > 0);
let cachedFrontmatter;
let frontmatterComputed = false;
return {
absPath,
relPath,
ext,
fileName,
parentDir,
parentDirAbs,
ancestorDirs,
stashRoot: root,
content: () => raw,
frontmatter: () => {
if (!frontmatterComputed) {
const parsed = parseFrontmatter(raw);
cachedFrontmatter = Object.keys(parsed.data).length > 0 ? parsed.data : null;
frontmatterComputed = true;
}
return cachedFrontmatter ?? null;
},
stat: () => {
throw new Error("stat() is unavailable in a validate overlay FileContext");
},
};
}
/**
* `validate` (spec §6) = shared base checks + the winning `type`'s per-type
* extra checks, reproducing today's `akmLint`/`getLinterForType` dispatch
* byte-for-byte (the FROZEN `lint/all-types.json` `perType` golden is the gate).
*
* Per change (non-delete, readable): the `type` is recovered by running the
* SAME sync matcher arbitration ({@link recognizeMatch}) over an OVERLAY
* FileContext ({@link buildOverlayContext}) — no live-FS read. `task` files
* keep raw YAML for the canonical task-v3 parser and use no frontmatter;
* everything else parses via `parseFrontmatter`.
* Base checks (shared) then the per-type extra checks run; a `skills/` directory
* pass reproduces `SkillLinter.lintDirectory` across the change set.
*/
async function validate(c, changes, ctx) {
const diagnostics = [];
const seenSkillDirs = new Set();
for (const change of changes) {
if (change.op === "delete")
continue;
const raw = change.after ?? (await ctx.readFile(change.path));
if (typeof raw !== "string")
continue;
const overlay = buildOverlayContext(c.root, change.path, raw);
const match = recognizeMatch(overlay);
const type = match?.type;
// A complete GitHub-shaped `on` + `jobs` document is workflow-owned. It
// never flows through task-v3 parsing or Markdown base/frontmatter checks.
if (type === "workflow" && overlay.ext === ".yml") {
diagnostics.push(...workflowYamlSourceDiagnostics(change.path, raw, change.path, c.root).errors);
continue;
}
// Task parsing belongs to perTypeValidateChecks' canonical v3 parser. Base
// checks need only a frontmatter-free placeholder here.
let parsed;
if (type === "task") {
parsed = { data: {}, content: raw, frontmatter: null };
}
else {
const p = parseFrontmatter(raw);
parsed = { data: p.data, content: p.content, frontmatter: p.frontmatter };
}
diagnostics.push(...(await runBaseValidateChecks(change.path, parsed, c.root, ctx)));
diagnostics.push(...(await perTypeValidateChecks({
type,
relPath: change.path,
raw,
data: parsed.data,
frontmatter: parsed.frontmatter,
body: parsed.content,
ext: overlay.ext,
ctx,
workspaceRoot: c.root,
})));
diagnostics.push(...(await skillDirectoryDiagnostics(change.path, seenSkillDirs, ctx)));
}
return diagnostics;
}
export const akmAdapter = {
id: "akm",
version: "0.9.0",
// Recognized-extension HINT, derived from what the matchers accept (§6):
// `.md` (Markdown types + workflow peer), `.yml` (task and workflow YAML),
// `.yaml` (task near-miss diagnostics), `.env` (env files), and the 16
// SCRIPT_EXTENSIONS. This is a
// NON-EXHAUSTIVE hint for the akm adapter: recognition is directory-driven
// via `recognize()` (e.g. a bare `secrets/<anything>` file with no extension
// is a `secret`), so `recognize()` — not this list — is the source of truth.
extensions: [
".md",
".yaml",
".yml",
".env",
".sh",
".ts",
".js",
".ps1",
".cmd",
".bat",
".py",
".rb",
".go",
".pl",
".php",
".lua",
".r",
".swift",
".kt",
".kts",
],
recognize,
renderExecutionSource,
validate,
/**
* Closed-form owner candidates (#857): every physical path spelling that
* could claim `conceptId` without walking the bundle. `deriveCanonicalAssetNameFromStashRoot`
* (recognize's own conceptId derivation) is a pure function of (type,
* filePath); this is its inverse, enumerated for the two placements that
* function can produce for one conceptId — CANONICAL (authored under the
* type's own stash subdir) and the LOOSE FALLBACK (authored anywhere else
* in the bundle, so the canonical name is the file's full path relative to
* the bundle root instead of the stash subdir). `assetPathCandidatesForName`
* additionally expands `env`'s `.env`/`<name>.env` duality on each.
*/
readCandidates(c, conceptId) {
const posix = conceptId.replace(/\\/g, "/");
const slash = posix.indexOf("/");
if (slash <= 0)
return [{ path: path.join(c.root, `${posix}.md`), conceptId: posix }];
const head = posix.slice(0, slash);
const rest = posix.slice(slash + 1);
const type = stashDirToType(head);
if (type === undefined || rest.length === 0)
return [];
const canonical = assetPathCandidatesForName(type, path.join(c.root, head), rest);
const loose = assetPathCandidatesForName(type, c.root, rest);
return [...new Set([...canonical, ...loose])].map((candidatePath) => ({
path: candidatePath,
conceptId: posix,
}));
},
/**
* Type-driven placement (§5.1), reproducing `path-resolver.ts#buildDiskCandidates`:
* the primary candidate is
* `assetPathForName(type, join(root, stashDirFor(type)), name)`.
*
* §5.1 removes `type` from the ref, so the winning type is recovered from the
* conceptId's LEADING path segment — the §1.3 qualified `<stash-subdir>/<name>`
* form (e.g. `knowledge/http-caching`, `workflows/release`, `skills/<dir>`).
* The leading segment is reverse-mapped against the placement stash-subdir map;
* the remainder is the per-type name handed to `assetPathForName`. An
* unqualified conceptId (no recognized leading stash-subdir) falls back to a
* direct `<root>/<conceptId>.md` — the same `${name}.md` fallback
* `buildDiskCandidates` carries (`preserveDirectNameFallback`).
*
* `recognize()` emits the SAME qualified spelling (ref-grammar decision
* D-R2), so recognize/place share one canonical identity — the earlier
* bare-vs-qualified split is resolved.
*/
placeNew(c, conceptId) {
const posix = conceptId.replace(/\\/g, "/");
const slash = posix.indexOf("/");
if (slash > 0) {
const head = posix.slice(0, slash);
const rest = posix.slice(slash + 1);
const type = stashDirToType(head);
if (type !== undefined && rest.length > 0) {
const typeDir = path.join(c.root, head);
return assetPathForName(type, typeDir, rest);
}
}
return path.join(c.root, `${posix}.md`);
},
/** The AKM workspace's owned stash subdirs (§7): the placement stash-subdir names, feeding git exact-path staging. */
directoryList(_c) {
return [...new Set(stashDirNames())];
},
/**
* Install-time probe (§1.2), reproducing today's stash-root detection
* (`provider-utils.ts#detectStashRoot`/`hasStashDirs`): a root is an AKM
* workspace root when it carries a `.stash` marker, multiple native placement
* directories, or one native directory whose Markdown is absent or
* declares the matching AKM type. The final check prevents an OKF bundle with
* an unrelated open type under (for example) `knowledge/` from being claimed
* by AKM merely because the directory name overlaps.
*/
looksLikeRoot(root) {
try {
if (fs.statSync(path.join(root, ".stash")).isDirectory())
return true;
}
catch {
// no .stash marker — fall through to the type-dir probe
}
const ownedDirNames = new Set(stashDirNames());
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
}
catch {
return false;
}
const ownedDirs = entries.filter((entry) => entry.isDirectory() && ownedDirNames.has(entry.name));
if (ownedDirs.length >= 2)
return true;
const only = ownedDirs[0];
if (!only)
return false;
const expectedType = stashDirToType(only.name);
try {
const markdown = fs
.readdirSync(path.join(root, only.name), { withFileTypes: true })
.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"));
if (!markdown)
return true;
const data = parseFrontmatter(fs.readFileSync(path.join(root, only.name, markdown.name), "utf8")).data;
const declaredType = typeof data.type === "string" ? data.type.trim() : "";
return !declaredType || declaredType === expectedType;
}
catch {
return true;
}
},
};