@cr8rcho/alkahest 0.1.92 → 0.1.94

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.
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // alkahest as-built preset — docs sync ENGINE. Do not edit this file: `alkahest preset update`
3
+ // replaces it with the preset's current version. Everything repo-specific (project, which docs
4
+ // go to which map, title rules, extra files) lives in scripts/sync-docs-maps.config.mjs, which
5
+ // the update never touches.
6
+ //
7
+ // Mirrors docs/ into the project's note maps:
8
+ // docs/decisions/NNN-*.md → note map `adr` (flat)
9
+ // docs/{system,components,features,modules}/*.md → note map `as-built` (folder = category)
10
+ //
11
+ // Staging (the importer's rules: filename = title, frontmatter passes through):
12
+ // - title from the first H1 ("ADR-NNN: rest — tail" → "ADR-NNN rest"; as-built strips a
13
+ // "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized — or the
14
+ // config's `title()` when it returns one;
15
+ // - the H1 line is dropped from the body (the note title renders it);
16
+ // - relative .md links to another synced doc become [[wikilinks]] (within the same map by
17
+ // default; `links: "all"` also across maps);
18
+ // - the ORIGINAL repo path rides in as `source_path:` frontmatter, which keeps identity through
19
+ // a retitle (the importer matches source_path before title and renames the note in place).
20
+ //
21
+ // Re-running is safe: import is idempotent by source_path, then title.
22
+ // Usage: node scripts/sync-docs-maps.mjs [--stage-only | --dry-run]
23
+ // --stage-only write the staged files and stop (the dir is kept and printed)
24
+ // --dry-run stage, then ask the importer for its plan without writing anything
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const ROOT = join(HERE, "..");
33
+ const CONFIG_FILE = join(HERE, "sync-docs-maps.config.mjs");
34
+ const config = existsSync(CONFIG_FILE) ? ((await import(pathToFileURL(CONFIG_FILE).href)).default ?? {}) : {};
35
+
36
+ const DOCS = join(ROOT, config.docsDir ?? "docs");
37
+ const PROJECT = config.project ?? process.env.ALKAHEST_PROJECT;
38
+ const STAGE_ONLY = process.argv.includes("--stage-only");
39
+ const DRY_RUN = process.argv.includes("--dry-run");
40
+ const LINKS = config.links ?? "set";
41
+ const SETS = config.sets ?? [
42
+ { map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
43
+ { map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
44
+ ];
45
+
46
+ // ---- helpers -----------------------------------------------------------------
47
+ const sanitize = (t) =>
48
+ t.replace(/\(\//g, "(").replace(/[/\\:]/g, "-").replace(/`/g, "").replace(/\s+/g, " ").trim();
49
+ // A config title is kept as written — only what can't live in a filename is replaced.
50
+ const fileSafe = (t) => t.replace(/[/\\]/g, "-").trim();
51
+ const posix = (p) => p.split(sep).join("/");
52
+ const firstH1 = (src) => (src.match(/^#\s+(.+)$/m) ?? [null, ""])[1].trim();
53
+ // Frontmatter rides through to the staged file — the importer strips it from the stored body
54
+ // and harvests its keys into note props (`tags:` is the reserved one). Split it off first so
55
+ // stripH1 still sees the H1 at the head of what's left.
56
+ const splitFm = (src) => {
57
+ const m = src.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n+/);
58
+ return m ? [m[0], src.slice(m[0].length)] : ["", src];
59
+ };
60
+ const stripH1 = (src) => {
61
+ const [fm, body] = splitFm(src);
62
+ return fm + body.replace(/^#\s+.+\n+/, "");
63
+ };
64
+ const withSource = (staged, rel) => {
65
+ const [fm, body] = splitFm(staged);
66
+ const line = `source_path: ${rel}\n`;
67
+ return fm ? fm.replace(/---\r?\n*$/, (close) => line + close) + body : `---\n${line}---\n\n${staged}`;
68
+ };
69
+
70
+ /** The preset's own title rules, per set kind. */
71
+ function defaultTitle(kind, file, h1) {
72
+ if (kind === "adr") {
73
+ // Convention "ADR-NNN: title — tail"; the em-dash variant "ADR-NNN — title" also parses.
74
+ const m = h1.match(/^ADR-(\d{3})(?::|\s+—)\s*(.+)$/);
75
+ const num = m ? m[1] : basename(file).slice(0, 3);
76
+ return sanitize(`ADR-${num} ${(m ? m[2] : h1).split(" — ")[0].trim()}`);
77
+ }
78
+ if (kind === "as-built") {
79
+ const t = h1.replace(/^(System|Component|Feature|Module)\s*—\s*/i, "").split(" — ")[0].trim();
80
+ return sanitize(t || h1);
81
+ }
82
+ return sanitize(h1 || basename(file, ".md"));
83
+ }
84
+
85
+ // ---- collect -----------------------------------------------------------------
86
+ const docs = []; // { rel (to DOCS), map, folder, src, title }
87
+ for (const set of SETS) {
88
+ for (const d of set.dirs ?? []) {
89
+ const dir = join(DOCS, d);
90
+ if (!existsSync(dir)) continue;
91
+ for (const f of readdirSync(dir).sort()) {
92
+ if (!f.endsWith(".md") || (set.match && !set.match.test(f))) continue;
93
+ docs.push({ rel: posix(join(d, f)), map: set.map, kind: set.kind, folder: set.folders ? d : null });
94
+ }
95
+ }
96
+ for (const f of config.extra?.[set.map] ?? []) {
97
+ if (existsSync(join(DOCS, f))) docs.push({ rel: posix(f), map: set.map, kind: set.kind, folder: null });
98
+ }
99
+ }
100
+ for (const doc of docs) {
101
+ doc.src = readFileSync(join(DOCS, doc.rel), "utf8");
102
+ const h1 = firstH1(doc.src);
103
+ const fallback = defaultTitle(doc.kind, doc.rel, h1);
104
+ const custom = config.title?.({ map: doc.map, kind: doc.kind, path: doc.rel, h1, defaultTitle: fallback });
105
+ doc.title = custom ? fileSafe(custom) : fallback;
106
+ }
107
+
108
+ // ---- links → [[wikilinks]] -----------------------------------------------------
109
+ const byRel = new Map(docs.map((d) => [d.rel, d]));
110
+ const byName = new Map(); // `${map}/${basename}` → docs (the fallback for bare `name.md` links)
111
+ for (const d of docs) {
112
+ const k = `${d.map}/${basename(d.rel)}`;
113
+ byName.set(k, [...(byName.get(k) ?? []), d]);
114
+ }
115
+ const LINK = /\[([^\]]*)\]\(([^)\s]+\.md)(#[^)\s]*)?\)/g;
116
+ function linkify(doc, body) {
117
+ return body.replace(LINK, (whole, _label, href) => {
118
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("/")) return whole;
119
+ const rel = posix(relative(DOCS, resolve(DOCS, dirname(doc.rel), href)));
120
+ let target = byRel.get(rel);
121
+ if (!target) {
122
+ const same = byName.get(`${doc.map}/${basename(href)}`);
123
+ if (same?.length === 1) target = same[0];
124
+ }
125
+ // ADRs are addressed by number: a link whose slug went stale after a retitle still names
126
+ // the right record (`./030-old-slug.md` → ADR-030).
127
+ const num = !target && /^(\d{3})-/.exec(basename(href))?.[1];
128
+ if (num) {
129
+ const adr = docs.filter((d) => d.kind === "adr" && basename(d.rel).startsWith(`${num}-`));
130
+ if (adr.length === 1) target = adr[0];
131
+ }
132
+ if (!target || (LINKS !== "all" && target.map !== doc.map)) return whole;
133
+ return `[[${target.title}]]`;
134
+ });
135
+ }
136
+
137
+ // ---- stage -----------------------------------------------------------------------
138
+ // A fresh dir per run: a fixed shared path let another repo's copy of this script, running at the
139
+ // same time, restage its own docs under us, and our import pushed them into this project's maps.
140
+ const OUT = mkdtempSync(join(tmpdir(), "alkahest-docs-staging-"));
141
+ const maps = [...new Set(SETS.map((s) => s.map))];
142
+ for (const doc of docs) {
143
+ const dir = join(OUT, doc.map, ...(doc.folder ? doc.folder.split("/") : []));
144
+ mkdirSync(dir, { recursive: true });
145
+ const docsRel = posix(relative(ROOT, join(DOCS, doc.rel)));
146
+ writeFileSync(join(dir, `${doc.title}.md`), withSource(linkify(doc, stripH1(doc.src)), docsRel));
147
+ }
148
+ for (const m of maps) mkdirSync(join(OUT, m), { recursive: true });
149
+ console.log(`staged ${maps.map((m) => `${docs.filter((d) => d.map === m).length} ${m}`).join(" + ")} docs → ${OUT}`);
150
+ if (STAGE_ONLY) process.exit(0);
151
+
152
+ // ---- import ------------------------------------------------------------------------
153
+ const scope = ["--path", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])];
154
+ if (PROJECT) console.log(`project: ${PROJECT}`);
155
+
156
+ // A note map that doesn't exist yet is created (the preset install makes them, but a repo can
157
+ // add a set or move projects). Skipped on --dry-run: a plan must not create anything.
158
+ if (!DRY_RUN && config.createMaps !== false) {
159
+ const list = spawnSync("alkahest", ["maps", "list", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])], { encoding: "utf8" });
160
+ const have = list.status === 0 ? list.stdout : null;
161
+ for (const m of maps) {
162
+ if (have === null || new RegExp(`(^|[^\\w-])${m.replace(/[-]/g, "\\-")}([^\\w-]|$)`, "m").test(have)) continue;
163
+ console.log(`[sync] creating note map '${m}'`);
164
+ spawnSync("alkahest", ["maps", "create", m, "--type", "note", ...scope], { stdio: "inherit" });
165
+ }
166
+ }
167
+
168
+ // A full mirror is many sequential POSTs, so a transient failure is a question of when, not
169
+ // if. The CLI handles it per file (keeps going, prints `✗ <file>: <message>`, exits 1) — act
170
+ // on the exit code and retry the whole set: rows that already landed come back as updates.
171
+ const ATTEMPTS = DRY_RUN ? 1 : 3;
172
+ const failed = [];
173
+ for (const m of maps) {
174
+ let ok = false;
175
+ for (let attempt = 1; attempt <= ATTEMPTS && !ok; attempt++) {
176
+ console.log(`\n== notes import ${m} → map ${m}${DRY_RUN ? " (dry run)" : ""}${attempt > 1 ? ` (retry ${attempt - 1})` : ""} ==`);
177
+ const run = spawnSync("alkahest", ["notes", "import", join(OUT, m), "--map", m, ...scope, ...(DRY_RUN ? ["--dry-run"] : [])], {
178
+ stdio: "inherit",
179
+ });
180
+ if (run.error) console.error(`[sync] could not run alkahest: ${run.error.message}`);
181
+ ok = !run.error && run.status === 0;
182
+ if (!ok && attempt < ATTEMPTS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
183
+ }
184
+ if (!ok) failed.push(m);
185
+ }
186
+
187
+ if (failed.length) {
188
+ console.error(`\n[sync] FAILED after ${ATTEMPTS} attempt(s): ${failed.join(", ")}`);
189
+ console.error("[sync] scroll up for the per-file '✗ <file>: <message>' lines from the importer.");
190
+ console.error(`[sync] staged files kept for inspection: ${OUT}`);
191
+ process.exit(1);
192
+ }
193
+ // The staging dir is this run's own, so removing it touches no other sync. Kept on
194
+ // --stage-only (that's what it's for) and on failure (above).
195
+ rmSync(OUT, { recursive: true, force: true });
196
+ console.log(DRY_RUN ? "\n[sync] dry run done — nothing written." : `\n[sync] ${maps.join(" + ")} up to date.`);
@@ -0,0 +1,26 @@
1
+ <!-- alkahest as-built preset — installed by `alkahest preset install as-built`. Owned by this repo: edit freely; `alkahest preset update` merges later preset changes into your edits. -->
2
+ ## Documentation — as-built docs + ADR
3
+
4
+ This repo keeps **as-built documentation** under `docs/` (four layers: system / components /
5
+ features / modules — see docs/README.md) and **ADRs** under `docs/decisions/`. The writing
6
+ instructions live in the account skills `alkahest/as-built-docs` and `alkahest/adr` — read them via the
7
+ alkahest MCP `skills` tool before writing docs.
8
+
9
+ 1. **After finishing any code change, update the affected docs in the same session.**
10
+ The layer mapping and the update checklist are in the `alkahest/as-built-docs` skill. Write an
11
+ ADR only for decisions whose "why" a code diff cannot reconstruct (criteria and the
12
+ template are in the `alkahest/adr` skill).
13
+ 2. **First documentation pass (repo has no docs yet)?** Follow the bootstrap protocol in
14
+ the `alkahest/as-built-docs` skill: one system map + 2–3 core modules + ADR-001 (architecture
15
+ snapshot) — small first, then mirror and hand the user the note-map link. Grow the rest
16
+ incrementally with later work.
17
+ 3. **After changing `docs/`, mirror it to the hosted note maps** by running
18
+ `node scripts/sync-docs-maps.mjs` (background recommended — one POST per document).
19
+ The script stages the docs (title from the first H1, H1 line stripped, intra-set
20
+ relative links → `[[wikilinks]]`, the original repo path injected as `source_path:`
21
+ frontmatter) and uploads with `alkahest notes import --map <adr|as-built>`. The import
22
+ is idempotent by source_path first, title second — re-runs and retitles update notes in
23
+ place. The script is the preset's engine — don't edit it (`alkahest preset update` replaces
24
+ it). This repo's settings (project, extra files, title rules, which folders go to which map)
25
+ live in `scripts/sync-docs-maps.config.mjs`, which updates never touch.
26
+ <!-- /alkahest as-built preset -->
@@ -40,6 +40,10 @@
40
40
  {
41
41
  "sha": "2eb353c13db7e5a7",
42
42
  "since": "0.1.92"
43
+ },
44
+ {
45
+ "sha": "c8301f8ce329dd96",
46
+ "since": "0.1.94"
43
47
  }
44
48
  ],
45
49
  "CLAUDE-snippet.md": [
@@ -54,6 +58,10 @@
54
58
  {
55
59
  "sha": "96497831c905de56",
56
60
  "since": "0.1.91"
61
+ },
62
+ {
63
+ "sha": "fa58e822a0e3f4f4",
64
+ "since": "0.1.94"
57
65
  }
58
66
  ]
59
67
  }
@@ -17,7 +17,13 @@
17
17
  "scripts": [
18
18
  {
19
19
  "file": "sync-docs-maps.mjs",
20
- "dest": "scripts/sync-docs-maps.mjs"
20
+ "dest": "scripts/sync-docs-maps.mjs",
21
+ "owner": "preset"
22
+ },
23
+ {
24
+ "file": "sync-docs-maps.config.mjs",
25
+ "dest": "scripts/sync-docs-maps.config.mjs",
26
+ "owner": "repo"
21
27
  }
22
28
  ],
23
29
  "maps": [
@@ -0,0 +1,31 @@
1
+ // Settings for scripts/sync-docs-maps.mjs (alkahest as-built preset) — this file is the REPO'S.
2
+ // `alkahest preset update` replaces the sync engine next to it but never touches this file, so
3
+ // everything that makes this repo's docs sync its own goes here. Every key is optional: an empty
4
+ // object is the preset's default behaviour.
5
+ export default {
6
+ // The alkahest project to sync into. Default: the project this folder is linked to
7
+ // (`.alkahest/project.json`), or the ALKAHEST_PROJECT environment variable.
8
+ // project: "my-project-1a2b3c",
9
+
10
+ // Where the docs live, relative to the repo root.
11
+ // docsDir: "docs",
12
+
13
+ // Extra files (relative to docsDir) to mirror into a map, beyond its folders.
14
+ // extra: { "as-built": ["README.md"] },
15
+
16
+ // Note titles. Return a string to replace the preset's title for a doc, or nothing to keep it.
17
+ // Titles are how existing notes are matched, so change them deliberately (then run --stage-only).
18
+ // title: ({ map, kind, path, h1, defaultTitle }) => (map === "adr" ? h1 : undefined),
19
+
20
+ // Relative .md links become [[wikilinks]] within each map ("set", default) or across maps ("all").
21
+ // links: "set",
22
+
23
+ // Which folders go to which note map. Default:
24
+ // sets: [
25
+ // { map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
26
+ // { map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
27
+ // ],
28
+
29
+ // Create a missing note map before importing (default true).
30
+ // createMaps: true,
31
+ };
@@ -1,36 +1,54 @@
1
1
  #!/usr/bin/env node
2
- // Reference implementation (alkahest as-built preset) — mirror this repo's docs into the
3
- // project's note maps:
2
+ // alkahest as-built preset — docs sync ENGINE. Do not edit this file: `alkahest preset update`
3
+ // replaces it with the preset's current version. Everything repo-specific (project, which docs
4
+ // go to which map, title rules, extra files) lives in scripts/sync-docs-maps.config.mjs, which
5
+ // the update never touches.
6
+ //
7
+ // Mirrors docs/ into the project's note maps:
4
8
  // docs/decisions/NNN-*.md → note map `adr` (flat)
5
9
  // docs/{system,components,features,modules}/*.md → note map `as-built` (folder = category)
6
10
  //
7
- // Staging transforms (the importer's rules: filename = title, frontmatter passes through):
11
+ // Staging (the importer's rules: filename = title, frontmatter passes through):
8
12
  // - title from the first H1 ("ADR-NNN: rest — tail" → "ADR-NNN rest"; as-built strips a
9
- // "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized;
13
+ // "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized — or the
14
+ // config's `title()` when it returns one;
10
15
  // - the H1 line is dropped from the body (the note title renders it);
11
- // - relative .md links WITHIN each set become [[wikilinks]] so the graph connects
12
- // (cross-set links stay plain markdown);
13
- // - the ORIGINAL repo path rides in as `source_path:` frontmatter — the staged filename
14
- // is title-derived, so this is what keeps identity through a retitle (the importer
15
- // matches source_path before title and renames the note in place).
16
+ // - relative .md links to another synced doc become [[wikilinks]] (within the same map by
17
+ // default; `links: "all"` also across maps);
18
+ // - the ORIGINAL repo path rides in as `source_path:` frontmatter, which keeps identity through
19
+ // a retitle (the importer matches source_path before title and renames the note in place).
16
20
  //
17
21
  // Re-running is safe: import is idempotent by source_path, then title.
18
- // This script belongs to the repo it lives in — adapt titles/sets/maps to local conventions.
19
- // Usage: node scripts/sync-docs-maps.mjs [--stage-only]
20
- import { readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
22
+ // Usage: node scripts/sync-docs-maps.mjs [--stage-only | --dry-run]
23
+ // --stage-only write the staged files and stop (the dir is kept and printed)
24
+ // --dry-run stage, then ask the importer for its plan without writing anything
21
25
  import { spawnSync } from "node:child_process";
22
- import { join, dirname, basename } from "node:path";
23
- import { fileURLToPath } from "node:url";
26
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
27
  import { tmpdir } from "node:os";
28
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
30
 
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
27
- const DOCS = join(ROOT, "docs");
28
- // A fresh dir per run: a fixed shared path let another repo's copy of this script, running at the
29
- // same time, restage its own docs under us, and our import pushed them into this project's maps.
30
- const OUT = mkdtempSync(join(tmpdir(), "alkahest-docs-staging-"));
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const ROOT = join(HERE, "..");
33
+ const CONFIG_FILE = join(HERE, "sync-docs-maps.config.mjs");
34
+ const config = existsSync(CONFIG_FILE) ? ((await import(pathToFileURL(CONFIG_FILE).href)).default ?? {}) : {};
35
+
36
+ const DOCS = join(ROOT, config.docsDir ?? "docs");
37
+ const PROJECT = config.project ?? process.env.ALKAHEST_PROJECT;
38
+ const STAGE_ONLY = process.argv.includes("--stage-only");
39
+ const DRY_RUN = process.argv.includes("--dry-run");
40
+ const LINKS = config.links ?? "set";
41
+ const SETS = config.sets ?? [
42
+ { map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
43
+ { map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
44
+ ];
31
45
 
46
+ // ---- helpers -----------------------------------------------------------------
32
47
  const sanitize = (t) =>
33
48
  t.replace(/\(\//g, "(").replace(/[/\\:]/g, "-").replace(/`/g, "").replace(/\s+/g, " ").trim();
49
+ // A config title is kept as written — only what can't live in a filename is replaced.
50
+ const fileSafe = (t) => t.replace(/[/\\]/g, "-").trim();
51
+ const posix = (p) => p.split(sep).join("/");
34
52
  const firstH1 = (src) => (src.match(/^#\s+(.+)$/m) ?? [null, ""])[1].trim();
35
53
  // Frontmatter rides through to the staged file — the importer strips it from the stored body
36
54
  // and harvests its keys into note props (`tags:` is the reserved one). Split it off first so
@@ -43,88 +61,136 @@ const stripH1 = (src) => {
43
61
  const [fm, body] = splitFm(src);
44
62
  return fm + body.replace(/^#\s+.+\n+/, "");
45
63
  };
46
- // Rename-safe identity: inject the ORIGINAL repo path as source_path: frontmatter (merged
47
- // into an existing block, or a new block is minted).
48
64
  const withSource = (staged, rel) => {
49
65
  const [fm, body] = splitFm(staged);
50
66
  const line = `source_path: ${rel}\n`;
51
67
  return fm ? fm.replace(/---\r?\n*$/, (close) => line + close) + body : `---\n${line}---\n\n${staged}`;
52
68
  };
53
69
 
54
- // ---- ADRs ------------------------------------------------------------------
55
- const adrDir = join(DOCS, "decisions");
56
- const adrFiles = readdirSync(adrDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
57
- const adrTitle = {};
58
- for (const f of adrFiles) {
59
- const h1 = firstH1(readFileSync(join(adrDir, f), "utf8"));
60
- // Convention "ADR-NNN: title — tail"; the em-dash variant "ADR-NNN — title" also parses.
61
- const m = h1.match(/^ADR-(\d{3})(?::|\s+—)\s*(.+)$/);
62
- const num = m ? m[1] : f.slice(0, 3);
63
- adrTitle[num] = sanitize(`ADR-${num} ${(m ? m[2] : h1).split(" — ")[0].trim()}`);
64
- }
65
- const linkAdrs = (body) =>
66
- body.replace(/\[([^\]]*)\]\((?:\.\/)?(\d{3})-[^)#\s]*\.md(?:#[^)]*)?\)/g, (all, _label, num) =>
67
- adrTitle[num] ? `[[${adrTitle[num]}]]` : all);
68
- mkdirSync(join(OUT, "adr"), { recursive: true });
69
- for (const f of adrFiles) {
70
- const t = adrTitle[f.slice(0, 3)];
71
- const staged = linkAdrs(stripH1(readFileSync(join(adrDir, f), "utf8")));
72
- writeFileSync(join(OUT, "adr", `${t}.md`), withSource(staged, `docs/decisions/${f}`));
70
+ /** The preset's own title rules, per set kind. */
71
+ function defaultTitle(kind, file, h1) {
72
+ if (kind === "adr") {
73
+ // Convention "ADR-NNN: title — tail"; the em-dash variant "ADR-NNN — title" also parses.
74
+ const m = h1.match(/^ADR-(\d{3})(?::|\s+—)\s*(.+)$/);
75
+ const num = m ? m[1] : basename(file).slice(0, 3);
76
+ return sanitize(`ADR-${num} ${(m ? m[2] : h1).split(" — ")[0].trim()}`);
77
+ }
78
+ if (kind === "as-built") {
79
+ const t = h1.replace(/^(System|Component|Feature|Module)\s*—\s*/i, "").split(" — ")[0].trim();
80
+ return sanitize(t || h1);
81
+ }
82
+ return sanitize(h1 || basename(file, ".md"));
73
83
  }
74
84
 
75
- // ---- as-built --------------------------------------------------------------
76
- const cats = ["system", "components", "features", "modules"];
77
- const abTitle = {};
78
- const abSrc = {};
79
- for (const cat of cats) {
80
- for (const f of readdirSync(join(DOCS, cat)).filter((x) => x.endsWith(".md"))) {
81
- const src = readFileSync(join(DOCS, cat, f), "utf8");
82
- let t = firstH1(src).replace(/^(System|Component|Feature|Module)\s*—\s*/i, "");
83
- t = t.split(" — ")[0].trim() || firstH1(src);
84
- abTitle[basename(f, ".md")] = sanitize(t);
85
- abSrc[basename(f, ".md")] = { cat, src };
85
+ // ---- collect -----------------------------------------------------------------
86
+ const docs = []; // { rel (to DOCS), map, folder, src, title }
87
+ for (const set of SETS) {
88
+ for (const d of set.dirs ?? []) {
89
+ const dir = join(DOCS, d);
90
+ if (!existsSync(dir)) continue;
91
+ for (const f of readdirSync(dir).sort()) {
92
+ if (!f.endsWith(".md") || (set.match && !set.match.test(f))) continue;
93
+ docs.push({ rel: posix(join(d, f)), map: set.map, kind: set.kind, folder: set.folders ? d : null });
94
+ }
95
+ }
96
+ for (const f of config.extra?.[set.map] ?? []) {
97
+ if (existsSync(join(DOCS, f))) docs.push({ rel: posix(f), map: set.map, kind: set.kind, folder: null });
86
98
  }
87
99
  }
88
- const linkAb = (body) =>
89
- body.replace(/\[([^\]]*)\]\((?:\.\.\/(?:system|components|features|modules)\/|\.\/)?([a-z0-9-]+)\.md(?:#[^)]*)?\)/g,
90
- (all, _label, name) => (abTitle[name] ? `[[${abTitle[name]}]]` : all));
91
- for (const [name, { cat, src }] of Object.entries(abSrc)) {
92
- mkdirSync(join(OUT, "as-built", cat), { recursive: true });
93
- writeFileSync(join(OUT, "as-built", cat, `${abTitle[name]}.md`), withSource(linkAb(stripH1(src)), `docs/${cat}/${name}.md`));
100
+ for (const doc of docs) {
101
+ doc.src = readFileSync(join(DOCS, doc.rel), "utf8");
102
+ const h1 = firstH1(doc.src);
103
+ const fallback = defaultTitle(doc.kind, doc.rel, h1);
104
+ const custom = config.title?.({ map: doc.map, kind: doc.kind, path: doc.rel, h1, defaultTitle: fallback });
105
+ doc.title = custom ? fileSafe(custom) : fallback;
94
106
  }
95
107
 
96
- console.log(`staged ${adrFiles.length} ADRs + ${Object.keys(abSrc).length} as-built docs → ${OUT}`);
97
- if (process.argv.includes("--stage-only")) process.exit(0);
108
+ // ---- links → [[wikilinks]] -----------------------------------------------------
109
+ const byRel = new Map(docs.map((d) => [d.rel, d]));
110
+ const byName = new Map(); // `${map}/${basename}` → docs (the fallback for bare `name.md` links)
111
+ for (const d of docs) {
112
+ const k = `${d.map}/${basename(d.rel)}`;
113
+ byName.set(k, [...(byName.get(k) ?? []), d]);
114
+ }
115
+ const LINK = /\[([^\]]*)\]\(([^)\s]+\.md)(#[^)\s]*)?\)/g;
116
+ function linkify(doc, body) {
117
+ return body.replace(LINK, (whole, _label, href) => {
118
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("/")) return whole;
119
+ const rel = posix(relative(DOCS, resolve(DOCS, dirname(doc.rel), href)));
120
+ let target = byRel.get(rel);
121
+ if (!target) {
122
+ const same = byName.get(`${doc.map}/${basename(href)}`);
123
+ if (same?.length === 1) target = same[0];
124
+ }
125
+ // ADRs are addressed by number: a link whose slug went stale after a retitle still names
126
+ // the right record (`./030-old-slug.md` → ADR-030).
127
+ const num = !target && /^(\d{3})-/.exec(basename(href))?.[1];
128
+ if (num) {
129
+ const adr = docs.filter((d) => d.kind === "adr" && basename(d.rel).startsWith(`${num}-`));
130
+ if (adr.length === 1) target = adr[0];
131
+ }
132
+ if (!target || (LINKS !== "all" && target.map !== doc.map)) return whole;
133
+ return `[[${target.title}]]`;
134
+ });
135
+ }
136
+
137
+ // ---- stage -----------------------------------------------------------------------
138
+ // A fresh dir per run: a fixed shared path let another repo's copy of this script, running at the
139
+ // same time, restage its own docs under us, and our import pushed them into this project's maps.
140
+ const OUT = mkdtempSync(join(tmpdir(), "alkahest-docs-staging-"));
141
+ const maps = [...new Set(SETS.map((s) => s.map))];
142
+ for (const doc of docs) {
143
+ const dir = join(OUT, doc.map, ...(doc.folder ? doc.folder.split("/") : []));
144
+ mkdirSync(dir, { recursive: true });
145
+ const docsRel = posix(relative(ROOT, join(DOCS, doc.rel)));
146
+ writeFileSync(join(dir, `${doc.title}.md`), withSource(linkify(doc, stripH1(doc.src)), docsRel));
147
+ }
148
+ for (const m of maps) mkdirSync(join(OUT, m), { recursive: true });
149
+ console.log(`staged ${maps.map((m) => `${docs.filter((d) => d.map === m).length} ${m}`).join(" + ")} docs → ${OUT}`);
150
+ if (STAGE_ONLY) process.exit(0);
151
+
152
+ // ---- import ------------------------------------------------------------------------
153
+ const scope = ["--path", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])];
154
+ if (PROJECT) console.log(`project: ${PROJECT}`);
155
+
156
+ // A note map that doesn't exist yet is created (the preset install makes them, but a repo can
157
+ // add a set or move projects). Skipped on --dry-run: a plan must not create anything.
158
+ if (!DRY_RUN && config.createMaps !== false) {
159
+ const list = spawnSync("alkahest", ["maps", "list", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])], { encoding: "utf8" });
160
+ const have = list.status === 0 ? list.stdout : null;
161
+ for (const m of maps) {
162
+ if (have === null || new RegExp(`(^|[^\\w-])${m.replace(/[-]/g, "\\-")}([^\\w-]|$)`, "m").test(have)) continue;
163
+ console.log(`[sync] creating note map '${m}'`);
164
+ spawnSync("alkahest", ["maps", "create", m, "--type", "note", ...scope], { stdio: "inherit" });
165
+ }
166
+ }
98
167
 
99
168
  // A full mirror is many sequential POSTs, so a transient failure is a question of when, not
100
169
  // if. The CLI handles it per file (keeps going, prints `✗ <file>: <message>`, exits 1) — act
101
170
  // on the exit code and retry the whole set: rows that already landed come back as updates.
102
- const ATTEMPTS = 3;
171
+ const ATTEMPTS = DRY_RUN ? 1 : 3;
103
172
  const failed = [];
104
-
105
- for (const [dir, map] of [["adr", "adr"], ["as-built", "as-built"]]) {
173
+ for (const m of maps) {
106
174
  let ok = false;
107
175
  for (let attempt = 1; attempt <= ATTEMPTS && !ok; attempt++) {
108
- console.log(`\n== notes import ${dir} → map ${map}${attempt > 1 ? ` (retry ${attempt - 1})` : ""} ==`);
109
- const run = spawnSync("alkahest", ["notes", "import", join(OUT, dir), "--map", map, "--path", ROOT], {
176
+ console.log(`\n== notes import ${m} → map ${m}${DRY_RUN ? " (dry run)" : ""}${attempt > 1 ? ` (retry ${attempt - 1})` : ""} ==`);
177
+ const run = spawnSync("alkahest", ["notes", "import", join(OUT, m), "--map", m, ...scope, ...(DRY_RUN ? ["--dry-run"] : [])], {
110
178
  stdio: "inherit",
111
179
  });
112
180
  if (run.error) console.error(`[sync] could not run alkahest: ${run.error.message}`);
113
181
  ok = !run.error && run.status === 0;
114
182
  if (!ok && attempt < ATTEMPTS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
115
183
  }
116
- if (!ok) failed.push(`${dir} → ${map}`);
184
+ if (!ok) failed.push(m);
117
185
  }
118
186
 
119
187
  if (failed.length) {
120
- console.error(`\n[sync] FAILED after ${ATTEMPTS} attempts: ${failed.join(", ")}`);
188
+ console.error(`\n[sync] FAILED after ${ATTEMPTS} attempt(s): ${failed.join(", ")}`);
121
189
  console.error("[sync] scroll up for the per-file '✗ <file>: <message>' lines from the importer.");
122
190
  console.error(`[sync] staged files kept for inspection: ${OUT}`);
123
191
  process.exit(1);
124
192
  }
125
- // The staging dir is this run's own (mkdtempSync), so removing it touches no other sync.
126
- // Kept on --stage-only (that's what it's for) and on failure (above). Imported here rather
127
- // than at the top so `alkahest preset update` can merge this into copies that reorganised
128
- // their import block.
129
- (await import("node:fs")).rmSync(OUT, { recursive: true, force: true });
130
- console.log("\n[sync] both maps up to date.");
193
+ // The staging dir is this run's own, so removing it touches no other sync. Kept on
194
+ // --stage-only (that's what it's for) and on failure (above).
195
+ rmSync(OUT, { recursive: true, force: true });
196
+ console.log(DRY_RUN ? "\n[sync] dry run done — nothing written." : `\n[sync] ${maps.join(" + ")} up to date.`);