@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,301 @@
|
|
|
1
|
+
// make-wiki-fixture.mjs — testkit helper: build a minimal hosted-
|
|
2
|
+
// mode wiki at a caller-supplied path using the skill's shipped
|
|
3
|
+
// starter templates. Useful for consumer tests that need a
|
|
4
|
+
// plausible wiki shape to read against without running the full
|
|
5
|
+
// build pipeline.
|
|
6
|
+
//
|
|
7
|
+
// What this does NOT do: invoke the full orchestrator. It seeds the
|
|
8
|
+
// layout contract and optionally writes seed leaves the consumer
|
|
9
|
+
// asks for. That is enough for consumers whose tests read/write
|
|
10
|
+
// frontmatter; for tests that exercise validate/fix/rebuild,
|
|
11
|
+
// consumers should `spawn` the CLI via cli-run.mjs.
|
|
12
|
+
//
|
|
13
|
+
// Zero runtime deps; pure Node built-ins.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
copyFile,
|
|
17
|
+
lstat,
|
|
18
|
+
mkdir,
|
|
19
|
+
writeFile,
|
|
20
|
+
} from "node:fs/promises";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import {
|
|
23
|
+
basename,
|
|
24
|
+
dirname,
|
|
25
|
+
isAbsolute,
|
|
26
|
+
join,
|
|
27
|
+
relative,
|
|
28
|
+
resolve,
|
|
29
|
+
} from "node:path";
|
|
30
|
+
import { defaultTemplateForKind, templatesDir } from "../lib/templates.mjs";
|
|
31
|
+
|
|
32
|
+
const VALID_KINDS = new Set(["dated", "subject"]);
|
|
33
|
+
|
|
34
|
+
// Reject any seed-leaf path that would escape the fixture root via
|
|
35
|
+
// an absolute path or `..` traversal. This is a LEXICAL check; it
|
|
36
|
+
// does not detect symlink-based escapes inside the fixture tree.
|
|
37
|
+
// For those, see `refuseSymlink` below, which walks every path
|
|
38
|
+
// segment from the root down to the leaf and rejects if any is a
|
|
39
|
+
// symbolic link. Together the two functions defend against:
|
|
40
|
+
// - absolute seed paths
|
|
41
|
+
// - `..` segments that resolve outside the root
|
|
42
|
+
// - symlinks anywhere in the resolved path's intermediate
|
|
43
|
+
// directories (e.g. `<root>/sub -> /etc/`)
|
|
44
|
+
function assertInsideRoot(rootAbs, entryRel) {
|
|
45
|
+
if (typeof entryRel !== "string" || entryRel.length === 0) {
|
|
46
|
+
throw new Error("makeWikiFixture: seedLeaves entries must have a non-empty path");
|
|
47
|
+
}
|
|
48
|
+
if (isAbsolute(entryRel)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`makeWikiFixture: seed-leaf path "${entryRel}" must be relative to the fixture root`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const resolved = resolve(rootAbs, entryRel);
|
|
54
|
+
const rel = relative(rootAbs, resolved);
|
|
55
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`makeWikiFixture: seed-leaf path "${entryRel}" resolves outside the fixture root`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Walk UP from `absPath` to the first existing ancestor and
|
|
64
|
+
// lstat it. Refuse if that ancestor is a symbolic link. Catches
|
|
65
|
+
// the attack where a pre-existing symlink on the path TO the
|
|
66
|
+
// fixture root would let `mkdir(rootAbs, {recursive:true})`
|
|
67
|
+
// follow it and create the fixture outside the caller's
|
|
68
|
+
// intended tree. Non-existent segments are safe — mkdir creates
|
|
69
|
+
// them fresh. We stop at the first existing ancestor, so OS-
|
|
70
|
+
// level symlinks above the caller-supplied tmp dir (macOS's
|
|
71
|
+
// /var → /private/var) don't false-positive.
|
|
72
|
+
async function refuseSymlinkOnExistingAncestor(absPath) {
|
|
73
|
+
let cursor = absPath;
|
|
74
|
+
while (true) {
|
|
75
|
+
try {
|
|
76
|
+
const st = await lstat(cursor);
|
|
77
|
+
if (st.isSymbolicLink()) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`makeWikiFixture: ${cursor} is a symbolic link on the path to ${absPath}; refusing to write through it`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return; // found first existing, and it's not a symlink
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err.code !== "ENOENT") throw err;
|
|
85
|
+
const parent = dirname(cursor);
|
|
86
|
+
if (parent === cursor) return; // filesystem root, no anchor
|
|
87
|
+
cursor = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Refuse to write through a pre-existing symlink.
|
|
93
|
+
//
|
|
94
|
+
// When called with a single `absPath` (no `rootAbs`), only that
|
|
95
|
+
// path is checked — useful for the one-time fixture-root probe,
|
|
96
|
+
// where climbing further up the chain would trip macOS's
|
|
97
|
+
// /var → /private/var (or similar OS-level symlinks that are NOT
|
|
98
|
+
// a fixture concern).
|
|
99
|
+
//
|
|
100
|
+
// When called with `rootAbs`, every segment from rootAbs down to
|
|
101
|
+
// absPath is checked. A lexical `assertInsideRoot` check can be
|
|
102
|
+
// bypassed by planting a symlinked sub-directory inside the
|
|
103
|
+
// fixture (e.g. `<root>/sub -> /etc/`) and then passing a
|
|
104
|
+
// seedLeaves entry like `sub/passwd`; walking every segment
|
|
105
|
+
// closes that.
|
|
106
|
+
//
|
|
107
|
+
// Non-existent segments are accepted (mkdir/writeFile will create
|
|
108
|
+
// them).
|
|
109
|
+
async function refuseSymlink(absPath, rootAbs = null) {
|
|
110
|
+
const segments = [];
|
|
111
|
+
if (rootAbs) {
|
|
112
|
+
// Build segment list from rootAbs DOWN to absPath so we only
|
|
113
|
+
// inspect paths inside the fixture. Never climb past rootAbs.
|
|
114
|
+
segments.push(rootAbs);
|
|
115
|
+
if (absPath !== rootAbs) {
|
|
116
|
+
const rel = relative(rootAbs, absPath);
|
|
117
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) {
|
|
118
|
+
const parts = rel.split(/[\\/]/).filter(Boolean);
|
|
119
|
+
let cursor = rootAbs;
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
cursor = join(cursor, part);
|
|
122
|
+
segments.push(cursor);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
segments.push(absPath);
|
|
128
|
+
}
|
|
129
|
+
for (const seg of segments) {
|
|
130
|
+
try {
|
|
131
|
+
const st = await lstat(seg);
|
|
132
|
+
if (st.isSymbolicLink()) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`makeWikiFixture: ${seg} is a symbolic link; refusing to write through it`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code !== "ENOENT") throw err;
|
|
139
|
+
// Segment doesn't exist yet — that's fine for mkdir's path.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const CONTRACT_FILENAME = ".llmwiki.layout.yaml";
|
|
145
|
+
|
|
146
|
+
export async function makeWikiFixture({
|
|
147
|
+
path,
|
|
148
|
+
kind = "dated",
|
|
149
|
+
template = null,
|
|
150
|
+
seedLeaves = [],
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!path || typeof path !== "string") {
|
|
153
|
+
throw new Error("makeWikiFixture: { path } is required");
|
|
154
|
+
}
|
|
155
|
+
// Validate `kind` up front and throw on unknown values rather
|
|
156
|
+
// than silently falling back to the dated template and then
|
|
157
|
+
// returning the caller's (invalid) `kind` in the result. This
|
|
158
|
+
// matches runInit's behaviour (INIT-03).
|
|
159
|
+
if (!VALID_KINDS.has(kind)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`makeWikiFixture: unknown kind "${kind}". Accepted: ${[...VALID_KINDS].join(", ")}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const rootAbs = resolve(path);
|
|
165
|
+
// Root-level check: walk UP from rootAbs to the first existing
|
|
166
|
+
// ancestor, lstat it, and refuse if it's a symlink. This catches
|
|
167
|
+
// an attacker-planted intermediate segment (e.g. `/tmp/foo -> /etc`
|
|
168
|
+
// before init runs `makeWikiFixture({ path: "/tmp/foo/wiki" })`,
|
|
169
|
+
// where mkdir(recursive:true) would otherwise follow the symlink
|
|
170
|
+
// and create the fixture outside the caller's intended tree.
|
|
171
|
+
// The walker stops at the first existing ancestor so OS-level
|
|
172
|
+
// symlinks above the caller's path (macOS's /var → /private/var)
|
|
173
|
+
// don't false-positive.
|
|
174
|
+
await refuseSymlinkOnExistingAncestor(rootAbs);
|
|
175
|
+
await mkdir(rootAbs, { recursive: true });
|
|
176
|
+
|
|
177
|
+
// Pick a template. `templatesDir()` returns the absolute path;
|
|
178
|
+
// filenames follow `<name>.llmwiki.layout.yaml`.
|
|
179
|
+
// Pick the default template from the shared table so
|
|
180
|
+
// makeWikiFixture and runInit stay in lockstep: both route
|
|
181
|
+
// dated → "reports", subject → "runbooks". Explicit --template
|
|
182
|
+
// still overrides.
|
|
183
|
+
const tmplName = template ?? defaultTemplateForKind(kind);
|
|
184
|
+
const tmplPath = join(templatesDir(), `${tmplName}.llmwiki.layout.yaml`);
|
|
185
|
+
if (!existsSync(tmplPath)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`makeWikiFixture: template "${tmplName}" not found at ${tmplPath}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const contractPath = join(rootAbs, CONTRACT_FILENAME);
|
|
191
|
+
await refuseSymlink(contractPath, rootAbs);
|
|
192
|
+
await copyFile(tmplPath, contractPath);
|
|
193
|
+
|
|
194
|
+
// Seed a root index.md so `skill-llm-wiki validate/heal` against
|
|
195
|
+
// the fixture doesn't trip WIKI-01 ("not a valid wiki root").
|
|
196
|
+
// validateWiki requires index.md with the `generator` marker, a
|
|
197
|
+
// matching `id`, `type: index`, `depth_role: category` for the
|
|
198
|
+
// root, and a non-empty `focus`.
|
|
199
|
+
const indexPath = join(rootAbs, "index.md");
|
|
200
|
+
await refuseSymlink(indexPath, rootAbs);
|
|
201
|
+
const rootId = basename(rootAbs);
|
|
202
|
+
await writeFile(indexPath, defaultRootIndexBody(rootId, tmplName), "utf8");
|
|
203
|
+
|
|
204
|
+
// Seed any requested leaves. Each entry is either:
|
|
205
|
+
// { path: "reports/2026/04/18/example.md", body: "..." }
|
|
206
|
+
// or just a plain string `"reports/2026/04/18/example.md"` which
|
|
207
|
+
// seeds a minimal leaf with default frontmatter.
|
|
208
|
+
const createdLeaves = [];
|
|
209
|
+
for (const raw of seedLeaves) {
|
|
210
|
+
const entry =
|
|
211
|
+
typeof raw === "string" ? { path: raw, body: null } : raw;
|
|
212
|
+
// Refuse any seed path that escapes the fixture root. Caller
|
|
213
|
+
// bugs (typos, absolute paths) are caught loudly before any
|
|
214
|
+
// write happens.
|
|
215
|
+
const abs = assertInsideRoot(rootAbs, entry.path);
|
|
216
|
+
const dirAbs = dirname(abs);
|
|
217
|
+
// IMPORTANT: segment-walk BEFORE mkdir. Once mkdir(recursive)
|
|
218
|
+
// runs, it follows any pre-existing symlinked sub-dir and can
|
|
219
|
+
// create directories OUTSIDE rootAbs — the attack this helper
|
|
220
|
+
// is explicitly defending against. Check every segment of
|
|
221
|
+
// dirAbs, then mkdir, then re-check the leaf path for a
|
|
222
|
+
// symlinked file.
|
|
223
|
+
await refuseSymlink(dirAbs, rootAbs);
|
|
224
|
+
await mkdir(dirAbs, { recursive: true });
|
|
225
|
+
await refuseSymlink(abs, rootAbs);
|
|
226
|
+
const body = entry.body ?? defaultLeafBody(entry.path);
|
|
227
|
+
await writeFile(abs, body, "utf8");
|
|
228
|
+
createdLeaves.push(abs);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
path,
|
|
233
|
+
template: tmplName,
|
|
234
|
+
kind,
|
|
235
|
+
contract_path: contractPath,
|
|
236
|
+
seeded_leaves: createdLeaves,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function defaultLeafBody(relativePath) {
|
|
241
|
+
const segments = relativePath.split(/[\\/]/).filter(Boolean);
|
|
242
|
+
const leafName = segments[segments.length - 1] ?? "leaf.md";
|
|
243
|
+
const id = leafName.replace(/\.md$/, "");
|
|
244
|
+
// Compute parents[] relative to the single root index.md that
|
|
245
|
+
// makeWikiFixture seeds. Depth is (segments - 1) because the
|
|
246
|
+
// last segment is the leaf file itself. A root-level leaf gets
|
|
247
|
+
// `index.md`, a 1-level-deep leaf gets `../index.md`, a 3-level
|
|
248
|
+
// date-partitioned leaf (yyyy/mm/dd/x.md) gets `../../../index.md`.
|
|
249
|
+
const depth = Math.max(0, segments.length - 1);
|
|
250
|
+
const parents = depth === 0 ? "index.md" : `${"../".repeat(depth)}index.md`;
|
|
251
|
+
// source.path is a POSIX-relative path per the contract
|
|
252
|
+
// (scripts/lib/contract.mjs frontmatter_schema.leaf.source.path).
|
|
253
|
+
// Windows callers may pass "a\\b\\c"; normalise.
|
|
254
|
+
const sourcePathPosix = relativePath.split(/[\\/]/).filter(Boolean).join("/");
|
|
255
|
+
return [
|
|
256
|
+
"---",
|
|
257
|
+
`id: ${id}`,
|
|
258
|
+
"type: primary",
|
|
259
|
+
"depth_role: leaf",
|
|
260
|
+
`focus: "${id}"`,
|
|
261
|
+
"covers: []",
|
|
262
|
+
`parents: [${parents}]`,
|
|
263
|
+
"tags: []",
|
|
264
|
+
`source:`,
|
|
265
|
+
` origin: file`,
|
|
266
|
+
` path: ${sourcePathPosix}`,
|
|
267
|
+
"---",
|
|
268
|
+
"",
|
|
269
|
+
`# ${id}`,
|
|
270
|
+
"",
|
|
271
|
+
"Fixture leaf body.",
|
|
272
|
+
"",
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Minimal root index.md for a fresh fixture. Carries the fields
|
|
277
|
+
// validateWiki + isWikiRoot require: generator marker, id matching
|
|
278
|
+
// basename, type: index, depth_role: category, focus, empty
|
|
279
|
+
// parents. Without this, `skill-llm-wiki validate/heal` against
|
|
280
|
+
// the fixture returns verdict "broken" with WIKI-01.
|
|
281
|
+
function defaultRootIndexBody(rootId, templateName) {
|
|
282
|
+
return [
|
|
283
|
+
"---",
|
|
284
|
+
`id: ${rootId}`,
|
|
285
|
+
"type: index",
|
|
286
|
+
"depth_role: category",
|
|
287
|
+
"depth: 0",
|
|
288
|
+
`focus: "test fixture: ${templateName}"`,
|
|
289
|
+
"parents: []",
|
|
290
|
+
"children: []",
|
|
291
|
+
"entries: []",
|
|
292
|
+
"shared_covers: []",
|
|
293
|
+
`generator: "skill-llm-wiki/v1"`,
|
|
294
|
+
"---",
|
|
295
|
+
"",
|
|
296
|
+
`# ${rootId}`,
|
|
297
|
+
"",
|
|
298
|
+
"Fixture root index seeded by makeWikiFixture. Not a production wiki.",
|
|
299
|
+
"",
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// stub-skill.mjs — testkit helper: seed a presence-only
|
|
2
|
+
// @ctxr/skill-llm-wiki install at a kit-canonical path under the
|
|
3
|
+
// caller-supplied base directory.
|
|
4
|
+
//
|
|
5
|
+
// Consumers use this in their test suites to satisfy the "is the
|
|
6
|
+
// skill installed?" preflight without needing a real skill
|
|
7
|
+
// checkout. The stub is NOT a working skill — it only carries the
|
|
8
|
+
// SKILL.md frontmatter consumers typically grep for. For richer
|
|
9
|
+
// test fixtures (a real hosted wiki), see make-wiki-fixture.mjs.
|
|
10
|
+
//
|
|
11
|
+
// Zero runtime deps; pure Node built-ins.
|
|
12
|
+
|
|
13
|
+
import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
15
|
+
import { FORMAT_VERSION } from "../lib/contract.mjs";
|
|
16
|
+
|
|
17
|
+
// The kit's ARTIFACT_TYPES.skill enumerates these install layouts.
|
|
18
|
+
// Consumers pick the one their test environment emulates.
|
|
19
|
+
const LAYOUTS = {
|
|
20
|
+
"claude-skills": [".claude", "skills"],
|
|
21
|
+
"agents-skills": [".agents", "skills"],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const STUB_SKILL_NAME = "ctxr-skill-llm-wiki";
|
|
25
|
+
|
|
26
|
+
// Walk every segment from `base` (inclusive) down to `target`
|
|
27
|
+
// (inclusive), lstat-ing each. Refuse if any segment is a
|
|
28
|
+
// symbolic link. Non-existent segments are accepted (they'll be
|
|
29
|
+
// created by mkdir). `base` itself is NOT walked further upward:
|
|
30
|
+
// we never inspect OS-level directories above the caller-supplied
|
|
31
|
+
// home (macOS's /var → /private/var, for example, would false-
|
|
32
|
+
// positive otherwise).
|
|
33
|
+
//
|
|
34
|
+
// Containment is validated with path.resolve + path.relative
|
|
35
|
+
// rather than a string-prefix check, so `/tmp/home2/x` is NOT
|
|
36
|
+
// misclassified as "under /tmp/home" and path separators /
|
|
37
|
+
// trailing slashes don't matter.
|
|
38
|
+
async function refuseSymlinkChain(base, target) {
|
|
39
|
+
const baseAbs = resolve(base);
|
|
40
|
+
const targetAbs = resolve(target);
|
|
41
|
+
const segments = [baseAbs];
|
|
42
|
+
if (targetAbs !== baseAbs) {
|
|
43
|
+
const rel = relative(baseAbs, targetAbs);
|
|
44
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`stubSkill: internal error, target ${target} is not under base ${base}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const parts = rel.split(/[\\/]/).filter(Boolean);
|
|
50
|
+
let cursor = baseAbs;
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
cursor = join(cursor, part);
|
|
53
|
+
segments.push(cursor);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const seg of segments) {
|
|
57
|
+
try {
|
|
58
|
+
const st = await lstat(seg);
|
|
59
|
+
if (st.isSymbolicLink()) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`stubSkill: ${seg} is a symbolic link; refusing to write through it`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code !== "ENOENT") throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function stubSkill({ home, layout = "claude-skills" } = {}) {
|
|
71
|
+
if (!home || typeof home !== "string") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"stubSkill: { home } is required (the base directory under which the stub install tree is seeded)",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const parts = LAYOUTS[layout];
|
|
77
|
+
if (!parts) {
|
|
78
|
+
const known = Object.keys(LAYOUTS).join(", ");
|
|
79
|
+
throw new Error(
|
|
80
|
+
`stubSkill: unknown layout "${layout}". Known: ${known}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const dir = join(home, ...parts, STUB_SKILL_NAME);
|
|
84
|
+
const skillMd = join(dir, "SKILL.md");
|
|
85
|
+
// Walk every intermediate segment (home → .claude → skills →
|
|
86
|
+
// ctxr-skill-llm-wiki) BEFORE mkdir. mkdir({recursive: true})
|
|
87
|
+
// follows symlinks, so a hostile fixture that planted
|
|
88
|
+
// `${home}/.claude -> /etc` would otherwise cause stubSkill to
|
|
89
|
+
// create `.claude/skills/ctxr-skill-llm-wiki` under `/etc/`.
|
|
90
|
+
await refuseSymlinkChain(home, dir);
|
|
91
|
+
await mkdir(dir, { recursive: true });
|
|
92
|
+
// Re-check the leaf file path before writing, so a pre-existing
|
|
93
|
+
// symlink at `<dir>/SKILL.md` is also rejected.
|
|
94
|
+
await refuseSymlinkChain(home, skillMd);
|
|
95
|
+
const body = [
|
|
96
|
+
"---",
|
|
97
|
+
"name: skill-llm-wiki",
|
|
98
|
+
"description: test stub of @ctxr/skill-llm-wiki — presence-only, not a working skill",
|
|
99
|
+
`format_version: ${FORMAT_VERSION}`,
|
|
100
|
+
"---",
|
|
101
|
+
"",
|
|
102
|
+
"This is a test stub. Do not invoke wiki operations against it.",
|
|
103
|
+
"",
|
|
104
|
+
].join("\n");
|
|
105
|
+
await writeFile(skillMd, body, "utf8");
|
|
106
|
+
return { dir, skillMd, layout };
|
|
107
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Layout contract for an architecture-decision-records (ADRs) topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind subject --template adrs
|
|
7
|
+
#
|
|
8
|
+
# ADRs are subject-grouped with a zero-padded sequential prefix per file
|
|
9
|
+
# (e.g. 0001-use-postgres.md). Subfolders emerge once a domain accumulates
|
|
10
|
+
# three or more decisions.
|
|
11
|
+
|
|
12
|
+
mode: hosted
|
|
13
|
+
|
|
14
|
+
versioning:
|
|
15
|
+
style: in-place
|
|
16
|
+
backup_before_mutate: true
|
|
17
|
+
backup_dir: .llmwiki.backups
|
|
18
|
+
|
|
19
|
+
purpose: "Architecture decision records, numbered and grouped by subject."
|
|
20
|
+
|
|
21
|
+
global_invariants:
|
|
22
|
+
- "filenames carry a zero-padded sequential prefix (NNNN-slug.md)"
|
|
23
|
+
- "superseded ADRs stay in place with status: superseded in frontmatter; never rename or delete"
|
|
24
|
+
- "category subfolders appear when three or more ADRs share a domain (storage/, auth/, transport/, ...)"
|
|
25
|
+
|
|
26
|
+
layout:
|
|
27
|
+
- path: .
|
|
28
|
+
purpose: "ADRs, numbered and grouped by subject"
|
|
29
|
+
allow_entry_types: [primary, index]
|
|
30
|
+
content_rules:
|
|
31
|
+
- "one decision per leaf; link to related decisions via parents[]"
|
|
32
|
+
- "frontmatter declares status: proposed | accepted | superseded | deprecated"
|
|
33
|
+
- "root-level leaves are reserved for broad, cross-cutting decisions; domain-specific nest under a subject subfolder"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Layout contract for a plans topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind dated --template plans
|
|
7
|
+
#
|
|
8
|
+
# Plans are hybrid: most live in dated subfolders alongside when they were
|
|
9
|
+
# drafted, but long-running plan families live under a subject subfolder
|
|
10
|
+
# instead. Both shapes are valid under this contract.
|
|
11
|
+
|
|
12
|
+
mode: hosted
|
|
13
|
+
|
|
14
|
+
versioning:
|
|
15
|
+
style: in-place
|
|
16
|
+
backup_before_mutate: true
|
|
17
|
+
backup_dir: .llmwiki.backups
|
|
18
|
+
|
|
19
|
+
purpose: "Implementation plans: dated drafts under {yyyy}/{mm}/{dd}/, long-running families under a subject subfolder."
|
|
20
|
+
|
|
21
|
+
global_invariants:
|
|
22
|
+
- "no flat date-prefixed leaves at the topic root"
|
|
23
|
+
- "plan families (3+ related plans under one umbrella) live in a subject subfolder, not a date subfolder"
|
|
24
|
+
|
|
25
|
+
layout:
|
|
26
|
+
- path: .
|
|
27
|
+
purpose: "plans filed by date, with subject subfolders for long-running families"
|
|
28
|
+
dynamic_subdirs:
|
|
29
|
+
template: "{yyyy}/{mm}/{dd}"
|
|
30
|
+
purpose: "one-off plans drafted on a given day"
|
|
31
|
+
allow_entry_types: [primary]
|
|
32
|
+
content_rules:
|
|
33
|
+
- "slug encodes the subject, not the date"
|
|
34
|
+
- "promote a plan to a named subject subfolder once a third related plan appears"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Layout contract for a dated regressions topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind dated --template regressions
|
|
7
|
+
#
|
|
8
|
+
# Regressions are lower-volume than reports or sessions, so the default
|
|
9
|
+
# template is {yyyy}/{mm}. If your regression volume grows past a few
|
|
10
|
+
# dozen per month, bump the template to {yyyy}/{mm}/{dd} and rebuild.
|
|
11
|
+
|
|
12
|
+
mode: hosted
|
|
13
|
+
|
|
14
|
+
versioning:
|
|
15
|
+
style: in-place
|
|
16
|
+
backup_before_mutate: true
|
|
17
|
+
backup_dir: .llmwiki.backups
|
|
18
|
+
|
|
19
|
+
purpose: "Regression notes (bug-triage writeups, post-mortems, failing-build captures), filed by month."
|
|
20
|
+
|
|
21
|
+
global_invariants:
|
|
22
|
+
- "no flat date-prefixed leaves at the topic root; regressions live under {yyyy}/{mm}/"
|
|
23
|
+
- "one leaf per distinct regression; update the leaf in place when the investigation continues"
|
|
24
|
+
|
|
25
|
+
layout:
|
|
26
|
+
- path: .
|
|
27
|
+
purpose: "regressions filed by month"
|
|
28
|
+
dynamic_subdirs:
|
|
29
|
+
template: "{yyyy}/{mm}"
|
|
30
|
+
purpose: "all regressions captured in a given month"
|
|
31
|
+
allow_entry_types: [primary]
|
|
32
|
+
content_rules:
|
|
33
|
+
- "slug encodes the symptom or ticket id, not the date"
|
|
34
|
+
- "include the commit or release that introduced the regression in frontmatter.source"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Layout contract for a dated reports topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind dated --template reports
|
|
7
|
+
#
|
|
8
|
+
# Reports accrete over time and must land under {yyyy}/{mm}/{dd}/ to keep
|
|
9
|
+
# the topic root scannable. Flat date-prefixed siblings are refused.
|
|
10
|
+
|
|
11
|
+
mode: hosted
|
|
12
|
+
|
|
13
|
+
versioning:
|
|
14
|
+
style: in-place
|
|
15
|
+
backup_before_mutate: true
|
|
16
|
+
backup_dir: .llmwiki.backups
|
|
17
|
+
|
|
18
|
+
purpose: "Generated reports (code review, investigation, regression triage), filed by date."
|
|
19
|
+
|
|
20
|
+
global_invariants:
|
|
21
|
+
- "no flat date-prefixed leaves at the topic root; reports live under {yyyy}/{mm}/{dd}/"
|
|
22
|
+
- "no user-visible versioned filenames (.v1.md, -v2.md, etc.); history lives in the private git"
|
|
23
|
+
|
|
24
|
+
layout:
|
|
25
|
+
- path: .
|
|
26
|
+
purpose: "reports filed by date"
|
|
27
|
+
dynamic_subdirs:
|
|
28
|
+
template: "{yyyy}/{mm}/{dd}"
|
|
29
|
+
purpose: "all reports written on a given day"
|
|
30
|
+
allow_entry_types: [primary]
|
|
31
|
+
content_rules:
|
|
32
|
+
- "one leaf per reviewed PR or standalone investigation"
|
|
33
|
+
- "slug encodes subject, not date (date is the path)"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Layout contract for a subject-categorised runbooks topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind subject --template runbooks
|
|
7
|
+
#
|
|
8
|
+
# Runbooks group by subject, not date. The hierarchy grows as you add
|
|
9
|
+
# subjects; pick a category subfolder on the first leaf, and create a new
|
|
10
|
+
# subfolder whenever two or more leaves share a defensible grouping.
|
|
11
|
+
|
|
12
|
+
mode: hosted
|
|
13
|
+
|
|
14
|
+
versioning:
|
|
15
|
+
style: in-place
|
|
16
|
+
backup_before_mutate: true
|
|
17
|
+
backup_dir: .llmwiki.backups
|
|
18
|
+
|
|
19
|
+
purpose: "Operational runbooks and playbooks, grouped by subject (e.g. deploy/, incident/, database/, onboarding/)."
|
|
20
|
+
|
|
21
|
+
global_invariants:
|
|
22
|
+
- "no flat list of runbook leaves at the topic root once more than three exist"
|
|
23
|
+
- "category subfolders are created on the first write; never 'later when there are enough files'"
|
|
24
|
+
- "no date prefixes or timestamp suffixes in filenames; history lives in the private git"
|
|
25
|
+
|
|
26
|
+
layout:
|
|
27
|
+
- path: .
|
|
28
|
+
purpose: "runbooks grouped by subject"
|
|
29
|
+
allow_entry_types: [primary, index]
|
|
30
|
+
content_rules:
|
|
31
|
+
- "root-level leaves are reserved for the 3-5 most general runbooks; everything else nests"
|
|
32
|
+
- "subject subfolders name the domain, not a ticket or date"
|
|
33
|
+
- "nested subcategories are preferred over long filenames"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Layout contract for a dated sessions topic.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to <topic>/.llmwiki.layout.yaml (or let `skill-llm-wiki init`
|
|
4
|
+
# seed it for you) before building in hosted mode:
|
|
5
|
+
#
|
|
6
|
+
# skill-llm-wiki init <topic> --kind dated --template sessions
|
|
7
|
+
#
|
|
8
|
+
# Sessions are per-day log-style entries (pair-programming sessions, daily
|
|
9
|
+
# working logs, standup notes). The day is the path; the slug is the
|
|
10
|
+
# topic or ticket, never the date.
|
|
11
|
+
|
|
12
|
+
mode: hosted
|
|
13
|
+
|
|
14
|
+
versioning:
|
|
15
|
+
style: in-place
|
|
16
|
+
backup_before_mutate: true
|
|
17
|
+
backup_dir: .llmwiki.backups
|
|
18
|
+
|
|
19
|
+
purpose: "Daily session logs (working sessions, pair-programming notes, standup records), filed by date."
|
|
20
|
+
|
|
21
|
+
global_invariants:
|
|
22
|
+
- "no flat date-prefixed leaves at the topic root; sessions live under {yyyy}/{mm}/{dd}/"
|
|
23
|
+
- "slug encodes the subject or ticket, not the date"
|
|
24
|
+
|
|
25
|
+
layout:
|
|
26
|
+
- path: .
|
|
27
|
+
purpose: "sessions filed by date"
|
|
28
|
+
dynamic_subdirs:
|
|
29
|
+
template: "{yyyy}/{mm}/{dd}"
|
|
30
|
+
purpose: "all sessions started on a given day"
|
|
31
|
+
allow_entry_types: [primary]
|
|
32
|
+
content_rules:
|
|
33
|
+
- "one leaf per discrete session; append to the leaf if the same session resumes same day"
|
|
34
|
+
- "link to related plans/reports via parents[] rather than inlining them"
|