@ctxr/skill-llm-wiki 1.0.1 → 1.0.2

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,229 @@
1
+ // contract.mjs — stable, machine-readable description of what this
2
+ // skill speaks to consumers. The single source of truth for the
3
+ // skill's format + CLI surface version.
4
+ //
5
+ // Consumers (other skills, agents, CI jobs) invoke
6
+ // `node scripts/cli.mjs contract --json` and assert
7
+ // `format_version >= <their required>` rather than drift-testing
8
+ // against SKILL.md prose. Bump FORMAT_VERSION on any breaking change
9
+ // to: leaf frontmatter schema, layout-contract grammar, CLI wire
10
+ // protocol, or exit-code meanings. Additive changes do not bump.
11
+ //
12
+ // The shape returned by `getContract()` is documented in
13
+ // guide/consumers/recipes/format-gate.md.
14
+
15
+ import { readFileSync } from "node:fs";
16
+
17
+ // ─── Version constants ──────────────────────────────────────────────
18
+ // `FORMAT_VERSION` is an integer. Bumps are breaking changes to the
19
+ // consumer-visible contract. Start at 1.
20
+ export const FORMAT_VERSION = 1;
21
+
22
+ // `MIN_CONSUMER_FORMAT_VERSION` is the oldest consumer-declared
23
+ // format_version this skill still speaks to. A consumer whose
24
+ // `required_format_version` is below this refuses to run; a consumer
25
+ // whose `required_format_version` is between this and FORMAT_VERSION
26
+ // runs unchanged. Bumps here signal a deprecation window closing.
27
+ export const MIN_CONSUMER_FORMAT_VERSION = 1;
28
+
29
+ // ─── Canonical shape ────────────────────────────────────────────────
30
+
31
+ // Leaf + index frontmatter fields the skill reads and writes.
32
+ // Mirrors scripts/lib/draft.mjs AUTHORED_LEAF_FIELDS for leaves
33
+ // and scripts/lib/indices.mjs / scripts/lib/validate.mjs for
34
+ // indices. Enums here are the canonical values the skill's own
35
+ // code emits and validate.mjs accepts; consumers authoring their
36
+ // own frontmatter must pick from these.
37
+ const FRONTMATTER_SCHEMA = {
38
+ leaf: {
39
+ required: ["id", "type", "depth_role", "focus", "parents", "source"],
40
+ fields: {
41
+ id: { kind: "string", description: "Unique leaf identifier; derived from source path." },
42
+ // Leaves are `primary` by default; `overlay` is a dedicated
43
+ // type that carries a smaller body budget (see validate.mjs
44
+ // SIZE-CAP) and mandates overlay_targets[].
45
+ type: { kind: "enum", values: ["primary", "overlay"] },
46
+ // Leaves only ever carry depth_role "leaf". Indices have
47
+ // their own depth_role vocabulary (see index.depth_role
48
+ // below).
49
+ depth_role: { kind: "enum", values: ["leaf"] },
50
+ focus: { kind: "string", description: "One-line subject of the leaf." },
51
+ covers: { kind: "string[]", description: "Sub-topics or H2 headings." },
52
+ parents: { kind: "string[]", description: "Relative paths to parent index.md files (e.g. [index.md] or [../index.md])." },
53
+ tags: { kind: "string[]" },
54
+ source: {
55
+ kind: "object",
56
+ fields: {
57
+ origin: { kind: "enum", values: ["file", "synthetic"] },
58
+ path: { kind: "string", description: "POSIX-relative path from the source root." },
59
+ hash: { kind: "string", description: "SHA-256 of the source content." },
60
+ },
61
+ },
62
+ activation: { kind: "object", description: "Routing hints: keyword_matches, tag_matches, etc." },
63
+ domains: { kind: "string[]" },
64
+ aliases: { kind: "string[]" },
65
+ shared_covers: { kind: "string[]" },
66
+ // Only present (and required) when type === "overlay".
67
+ overlay_targets: { kind: "string[]" },
68
+ links: { kind: "string[]" },
69
+ },
70
+ },
71
+ index: {
72
+ required: ["id", "type", "depth_role", "focus"],
73
+ fields: {
74
+ id: { kind: "string", description: "Must match the containing directory's basename." },
75
+ type: { kind: "enum", values: ["index"] },
76
+ // `category` is the root index of a wiki; `subcategory` is
77
+ // any nested index. There is no `root` or `branch` role in
78
+ // the canonical shape; the skill refuses those.
79
+ depth_role: { kind: "enum", values: ["category", "subcategory"] },
80
+ focus: { kind: "string", description: "One-line subject of this branch." },
81
+ depth: { kind: "integer", description: "0 for root category, 1+ for subcategories." },
82
+ parents: { kind: "string[]", description: "Empty at the root; [../index.md] for nested." },
83
+ children: { kind: "string[]", description: "Relative paths to nested index files." },
84
+ entries: { kind: "object[]", description: "Per-leaf summaries (id, file, type, focus, tags)." },
85
+ shared_covers: { kind: "string[]", description: "Inherited by this branch's leaves." },
86
+ orientation: { kind: "string", description: "Authored guidance block; preserved across rebuilds." },
87
+ rebuild_command: { kind: "string" },
88
+ // Skill-generated marker; validate rejects a wiki root
89
+ // whose root index.md lacks it.
90
+ generator: { kind: "string", description: "e.g. \"skill-llm-wiki/v1\"; required on the root index." },
91
+ },
92
+ },
93
+ };
94
+
95
+ // Dynamic-subdirs template tokens supported inside
96
+ // `.llmwiki.layout.yaml` under `layout[].dynamic_subdirs.template`.
97
+ const LAYOUT_TOKENS = [
98
+ { token: "{yyyy}", description: "4-digit year." },
99
+ { token: "{mm}", description: "2-digit month." },
100
+ { token: "{dd}", description: "2-digit day of month." },
101
+ { token: "{slug}", description: "Leaf slug derived from source filename." },
102
+ ];
103
+
104
+ // Exit code contract. Mirrors the banner in cli.mjs printUsage().
105
+ // Listed here so consumers have a single machine-readable source.
106
+ const EXIT_CODES = {
107
+ 0: "ok",
108
+ 1: "usage error",
109
+ 2: "validation or ambiguity error",
110
+ 3: "resolve-wiki miss",
111
+ 4: "Node.js too old",
112
+ 5: "git missing or too old",
113
+ 6: "wiki corrupt",
114
+ 7: "NEEDS_TIER2 — suspend and resume; not a failure",
115
+ 8: "DEPS_MISSING — required runtime dep missing",
116
+ };
117
+
118
+ // Top-level subcommands consumers are expected to invoke. Low-level
119
+ // helpers (`ingest`, `draft-leaf`, etc.) are deliberately omitted
120
+ // from the contract: they are internal tools, subject to change
121
+ // without a format_version bump. Keep this list in sync with
122
+ // cli.mjs printUsage() top-level operations.
123
+ // Keep this table in sync with scripts/cli.mjs. A drift test in
124
+ // tests/unit/contract.test.mjs asserts every flag listed here is
125
+ // actually accepted by the CLI's shared parser or one of the
126
+ // per-subcommand handlers. `SUBCOMMANDS[*].flags` lists canonical
127
+ // consumer-surface flags only; legacy aliases accepted by the CLI
128
+ // (for example `--json-errors` as an alias of `--json`) are
129
+ // deliberately omitted so consumers standardise on the current
130
+ // flag form.
131
+ const SUBCOMMANDS = {
132
+ build: {
133
+ positionals: ["source"],
134
+ flags: [
135
+ "--layout-mode",
136
+ "--target",
137
+ "--quality-mode",
138
+ "--no-prompt",
139
+ "--accept-dirty",
140
+ "--accept-foreign-target",
141
+ "--json",
142
+ ],
143
+ },
144
+ extend: {
145
+ positionals: ["wiki"],
146
+ flags: ["--quality-mode", "--no-prompt", "--json"],
147
+ },
148
+ validate: { positionals: ["wiki"], flags: ["--json"] },
149
+ rebuild: {
150
+ positionals: ["wiki"],
151
+ flags: ["--quality-mode", "--review", "--no-prompt", "--json"],
152
+ },
153
+ fix: { positionals: ["wiki"], flags: ["--json"] },
154
+ join: {
155
+ positionals: ["wiki-a", "wiki-b"],
156
+ flags: ["--target", "--canonical", "--json"],
157
+ },
158
+ rollback: { positionals: ["wiki"], flags: ["--to", "--json"] },
159
+ init: {
160
+ positionals: ["topic"],
161
+ flags: ["--kind", "--template", "--force", "--json"],
162
+ },
163
+ heal: { positionals: ["wiki"], flags: ["--dry-run", "--json"] },
164
+ where: { positionals: [], flags: ["--json"] },
165
+ contract: { positionals: [], flags: ["--json"] },
166
+ "testkit-stub": { positionals: [], flags: ["--at", "--layout"] },
167
+ };
168
+
169
+ // Envelope schema that --json stdout follows across every command.
170
+ // Full JSON Schema lives in scripts/lib/json-envelope.mjs (Feature
171
+ // 5). Consumers gate on the `schema` discriminator.
172
+ const ENVELOPE_SCHEMA = {
173
+ schema: "skill-llm-wiki/v1",
174
+ fields: {
175
+ schema: { kind: "string", const: "skill-llm-wiki/v1" },
176
+ command: { kind: "string" },
177
+ target: { kind: "string|null" },
178
+ verdict: { kind: "string" },
179
+ exit: { kind: "integer" },
180
+ diagnostics: { kind: "object[]" },
181
+ artifacts: { kind: "object" },
182
+ timing_ms: { kind: "integer" },
183
+ // `next` is optional: present only when the subcommand wants
184
+ // to hand the consumer a machine-readable follow-up command
185
+ // (init emits `{command:"skill-llm-wiki", args:["build",...]}`,
186
+ // heal emits the fix/rebuild invocation). When absent, the
187
+ // consumer has nothing to run.
188
+ next: { kind: "object|null", fields: { command: "string", args: "string[]" } },
189
+ },
190
+ };
191
+
192
+ // ─── Assembly ───────────────────────────────────────────────────────
193
+
194
+ function packageVersion() {
195
+ try {
196
+ const pkgUrl = new URL("../../package.json", import.meta.url);
197
+ return JSON.parse(readFileSync(pkgUrl, "utf8")).version;
198
+ } catch {
199
+ return "unknown";
200
+ }
201
+ }
202
+
203
+ export function getContract() {
204
+ return {
205
+ schema: "skill-llm-wiki/contract/v1",
206
+ format_version: FORMAT_VERSION,
207
+ min_consumer_format_version: MIN_CONSUMER_FORMAT_VERSION,
208
+ package_version: packageVersion(),
209
+ frontmatter_schema: FRONTMATTER_SCHEMA,
210
+ layout_tokens: LAYOUT_TOKENS,
211
+ subcommands: SUBCOMMANDS,
212
+ envelope_schema: ENVELOPE_SCHEMA,
213
+ exit_codes: EXIT_CODES,
214
+ };
215
+ }
216
+
217
+ // Human-readable summary. Used when `contract` is invoked without
218
+ // --json. Keep it short: anyone who wants detail takes --json.
219
+ export function renderContractText(contract) {
220
+ const lines = [];
221
+ lines.push(`skill-llm-wiki contract`);
222
+ lines.push(` package_version: ${contract.package_version}`);
223
+ lines.push(` format_version: ${contract.format_version}`);
224
+ lines.push(` min_consumer_format_version: ${contract.min_consumer_format_version}`);
225
+ lines.push(` subcommands: ${Object.keys(contract.subcommands).join(", ")}`);
226
+ lines.push(` layout_tokens: ${contract.layout_tokens.map((t) => t.token).join(" ")}`);
227
+ lines.push(` envelope: ${contract.envelope_schema.schema}`);
228
+ return lines.join("\n") + "\n";
229
+ }
@@ -0,0 +1,162 @@
1
+ // heal.mjs — classify validate findings and name the next command.
2
+ //
3
+ // `skill-llm-wiki heal <wiki>` runs validate internally, then maps
4
+ // the findings to one of five verdicts:
5
+ //
6
+ // ok → nothing to do
7
+ // fixable → `skill-llm-wiki fix <wiki>` will resolve it
8
+ // needs-rebuild → `skill-llm-wiki rebuild <wiki>` is required
9
+ // broken → the wiki is corrupt; manual intervention needed
10
+ // ambiguous → validate itself failed before producing findings
11
+ //
12
+ // The consumer invokes the recommended command as a separate step.
13
+ // Heal does not run fix/rebuild directly: doing so pulls in the
14
+ // orchestrator's error surface (Tier 2 exits, validation rollback,
15
+ // non-interactive refusal) that a classify-only shape deliberately
16
+ // avoids. A follow-up can add --apply once that error-mapping is
17
+ // proven.
18
+ //
19
+ // The classification table below is the public contract: consumers
20
+ // who want to pre-classify findings themselves (e.g. for a CI dash)
21
+ // can import FINDING_ACTIONS.
22
+
23
+ import { validateWiki } from "./validate.mjs";
24
+
25
+ // Map every known finding code to the minimum action that resolves
26
+ // it. Ranked from cheapest ("none") to most invasive ("manual"):
27
+ //
28
+ // none → a warning; no mutating step required
29
+ // fix → `skill-llm-wiki fix` is sufficient
30
+ // rebuild → `skill-llm-wiki rebuild` is required
31
+ // manual → the wiki is broken in a way the skill cannot self-heal
32
+ export const FINDING_ACTIONS = Object.freeze({
33
+ // Wiki substrate / git:
34
+ "WIKI-01": "manual", // not a valid wiki root
35
+ "GIT-01": "manual", // private git is broken / divergent
36
+
37
+ // Content loss:
38
+ "LOSS-01": "rebuild", // source bytes not accounted for in target
39
+
40
+ // Parse / malformed frontmatter:
41
+ PARSE: "manual", // cannot parse YAML; user must edit
42
+
43
+ // Frontmatter field issues — fix regenerates most of these:
44
+ "MISSING-FIELD": "fix",
45
+ "DUP-ID": "rebuild",
46
+ "ALIAS-COLLIDES-ID": "fix",
47
+ "ID-MISMATCH-DIR": "rebuild",
48
+ "ID-MISMATCH-FILE": "rebuild",
49
+ "DEPTH-ROLE": "rebuild",
50
+ "PARENTS-REQUIRED": "rebuild",
51
+ "PARENT-CONTRACT": "rebuild",
52
+ "DANGLING-LINK": "fix",
53
+ "DANGLING-OVERLAY": "fix",
54
+
55
+ // Size cap is a warning surface only:
56
+ "SIZE-CAP": "none",
57
+ });
58
+
59
+ // Priority ranking: if any finding maps to a higher-priority action,
60
+ // that action wins for the whole wiki.
61
+ const PRIORITY = Object.freeze({ none: 0, fix: 1, rebuild: 2, manual: 3 });
62
+
63
+ // Verdict that corresponds to each action tier. Keeps the four
64
+ // tables (FINDING_ACTIONS, PRIORITY, NEXT_COMMAND_BY_ACTION,
65
+ // VERDICT_BY_ACTION) in parallel so adding a new action tier means
66
+ // adding one row in each.
67
+ const VERDICT_BY_ACTION = Object.freeze({
68
+ none: "ok",
69
+ fix: "fixable",
70
+ rebuild: "needs-rebuild",
71
+ manual: "broken",
72
+ });
73
+
74
+ function actionFor(code) {
75
+ return FINDING_ACTIONS[code] ?? "rebuild";
76
+ }
77
+
78
+ // Core routing. Pure: call validateWiki separately if you want to
79
+ // avoid re-validating.
80
+ export function classifyFindings(findings) {
81
+ const actions = new Set();
82
+ for (const f of findings) {
83
+ // Warnings never trigger a mutating verdict — they're advisory.
84
+ if (f.severity !== "error") continue;
85
+ actions.add(actionFor(f.code));
86
+ }
87
+ if (actions.size === 0) {
88
+ return { action: "none", verdict: "ok" };
89
+ }
90
+ let best = "none";
91
+ for (const a of actions) {
92
+ if (PRIORITY[a] > PRIORITY[best]) best = a;
93
+ }
94
+ return { action: best, verdict: VERDICT_BY_ACTION[best] };
95
+ }
96
+
97
+ // Full heal run against a wiki path. Returns an object the CLI
98
+ // wraps into an envelope.
99
+ export function runHeal(wikiPath) {
100
+ let findings;
101
+ try {
102
+ findings = validateWiki(wikiPath);
103
+ } catch (err) {
104
+ return {
105
+ target: wikiPath,
106
+ verdict: "ambiguous",
107
+ action: "manual",
108
+ findings: [],
109
+ error: err.message,
110
+ next_command: null,
111
+ };
112
+ }
113
+ const { action, verdict } = classifyFindings(findings);
114
+ const next_command = buildNextCommand(action, wikiPath);
115
+ return {
116
+ target: wikiPath,
117
+ verdict,
118
+ action,
119
+ findings,
120
+ error: null,
121
+ next_command,
122
+ };
123
+ }
124
+
125
+ // Map every action to the CLI invocation that resolves it. A map
126
+ // rather than an if/else chain keeps the action vocabulary in one
127
+ // place next to FINDING_ACTIONS / PRIORITY / VERDICT_BY_ACTION. To
128
+ // add a new action tier, add a row here; anything else falls back
129
+ // to `null` (no auto-step).
130
+ const NEXT_COMMAND_BY_ACTION = Object.freeze({
131
+ none: null,
132
+ fix: (wikiPath) => ["skill-llm-wiki", "fix", wikiPath, "--json"],
133
+ rebuild: (wikiPath) => ["skill-llm-wiki", "rebuild", wikiPath, "--json"],
134
+ manual: null,
135
+ });
136
+
137
+ function buildNextCommand(action, wikiPath) {
138
+ const builder = NEXT_COMMAND_BY_ACTION[action];
139
+ if (typeof builder !== "function") return null;
140
+ return builder(wikiPath);
141
+ }
142
+
143
+ // Human-readable rendering of a runHeal result. Lives here so the
144
+ // text and JSON output of heal stay under the same roof and cannot
145
+ // drift. Mirrors the renderContractText / renderInitText pattern.
146
+ export function renderHealText(result) {
147
+ const lines = [`heal: ${result.verdict} (${result.action})`];
148
+ for (const f of result.findings) {
149
+ const tag =
150
+ f.severity === "error"
151
+ ? "ERR "
152
+ : f.severity === "warning"
153
+ ? "WARN"
154
+ : "INFO";
155
+ lines.push(` [${tag}] ${f.code} ${f.target}`);
156
+ lines.push(` ${f.message}`);
157
+ }
158
+ if (result.next_command) {
159
+ lines.push(` next: ${result.next_command.join(" ")}`);
160
+ }
161
+ return lines.join("\n") + "\n";
162
+ }
@@ -0,0 +1,210 @@
1
+ // init.mjs — seed a topic directory with a shipped layout contract.
2
+ //
3
+ // `skill-llm-wiki init <topic> --kind dated|subject [--template <name>] [--force]`
4
+ //
5
+ // Removes the cp + edit + build-flag dance every consumer reinvents.
6
+ // Seeds the contract file and returns a structured envelope telling
7
+ // the consumer the exact build command to run next. Auto-build is
8
+ // intentionally not included here: running build internally pulls in
9
+ // the full orchestrator error surface (Tier 2 exits, validation
10
+ // rollback, non-interactive refusal). A follow-up can add a --build
11
+ // flag once the error-mapping story is proven out.
12
+ //
13
+ // Behaviour:
14
+ // 1. Resolve topic path relative to cwd; create parent dirs if needed.
15
+ // 2. Refuse if topic exists as a file, not a directory.
16
+ // 3. Select template: explicit --template wins; otherwise
17
+ // defaultTemplateForKind.
18
+ // 4. Refuse if <topic>/.llmwiki.layout.yaml already exists, unless
19
+ // --force.
20
+ // 5. Copy the template's body to <topic>/.llmwiki.layout.yaml.
21
+ // 6. Return a structured result; the CLI wraps it in the envelope.
22
+
23
+ import {
24
+ existsSync,
25
+ lstatSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { dirname, join, resolve as pathResolve } from "node:path";
31
+ import {
32
+ defaultTemplateForKind,
33
+ getTemplate,
34
+ listTemplates,
35
+ } from "./templates.mjs";
36
+
37
+ const CONTRACT_FILENAME = ".llmwiki.layout.yaml";
38
+
39
+ // Walk UP from `absTopic` to the first existing ancestor and
40
+ // lstat it. Refuse if that ancestor is a symbolic link. This
41
+ // catches the attack where a pre-existing symlink on the path to
42
+ // absTopic would let `mkdirSync(recursive: true)` follow it and
43
+ // create directories outside the user's intended tree.
44
+ //
45
+ // Non-existent segments need no check — mkdir creates them fresh.
46
+ // Segments ABOVE the first existing ancestor are user-environment
47
+ // territory (including OS symlinks like macOS's /var), not our
48
+ // concern, and walking into them would produce false positives.
49
+ function refuseSymlinkOnExistingAncestor(absTopic) {
50
+ let cursor = absTopic;
51
+ while (!existsSync(cursor)) {
52
+ const parent = dirname(cursor);
53
+ if (parent === cursor) return; // reached filesystem root, no anchor
54
+ cursor = parent;
55
+ }
56
+ const st = lstatSync(cursor);
57
+ if (st.isSymbolicLink()) {
58
+ throw new InitError(
59
+ "INIT-08",
60
+ `init: ${cursor} is a symbolic link on the path to ${absTopic}; refusing to write through it. Remove or resolve the symlink explicitly before initialising.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ export class InitError extends Error {
66
+ constructor(code, message) {
67
+ super(message);
68
+ this.code = code;
69
+ }
70
+ }
71
+
72
+ export function runInit({
73
+ topic,
74
+ kind = null,
75
+ template = null,
76
+ force = false,
77
+ cwd = process.cwd(),
78
+ } = {}) {
79
+ if (!topic || typeof topic !== "string") {
80
+ throw new InitError("INIT-01", "init requires a <topic> path");
81
+ }
82
+ const absTopic = pathResolve(cwd, topic);
83
+
84
+ // Pick the template.
85
+ let templateName = template;
86
+ if (!templateName) {
87
+ if (!kind) {
88
+ throw new InitError(
89
+ "INIT-02",
90
+ "init requires either --template <name> or --kind <dated|subject>",
91
+ );
92
+ }
93
+ templateName = defaultTemplateForKind(kind);
94
+ if (!templateName) {
95
+ throw new InitError(
96
+ "INIT-03",
97
+ `init: unknown --kind "${kind}". Accepted: dated, subject.`,
98
+ );
99
+ }
100
+ }
101
+ const tmpl = getTemplate(templateName);
102
+ if (!tmpl) {
103
+ const available = Object.keys(listTemplates()).join(", ");
104
+ throw new InitError(
105
+ "INIT-04",
106
+ `init: template "${templateName}" not found. Available: ${available}`,
107
+ );
108
+ }
109
+ // If the caller supplied both --kind and --template, check that
110
+ // they're compatible so we catch "`--kind dated --template runbooks`"
111
+ // before the consumer is surprised by a dated mis-shape.
112
+ if (kind && tmpl.kind !== "unknown" && tmpl.kind !== kind) {
113
+ throw new InitError(
114
+ "INIT-05",
115
+ `init: template "${templateName}" is kind=${tmpl.kind}, but --kind ${kind} was requested.`,
116
+ );
117
+ }
118
+
119
+ // Refuse to write through a pre-existing symlink in the topic
120
+ // path. Two shapes to catch:
121
+ // (a) absTopic itself is a symbolic link — covered by the
122
+ // existing-path branch below.
123
+ // (b) an intermediate segment on the way to absTopic is a
124
+ // symlink (e.g. `<parent>/sub -> /etc/` before init runs).
125
+ // `mkdirSync(recursive: true)` would follow it and create
126
+ // directories outside the user's intended topic tree.
127
+ //
128
+ // Algorithm: walk UP from absTopic until we find the first
129
+ // existing ancestor (the "anchor"), then lstat it. If the
130
+ // anchor is a symlink, refuse. Segments that do NOT yet exist
131
+ // are safe — mkdir creates them fresh. We deliberately do NOT
132
+ // walk past the anchor upward: OS-level symlinks above the
133
+ // user-chosen path (macOS's /var → /private/var on tmpdirs)
134
+ // would false-positive and block every test.
135
+ refuseSymlinkOnExistingAncestor(absTopic);
136
+
137
+ if (existsSync(absTopic)) {
138
+ const st = lstatSync(absTopic);
139
+ if (!st.isDirectory()) {
140
+ throw new InitError(
141
+ "INIT-06",
142
+ `init: ${absTopic} exists but is not a directory.`,
143
+ );
144
+ }
145
+ } else {
146
+ mkdirSync(absTopic, { recursive: true });
147
+ }
148
+
149
+ const contractPath = join(absTopic, CONTRACT_FILENAME);
150
+ // Same symlink guard for the contract file itself. An attacker
151
+ // who controls the topic directory could plant a symlink at
152
+ // <topic>/.llmwiki.layout.yaml pointing anywhere; without lstat
153
+ // we'd follow it on writeFileSync.
154
+ if (existsSync(contractPath)) {
155
+ const cst = lstatSync(contractPath);
156
+ if (cst.isSymbolicLink()) {
157
+ throw new InitError(
158
+ "INIT-08",
159
+ `init: ${contractPath} is a symbolic link; refusing to overwrite through it.`,
160
+ );
161
+ }
162
+ }
163
+ const alreadyPresent = existsSync(contractPath);
164
+ if (alreadyPresent && !force) {
165
+ throw new InitError(
166
+ "INIT-07",
167
+ `init: ${CONTRACT_FILENAME} already exists at ${absTopic}. Pass --force to overwrite, or use \`skill-llm-wiki rebuild\` to reconcile against the existing contract.`,
168
+ );
169
+ }
170
+
171
+ const body = readFileSync(tmpl.path, "utf8");
172
+ writeFileSync(contractPath, body, "utf8");
173
+
174
+ // The next step for the consumer. Passed back so the CLI can
175
+ // include it in the envelope both as a structured `next` field
176
+ // and as a human-readable NEXT-01 info diagnostic.
177
+ const buildCommand = [
178
+ "skill-llm-wiki",
179
+ "build",
180
+ absTopic,
181
+ "--layout-mode",
182
+ "hosted",
183
+ "--target",
184
+ absTopic,
185
+ "--json",
186
+ ];
187
+
188
+ return {
189
+ topic: absTopic,
190
+ template: templateName,
191
+ kind: tmpl.kind,
192
+ contract_path: contractPath,
193
+ overwrote: alreadyPresent,
194
+ build_command: buildCommand,
195
+ next: { command: buildCommand[0], args: buildCommand.slice(1) },
196
+ };
197
+ }
198
+
199
+ // Human-readable rendering of a runInit result. Lives here (not in
200
+ // cli.mjs) so the text and JSON output of init stay under the same
201
+ // roof and cannot drift. Mirrors the renderContractText /
202
+ // renderWhereText pattern.
203
+ export function renderInitText(result) {
204
+ return (
205
+ `init: seeded ${result.contract_path}\n` +
206
+ ` template: ${result.template} (kind=${result.kind})\n` +
207
+ (result.overwrote ? ` overwrote existing contract\n` : "") +
208
+ ` next: ${result.build_command.join(" ")}\n`
209
+ );
210
+ }