@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.
- package/SKILL.md +7 -0
- package/guide/cli.md +3 -2
- package/guide/consumers/index.md +106 -0
- package/guide/consumers/quickstart.md +96 -0
- package/guide/consumers/recipes/ci-gate.md +125 -0
- package/guide/consumers/recipes/dated-wiki.md +131 -0
- package/guide/consumers/recipes/format-gate.md +126 -0
- package/guide/consumers/recipes/post-write-heal.md +125 -0
- package/guide/consumers/recipes/skill-absent.md +111 -0
- package/guide/consumers/recipes/subject-wiki.md +110 -0
- package/guide/consumers/recipes/testing.md +149 -0
- package/guide/index.md +9 -0
- package/guide/ux/user-intent.md +5 -4
- package/package.json +6 -2
- package/scripts/cli.mjs +473 -13
- package/scripts/lib/contract.mjs +229 -0
- package/scripts/lib/heal.mjs +162 -0
- package/scripts/lib/init.mjs +210 -0
- package/scripts/lib/json-envelope.mjs +190 -0
- package/scripts/lib/templates.mjs +78 -0
- package/scripts/lib/where.mjs +71 -0
- package/scripts/testkit/assert-frontmatter.mjs +171 -0
- package/scripts/testkit/cli-run.mjs +95 -0
- package/scripts/testkit/make-wiki-fixture.mjs +301 -0
- package/scripts/testkit/stub-skill.mjs +107 -0
- package/templates/adrs.llmwiki.layout.yaml +33 -0
- package/templates/plans.llmwiki.layout.yaml +34 -0
- package/templates/regressions.llmwiki.layout.yaml +34 -0
- package/templates/reports.llmwiki.layout.yaml +33 -0
- package/templates/runbooks.llmwiki.layout.yaml +33 -0
- package/templates/sessions.llmwiki.layout.yaml +34 -0
|
@@ -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
|
+
}
|