akm-cli 0.4.1 → 0.5.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.
@@ -1,16 +1,19 @@
1
- import { createHash } from "node:crypto";
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
- import { fetchWithRetry } from "../common";
5
- import { ConfigError } from "../errors";
5
+ import { resolveStashDir } from "../common";
6
+ import { loadConfig } from "../config";
7
+ import { ConfigError, UsageError } from "../errors";
6
8
  import { getRegistryIndexCacheDir } from "../paths";
7
- import { extractTarGzSecure } from "../registry-install";
9
+ import { validateGitUrl } from "../registry-resolve";
8
10
  import { registerStashProvider } from "../stash-provider-factory";
9
11
  import { isExpired, sanitizeString } from "./provider-utils";
10
12
  /** Cache TTL before refreshing the mirrored repo (12 hours). */
11
13
  const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
12
14
  /** Maximum stale age allowed when refresh fails (7 days). */
13
15
  const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const GIT_STASH_TYPES = new Set(["git", "context-hub", "github"]);
14
17
  class GitStashProvider {
15
18
  type = "git";
16
19
  name;
@@ -40,13 +43,13 @@ function getCachePaths(repoUrl) {
40
43
  const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
41
44
  return {
42
45
  rootDir,
43
- archivePath: path.join(rootDir, "repo.tar.gz"),
44
46
  repoDir: path.join(rootDir, "repo"),
45
47
  indexPath: path.join(rootDir, "index.json"),
46
48
  };
47
49
  }
48
50
  async function ensureGitMirror(repo, cachePaths, options) {
49
51
  const requireRepoDir = options?.requireRepoDir === true;
52
+ const writable = options?.writable === true;
50
53
  // Check if cache is fresh
51
54
  let mtime = 0;
52
55
  try {
@@ -60,8 +63,13 @@ async function ensureGitMirror(repo, cachePaths, options) {
60
63
  }
61
64
  try {
62
65
  fs.mkdirSync(cachePaths.rootDir, { recursive: true });
63
- await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
64
- extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
66
+ if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) {
67
+ // Writable repo already cloned — pull instead of re-clone to preserve local changes
68
+ pullRepo(cachePaths.repoDir);
69
+ }
70
+ else {
71
+ cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable);
72
+ }
65
73
  // Touch index file to track freshness
66
74
  fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
67
75
  }
@@ -72,6 +80,50 @@ async function ensureGitMirror(repo, cachePaths, options) {
72
80
  throw err;
73
81
  }
74
82
  }
83
+ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
84
+ // Stage the clone into a sibling temp dir so that a failed clone never
85
+ // destroys a previously-valid destDir (e.g. when the remote is temporarily
86
+ // unreachable and we have a valid cached copy).
87
+ const tmpDir = `${destDir}.tmp-${randomBytes(4).toString("hex")}`;
88
+ const args = ["clone", "--depth", "1"];
89
+ if (ref)
90
+ args.push("--branch", ref);
91
+ args.push(cloneUrl, tmpDir);
92
+ const result = spawnSync("git", args, { encoding: "utf8", timeout: 120_000 });
93
+ if (result.status !== 0) {
94
+ // Clean up the (possibly partial) temp dir but leave destDir untouched.
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ const err = result.stderr?.trim() || result.error?.message || "unknown error";
97
+ throw new Error(`Failed to clone ${cloneUrl}: ${err}`);
98
+ }
99
+ try {
100
+ if (!writable) {
101
+ // Remove .git directory — we only need the working tree for read-only stashes
102
+ const gitDir = path.join(tmpDir, ".git");
103
+ if (fs.existsSync(gitDir))
104
+ fs.rmSync(gitDir, { recursive: true, force: true });
105
+ }
106
+ // Swap: remove the old destDir (if any) then atomically rename tmpDir into place.
107
+ if (fs.existsSync(destDir))
108
+ fs.rmSync(destDir, { recursive: true, force: true });
109
+ fs.renameSync(tmpDir, destDir);
110
+ }
111
+ catch (err) {
112
+ // Post-clone steps failed — clean up the temp dir to avoid orphaned dirs.
113
+ fs.rmSync(tmpDir, { recursive: true, force: true });
114
+ throw err;
115
+ }
116
+ }
117
+ function pullRepo(repoDir) {
118
+ const result = spawnSync("git", ["-C", repoDir, "pull", "--ff-only"], {
119
+ encoding: "utf8",
120
+ timeout: 120_000,
121
+ });
122
+ if (result.status !== 0) {
123
+ const err = result.stderr?.trim() || result.error?.message || "unknown error";
124
+ throw new Error(`Failed to pull ${repoDir}: ${err}`);
125
+ }
126
+ }
75
127
  function hasExtractedRepo(repoDir) {
76
128
  try {
77
129
  return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
@@ -80,25 +132,22 @@ function hasExtractedRepo(repoDir) {
80
132
  return false;
81
133
  }
82
134
  }
83
- async function downloadArchive(url, destination) {
84
- const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
85
- if (!response.ok) {
86
- throw new Error(`Failed to download archive (${response.status}) from ${url}`);
87
- }
88
- const BunRuntime = globalThis.Bun;
89
- if (BunRuntime?.write) {
90
- await BunRuntime.write(destination, response);
91
- return;
92
- }
93
- const arrayBuffer = await response.arrayBuffer();
94
- fs.writeFileSync(destination, Buffer.from(arrayBuffer));
95
- }
96
- function buildTarballUrl(repo) {
97
- return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
98
- }
99
135
  function parseGitRepoUrl(rawUrl) {
100
136
  if (!rawUrl) {
101
- throw new ConfigError("Git provider requires a GitHub repository URL");
137
+ throw new ConfigError("Git provider requires a repository URL");
138
+ }
139
+ // SSH shorthand: git@host:path — valid as-is, delegated to system git credentials
140
+ if (/^git@[^:]+:.+$/.test(rawUrl)) {
141
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl: rawUrl };
142
+ }
143
+ // Validate URL scheme is safe before parsing
144
+ try {
145
+ validateGitUrl(rawUrl);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof UsageError)
149
+ throw new ConfigError(err.message);
150
+ throw err;
102
151
  }
103
152
  let parsed;
104
153
  try {
@@ -107,33 +156,122 @@ function parseGitRepoUrl(rawUrl) {
107
156
  catch {
108
157
  throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
109
158
  }
110
- if (parsed.protocol !== "https:") {
111
- throw new ConfigError(`Git provider URL must use https://, got "${parsed.protocol}"`);
159
+ // GitHub web URLs: extract a clean clone URL and optional branch from /tree/<ref>
160
+ if (parsed.hostname === "github.com" && parsed.protocol === "https:") {
161
+ const segments = parsed.pathname.split("/").filter(Boolean);
162
+ if (segments.length < 2) {
163
+ throw new ConfigError(`Git provider URL must point to a repository, got "${rawUrl}"`);
164
+ }
165
+ const owner = sanitizeString(segments[0]);
166
+ const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
167
+ if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
168
+ throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
169
+ }
170
+ let ref = null;
171
+ if (segments[2] === "tree" && segments.length >= 4) {
172
+ const rawRef = sanitizeString(segments.slice(3).join("/"), 255);
173
+ if (rawRef && !rawRef.includes("..") && /^[A-Za-z0-9._/-]+$/.test(rawRef)) {
174
+ ref = rawRef;
175
+ }
176
+ }
177
+ const cloneUrl = `https://github.com/${owner}/${repo}`;
178
+ const canonicalUrl = ref ? `${cloneUrl}/tree/${ref}` : cloneUrl;
179
+ return { cloneUrl, ref, canonicalUrl };
180
+ }
181
+ // Any other valid git URL: use as-is for cloning, but strip embedded credentials
182
+ // from canonicalUrl so secrets don't leak into cache keys or warning messages.
183
+ let canonicalUrl = rawUrl;
184
+ try {
185
+ const u = new URL(rawUrl);
186
+ u.username = "";
187
+ u.password = "";
188
+ u.search = "";
189
+ u.hash = "";
190
+ canonicalUrl = u.toString();
191
+ }
192
+ catch {
193
+ // URL failed to parse — fall back to raw (validateGitUrl already accepted it)
194
+ }
195
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl };
196
+ }
197
+ /**
198
+ * Commit (and optionally push) local changes in a git-backed stash.
199
+ *
200
+ * Behaviour:
201
+ * - Not a git repo → skipped (no-op)
202
+ * - Git repo, no remote → commit only
203
+ * - Git repo, has remote, but stash is not writable → commit only
204
+ * - Git repo, has remote, stash is writable → commit + push
205
+ *
206
+ * When `name` is omitted the primary stash directory is used.
207
+ * When `message` is omitted a timestamp is used.
208
+ */
209
+ export function saveGitStash(name, message, writableOverride) {
210
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
211
+ const commitMessage = message?.trim() || `akm save ${timestamp}`;
212
+ let repoDir;
213
+ let writable = false;
214
+ if (name) {
215
+ const config = loadConfig();
216
+ const stash = config.stashes?.find((s) => s.name === name || s.url === name);
217
+ if (!stash)
218
+ throw new UsageError(`No git stash found with name "${name}"`);
219
+ if (!GIT_STASH_TYPES.has(stash.type)) {
220
+ throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
221
+ }
222
+ if (!stash.url)
223
+ throw new UsageError(`Stash "${name}" has no URL configured`);
224
+ const repo = parseGitRepoUrl(stash.url);
225
+ repoDir = getCachePaths(repo.canonicalUrl).repoDir;
226
+ writable = stash.writable === true;
227
+ }
228
+ else {
229
+ repoDir = resolveStashDir({ readOnly: true });
230
+ // Allow caller to override writable for the primary stash (e.g. from root config.writable)
231
+ if (writableOverride !== undefined) {
232
+ writable = writableOverride;
233
+ }
234
+ }
235
+ // No-op: not a git repo
236
+ if (!fs.existsSync(path.join(repoDir, ".git"))) {
237
+ return { committed: false, pushed: false, skipped: true, reason: "not a git repository", output: "" };
238
+ }
239
+ // Nothing to commit?
240
+ const statusResult = spawnSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" });
241
+ if (statusResult.error || statusResult.status !== 0) {
242
+ throw new Error(`git status failed: ${statusResult.error?.message || statusResult.stderr?.trim() || "unknown error"}`);
243
+ }
244
+ if (!statusResult.stdout.trim()) {
245
+ return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" };
112
246
  }
113
- if (parsed.hostname !== "github.com") {
114
- throw new ConfigError(`Git provider only supports github.com URLs, got "${parsed.hostname}"`);
247
+ // Stage and commit — supply fallback identity so fresh environments without
248
+ // user.name/user.email configured can always commit to the default stash.
249
+ const addResult = spawnSync("git", ["-C", repoDir, "add", "-A"], { encoding: "utf8" });
250
+ if (addResult.status !== 0) {
251
+ throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
115
252
  }
116
- const segments = parsed.pathname.split("/").filter(Boolean);
117
- if (segments.length < 2) {
118
- throw new ConfigError(`Git provider URL must point to a GitHub repository, got "${rawUrl}"`);
253
+ const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", commitMessage], { encoding: "utf8" });
254
+ if (commitResult.status !== 0) {
255
+ throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
119
256
  }
120
- const owner = sanitizeString(segments[0]);
121
- const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
122
- let ref = "main";
123
- if (segments[2] === "tree" && segments.length >= 4) {
124
- ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
257
+ // Push only when there is a remote AND the stash is marked writable
258
+ const remoteResult = spawnSync("git", ["-C", repoDir, "remote"], { encoding: "utf8" });
259
+ if (remoteResult.status !== 0) {
260
+ throw new Error(`git remote failed: ${remoteResult.stderr?.trim() || "unknown error"}`);
125
261
  }
126
- if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
127
- throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
262
+ const hasRemote = remoteResult.stdout.trim().length > 0;
263
+ if (!hasRemote || !writable) {
264
+ return { committed: true, pushed: false, skipped: false, output: commitResult.stdout.trim() };
128
265
  }
129
- if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
130
- throw new ConfigError(`Unsupported branch/ref in URL: "${rawUrl}"`);
266
+ const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
267
+ if (pushResult.status !== 0) {
268
+ throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
131
269
  }
132
270
  return {
133
- owner,
134
- repo,
135
- ref,
136
- canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
271
+ committed: true,
272
+ pushed: true,
273
+ skipped: false,
274
+ output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
137
275
  };
138
276
  }
139
277
  // ── Exports ─────────────────────────────────────────────────────────────────
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { loadConfig } from "./config";
3
4
  import { closeDatabase, openDatabase } from "./db";
@@ -13,6 +14,35 @@ import { resolveAssetPath } from "./stash-resolve";
13
14
  import { insertUsageEvent } from "./usage-events";
14
15
  // Eagerly import stash providers to trigger self-registration
15
16
  import "./stash-providers/index";
17
+ /**
18
+ * Show a wiki root (no page path) — returns the same payload as
19
+ * `akm wiki show <name>`.
20
+ *
21
+ * Called when `parseAssetRef` yields `type === "wiki"` and the name has no
22
+ * `/`, e.g. `wiki:research`.
23
+ */
24
+ async function showWikiRoot(stashDir, wikiName) {
25
+ const { showWiki, resolveWikiDir } = await import("./wiki.js");
26
+ const wikiDir = resolveWikiDir(stashDir, wikiName);
27
+ if (!fs.existsSync(wikiDir)) {
28
+ throw new NotFoundError(`Wiki not found: ${wikiName}. Run \`akm wiki create ${wikiName}\` to create it.`);
29
+ }
30
+ const result = showWiki(stashDir, wikiName);
31
+ // Shape the WikiShowResult into a ShowResponse-compatible object.
32
+ // The payload mirrors what `akm wiki show <name>` returns.
33
+ return {
34
+ type: "wiki",
35
+ name: result.ref,
36
+ path: result.path,
37
+ ...(result.description ? { description: result.description } : {}),
38
+ origin: null,
39
+ editable: false,
40
+ pages: result.pages,
41
+ raws: result.raws,
42
+ ...(result.lastModified ? { lastModified: result.lastModified } : {}),
43
+ recentLog: result.recentLog,
44
+ };
45
+ }
16
46
  /**
17
47
  * Unified show: tries local FTS5 index first, then remote providers.
18
48
  *
@@ -21,6 +51,30 @@ import "./stash-providers/index";
21
51
  */
22
52
  export async function akmShowUnified(input) {
23
53
  const ref = input.ref.trim();
54
+ // 0. Wiki-root shortcut: `wiki:<name>` with no page path routes to the
55
+ // wiki summary (same payload as `akm wiki show <name>`). Honour
56
+ // `parsed.origin` by resolving against the matching stash source(s),
57
+ // falling back to the primary stash when no origin is given.
58
+ {
59
+ const parsed = parseAssetRef(ref);
60
+ if (parsed.type === "wiki" && !parsed.name.includes("/")) {
61
+ const allSources = resolveStashSources();
62
+ const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
63
+ let lastError;
64
+ for (const source of searchSources) {
65
+ try {
66
+ return await showWikiRoot(source.path, parsed.name);
67
+ }
68
+ catch (err) {
69
+ if (!(err instanceof NotFoundError))
70
+ throw err;
71
+ lastError = err;
72
+ }
73
+ }
74
+ throw (lastError ??
75
+ new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
76
+ }
77
+ }
24
78
  // 1. Try local filesystem first (FTS5 index lookup)
25
79
  let localError;
26
80
  try {
@@ -127,7 +181,7 @@ export async function showLocal(input) {
127
181
  if (!renderer) {
128
182
  throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
129
183
  }
130
- const renderCtx = buildRenderContext(fileCtx, match, allStashDirs);
184
+ const renderCtx = buildRenderContext(fileCtx, match, allStashDirs, source?.registryId);
131
185
  const response = renderer.buildShowResponse(renderCtx);
132
186
  const editable = isEditable(assetPath, config);
133
187
  const fullResponse = {
@@ -188,6 +242,7 @@ function buildSummaryResponse(full, assetPath) {
188
242
  ...(description ? { description } : {}),
189
243
  ...(tags && tags.length > 0 ? { tags } : {}),
190
244
  ...(full.parameters ? { parameters: full.parameters } : {}),
245
+ ...(full.workflowTitle ? { workflowTitle: full.workflowTitle } : {}),
191
246
  ...(full.action ? { action: full.action } : {}),
192
247
  ...(full.run ? { run: full.run } : {}),
193
248
  ...(full.origin !== undefined ? { origin: full.origin } : {}),
@@ -11,12 +11,16 @@ import { resolveStashSources } from "./search-source";
11
11
  * (e.g. "openviking").
12
12
  */
13
13
  export function addStash(opts) {
14
- const { target, name, providerType, options: providerOptions } = opts;
14
+ const { target, name, providerType, options: providerOptions, writable } = opts;
15
15
  const config = loadUserConfig();
16
16
  const stashes = [...(config.stashes ?? [])];
17
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
17
+ const isRemoteUrl = target.startsWith("http://") ||
18
+ target.startsWith("https://") ||
19
+ target.startsWith("git@") ||
20
+ target.startsWith("ssh://") ||
21
+ target.startsWith("git://");
18
22
  let entry;
19
- if (isUrl) {
23
+ if (isRemoteUrl) {
20
24
  if (!providerType) {
21
25
  throw new UsageError("--provider is required for URL sources (e.g. --provider openviking)");
22
26
  }
@@ -27,6 +31,8 @@ export function addStash(opts) {
27
31
  entry = { type: providerType, url: target };
28
32
  if (name)
29
33
  entry.name = name;
34
+ if (writable)
35
+ entry.writable = true;
30
36
  if (providerOptions)
31
37
  entry.options = providerOptions;
32
38
  }
@@ -51,7 +57,11 @@ export function addStash(opts) {
51
57
  export function removeStash(target) {
52
58
  const config = loadUserConfig();
53
59
  const stashes = [...(config.stashes ?? [])];
54
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
60
+ const isUrl = target.startsWith("http://") ||
61
+ target.startsWith("https://") ||
62
+ target.startsWith("git@") ||
63
+ target.startsWith("ssh://") ||
64
+ target.startsWith("git://");
55
65
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
56
66
  // Try URL match first, then path, then name (most specific → least specific)
57
67
  let idx = -1;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Scaffolded content for a fresh `wikis/<name>/` directory.
3
+ *
4
+ * Inlined as TypeScript constants so they ship with the published bundle
5
+ * (the build step is `tsc` — non-TS files are not copied to dist).
6
+ *
7
+ * The scaffold is deliberately minimal: akm does not prescribe conventions
8
+ * beyond the three-layer layout. `schema.md` is the per-wiki rulebook the
9
+ * agent reads first; authors customise it freely.
10
+ */
11
+ export function buildSchemaMd(wikiName) {
12
+ return `---
13
+ description: Rules that govern this wiki. Read before ingesting, searching, or editing pages.
14
+ wikiRole: schema
15
+ ---
16
+
17
+ # ${wikiName} wiki schema
18
+
19
+ This wiki follows the three-layer pattern:
20
+
21
+ - \`raw/\` — immutable ingested sources (never edit)
22
+ - \`<page>.md\` and \`<topic>/<page>.md\` — agent-authored pages
23
+ - \`schema.md\` (this file), \`index.md\`, \`log.md\` — wiki-level metadata
24
+
25
+ ## Page frontmatter
26
+
27
+ Every page should carry frontmatter so akm can index and link it:
28
+
29
+ \`\`\`yaml
30
+ ---
31
+ description: one-sentence summary used in search and lint
32
+ pageKind: entity | concept | question | note | <your-custom-kind>
33
+ xrefs:
34
+ - wiki:${wikiName}/other-page
35
+ sources:
36
+ - raw/<slug>.md
37
+ ---
38
+ \`\`\`
39
+
40
+ \`pageKind\` accepts any non-empty string. Add new categories freely; they
41
+ will surface in \`index.md\` as new sections after the next \`akm index\` run.
42
+
43
+ ## Three operations
44
+
45
+ ### Ingest
46
+
47
+ 1. Copy the new source into \`raw/\` with \`akm wiki stash ${wikiName} <path>\`.
48
+ 2. Find related pages: \`akm wiki search ${wikiName} "<terms>"\`.
49
+ 3. For each related page: append a section, note a contradiction, or create a
50
+ new page. Update xrefs on both sides.
51
+ 4. Cite the raw source in each touched page's \`sources:\` frontmatter.
52
+ 5. Append one entry to \`log.md\` describing what was assimilated.
53
+
54
+ ### Query
55
+
56
+ 1. \`akm wiki search ${wikiName} "<question>"\` — find candidate pages.
57
+ 2. \`akm show wiki:${wikiName}/<page>\` — read the top hits.
58
+ 3. Compose the answer from the wiki; cite raw sources only when the wiki
59
+ points at them.
60
+
61
+ ### Lint
62
+
63
+ 1. \`akm wiki lint ${wikiName}\` — deterministic structural checks.
64
+ 2. Resolve each finding: link orphans, fix broken xrefs, add descriptions,
65
+ cite uncited raws, refresh the index.
66
+
67
+ ## Hard rules
68
+
69
+ - \`raw/\` is immutable. Never edit ingested sources.
70
+ - Cross-references must point at pages that actually exist.
71
+ - Prefer appending to an existing page over duplicating one.
72
+ - Cite the raw source id (e.g. \`raw/2026-04-foo.md\`) when copying claims.
73
+ `;
74
+ }
75
+ export function buildIndexMd(wikiName) {
76
+ return `---
77
+ description: Catalog of pages in the ${wikiName} wiki. Regenerated by \`akm index\`.
78
+ wikiRole: index
79
+ ---
80
+
81
+ # ${wikiName} — index
82
+
83
+ _This file is regenerated on every \`akm index\` run. Manual edits are
84
+ preserved until the next regeneration, then replaced._
85
+
86
+ _(no pages yet — create one with your editor, or ingest a source with \`akm
87
+ wiki stash ${wikiName} <path>\`.)_
88
+ `;
89
+ }
90
+ export function buildLogMd(wikiName) {
91
+ return `---
92
+ description: Append-only log for the ${wikiName} wiki. Newest entries at the top.
93
+ wikiRole: log
94
+ ---
95
+
96
+ # ${wikiName} — log
97
+
98
+ _Each entry: ISO date, operation, brief summary._
99
+ `;
100
+ }