UNPKG

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

605 lines (604 loc) • 29.3 kB
// 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", // Bump when a change to the matchers (or `recognize`) can retype or re-ref a // file that has not changed: the version is folded into each directory's // freshness, so the next incremental `akm index` re-drains it and re-files // those rows (#1063). 0.9.1: a skill's resource is never retyped by a `$1`. // 0.9.2: a skill's resource is never retyped by a `$ARGUMENTS` (#1084). version: "0.9.2", // 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; } }, };