@ctxr/skill-llm-wiki 1.0.1
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/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- package/scripts/lib/validate.mjs +362 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// Minimal, dependency-free YAML frontmatter parser/writer.
|
|
2
|
+
//
|
|
3
|
+
// Supports exactly the subset the methodology's index.md and leaf frontmatter
|
|
4
|
+
// use: scalars (string/number/bool/null), block mappings, block sequences of
|
|
5
|
+
// scalars, block sequences of maps, nested mappings. No flow maps, no anchors,
|
|
6
|
+
// no multi-document streams. Deterministic roundtrip: parse → render on an
|
|
7
|
+
// unchanged tree yields byte-identical output modulo key ordering (which is
|
|
8
|
+
// fixed by a canonical key order when rendering).
|
|
9
|
+
//
|
|
10
|
+
// We ship zero runtime dependencies because `kit install` does not populate
|
|
11
|
+
// `node_modules` inside an installed skill directory — scripts must run with
|
|
12
|
+
// just Node built-ins.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
const FM = "---";
|
|
17
|
+
|
|
18
|
+
export function readFrontmatter(filePath) {
|
|
19
|
+
const raw = readFileSync(filePath, "utf8");
|
|
20
|
+
return parseFrontmatter(raw, filePath);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFrontmatter(raw, filePath = "<buffer>") {
|
|
24
|
+
if (!raw.startsWith(FM + "\n") && raw !== FM + "\n") {
|
|
25
|
+
return { data: {}, body: raw };
|
|
26
|
+
}
|
|
27
|
+
const lines = raw.split("\n");
|
|
28
|
+
let closeIdx = -1;
|
|
29
|
+
for (let i = 1; i < lines.length; i++) {
|
|
30
|
+
if (lines[i] === FM) {
|
|
31
|
+
closeIdx = i;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (closeIdx === -1) {
|
|
36
|
+
throw new Error(`${filePath}: frontmatter opens with --- but never closes`);
|
|
37
|
+
}
|
|
38
|
+
const yamlLines = lines.slice(1, closeIdx);
|
|
39
|
+
const bodyLines = lines.slice(closeIdx + 1);
|
|
40
|
+
const data = parseYaml(yamlLines, filePath);
|
|
41
|
+
return { data, body: bodyLines.join("\n") };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeFrontmatter(filePath, data, body) {
|
|
45
|
+
const yaml = renderYaml(data, 0);
|
|
46
|
+
const trimmedBody = body.startsWith("\n") ? body : "\n" + body;
|
|
47
|
+
writeFileSync(filePath, `${FM}\n${yaml}${FM}${trimmedBody}`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderFrontmatter(data, body = "") {
|
|
51
|
+
const yaml = renderYaml(data, 0);
|
|
52
|
+
const trimmedBody = body.startsWith("\n") ? body : "\n" + body;
|
|
53
|
+
return `${FM}\n${yaml}${FM}${trimmedBody}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Parser ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
class Parser {
|
|
59
|
+
constructor(lines, filePath) {
|
|
60
|
+
this.lines = lines;
|
|
61
|
+
this.pos = 0;
|
|
62
|
+
this.filePath = filePath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
peek() {
|
|
66
|
+
while (this.pos < this.lines.length) {
|
|
67
|
+
const raw = this.lines[this.pos];
|
|
68
|
+
if (raw.trim() === "" || raw.trimStart().startsWith("#")) {
|
|
69
|
+
this.pos++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const indent = raw.length - raw.trimStart().length;
|
|
73
|
+
return { raw, indent, text: raw.slice(indent) };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
advance() {
|
|
79
|
+
this.pos++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
error(msg, line) {
|
|
83
|
+
throw new Error(`${this.filePath}:${this.pos + 1}: ${msg} — "${line ?? ""}"`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseYaml(lines, filePath) {
|
|
88
|
+
const p = new Parser(lines, filePath);
|
|
89
|
+
return parseMap(p, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Keys that can poison the parsed object's prototype. These are refused
|
|
93
|
+
// at parse time so adversarial frontmatter (e.g. from a shared wiki a
|
|
94
|
+
// user received from a third party) cannot plant properties on
|
|
95
|
+
// Object.prototype or swap the instance's [[Prototype]] via the
|
|
96
|
+
// `__proto__` setter. See `tests/unit/frontmatter-pollution.test.mjs`.
|
|
97
|
+
const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
98
|
+
|
|
99
|
+
function safeAssign(out, key, value, p, tok) {
|
|
100
|
+
if (POLLUTION_KEYS.has(key)) {
|
|
101
|
+
p.error(`forbidden YAML key "${key}"`, tok?.raw ?? key);
|
|
102
|
+
}
|
|
103
|
+
// Defence in depth: always write via defineProperty so the __proto__
|
|
104
|
+
// setter cannot fire even if the key check above is ever loosened.
|
|
105
|
+
Object.defineProperty(out, key, {
|
|
106
|
+
value,
|
|
107
|
+
writable: true,
|
|
108
|
+
enumerable: true,
|
|
109
|
+
configurable: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseMap(p, baseIndent) {
|
|
114
|
+
const out = {};
|
|
115
|
+
while (true) {
|
|
116
|
+
const tok = p.peek();
|
|
117
|
+
if (!tok) return out;
|
|
118
|
+
if (tok.indent < baseIndent) return out;
|
|
119
|
+
if (tok.indent > baseIndent) {
|
|
120
|
+
p.error("unexpected indent", tok.raw);
|
|
121
|
+
}
|
|
122
|
+
const { text } = tok;
|
|
123
|
+
const colon = findKeyColon(text);
|
|
124
|
+
if (colon === -1) p.error("expected key:", tok.raw);
|
|
125
|
+
const key = text.slice(0, colon).trim();
|
|
126
|
+
const rest = text.slice(colon + 1).trim();
|
|
127
|
+
p.advance();
|
|
128
|
+
|
|
129
|
+
if (rest === "|" || rest === ">") {
|
|
130
|
+
safeAssign(out, key, parseBlockScalar(p, baseIndent, rest === "|"), p, tok);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (rest !== "") {
|
|
134
|
+
safeAssign(out, key, parseScalarInline(rest), p, tok);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Nested structure — peek indent to decide.
|
|
139
|
+
const next = p.peek();
|
|
140
|
+
if (!next || next.indent <= baseIndent) {
|
|
141
|
+
safeAssign(out, key, null, p, tok);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (next.text.startsWith("- ") || next.text === "-") {
|
|
145
|
+
safeAssign(out, key, parseSeq(p, next.indent), p, tok);
|
|
146
|
+
} else {
|
|
147
|
+
safeAssign(out, key, parseMap(p, next.indent), p, tok);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseSeq(p, baseIndent) {
|
|
153
|
+
const out = [];
|
|
154
|
+
while (true) {
|
|
155
|
+
const tok = p.peek();
|
|
156
|
+
if (!tok) return out;
|
|
157
|
+
if (tok.indent < baseIndent) return out;
|
|
158
|
+
if (tok.indent > baseIndent) p.error("unexpected indent in sequence", tok.raw);
|
|
159
|
+
if (!tok.text.startsWith("-")) return out;
|
|
160
|
+
|
|
161
|
+
// Item starts at baseIndent with `- `. The rest of the line is either
|
|
162
|
+
// empty, a scalar, or a first key of an inline map.
|
|
163
|
+
const afterDash = tok.text === "-" ? "" : tok.text.slice(2);
|
|
164
|
+
p.advance();
|
|
165
|
+
|
|
166
|
+
if (afterDash === "") {
|
|
167
|
+
// Nested content under a bare `-`
|
|
168
|
+
const next = p.peek();
|
|
169
|
+
if (next && next.indent > baseIndent) {
|
|
170
|
+
if (next.text.startsWith("- ")) {
|
|
171
|
+
out.push(parseSeq(p, next.indent));
|
|
172
|
+
} else {
|
|
173
|
+
out.push(parseMap(p, next.indent));
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
out.push(null);
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const colon = findKeyColon(afterDash);
|
|
182
|
+
if (colon === -1) {
|
|
183
|
+
out.push(parseScalarInline(afterDash));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// `- key: value` possibly followed by more keys at indent baseIndent+2
|
|
188
|
+
const firstKey = afterDash.slice(0, colon).trim();
|
|
189
|
+
const firstRest = afterDash.slice(colon + 1).trim();
|
|
190
|
+
const item = {};
|
|
191
|
+
|
|
192
|
+
if (firstRest === "|" || firstRest === ">") {
|
|
193
|
+
item[firstKey] = parseBlockScalar(p, baseIndent + 2, firstRest === "|");
|
|
194
|
+
} else if (firstRest !== "") {
|
|
195
|
+
item[firstKey] = parseScalarInline(firstRest);
|
|
196
|
+
} else {
|
|
197
|
+
// Nested structure under first key
|
|
198
|
+
const nested = p.peek();
|
|
199
|
+
if (nested && nested.indent > baseIndent + 2) {
|
|
200
|
+
if (nested.text.startsWith("- ")) {
|
|
201
|
+
item[firstKey] = parseSeq(p, nested.indent);
|
|
202
|
+
} else {
|
|
203
|
+
item[firstKey] = parseMap(p, nested.indent);
|
|
204
|
+
}
|
|
205
|
+
} else if (nested && nested.indent === baseIndent + 2) {
|
|
206
|
+
// First key had nested sub-map at +2 (legal when firstRest is empty)
|
|
207
|
+
if (nested.text.startsWith("- ")) {
|
|
208
|
+
item[firstKey] = parseSeq(p, nested.indent);
|
|
209
|
+
} else {
|
|
210
|
+
item[firstKey] = parseMap(p, nested.indent);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
item[firstKey] = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Additional keys at baseIndent+2
|
|
218
|
+
while (true) {
|
|
219
|
+
const cont = p.peek();
|
|
220
|
+
if (!cont) break;
|
|
221
|
+
if (cont.indent < baseIndent + 2) break;
|
|
222
|
+
if (cont.indent > baseIndent + 2) break;
|
|
223
|
+
if (cont.text.startsWith("- ")) break;
|
|
224
|
+
const subColon = findKeyColon(cont.text);
|
|
225
|
+
if (subColon === -1) break;
|
|
226
|
+
const subKey = cont.text.slice(0, subColon).trim();
|
|
227
|
+
const subRest = cont.text.slice(subColon + 1).trim();
|
|
228
|
+
p.advance();
|
|
229
|
+
if (subRest === "") {
|
|
230
|
+
const nested2 = p.peek();
|
|
231
|
+
if (nested2 && nested2.indent > baseIndent + 2) {
|
|
232
|
+
if (nested2.text.startsWith("- ")) {
|
|
233
|
+
item[subKey] = parseSeq(p, nested2.indent);
|
|
234
|
+
} else {
|
|
235
|
+
item[subKey] = parseMap(p, nested2.indent);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
item[subKey] = null;
|
|
239
|
+
}
|
|
240
|
+
} else if (subRest === "|" || subRest === ">") {
|
|
241
|
+
item[subKey] = parseBlockScalar(p, baseIndent + 2, subRest === "|");
|
|
242
|
+
} else {
|
|
243
|
+
item[subKey] = parseScalarInline(subRest);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
out.push(item);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseBlockScalar(p, baseIndent, literal) {
|
|
252
|
+
const collected = [];
|
|
253
|
+
while (p.pos < p.lines.length) {
|
|
254
|
+
const raw = p.lines[p.pos];
|
|
255
|
+
if (raw.trim() === "") {
|
|
256
|
+
collected.push("");
|
|
257
|
+
p.pos++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const indent = raw.length - raw.trimStart().length;
|
|
261
|
+
if (indent <= baseIndent) break;
|
|
262
|
+
collected.push(raw.slice(baseIndent + 2));
|
|
263
|
+
p.pos++;
|
|
264
|
+
}
|
|
265
|
+
// Trim trailing empty lines
|
|
266
|
+
while (collected.length > 0 && collected[collected.length - 1] === "") {
|
|
267
|
+
collected.pop();
|
|
268
|
+
}
|
|
269
|
+
return literal ? collected.join("\n") : collected.join(" ").trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseScalarInline(raw) {
|
|
273
|
+
const s = raw.trim();
|
|
274
|
+
if (s === "") return null;
|
|
275
|
+
if (s === "null" || s === "~") return null;
|
|
276
|
+
if (s === "true") return true;
|
|
277
|
+
if (s === "false") return false;
|
|
278
|
+
if (/^-?\d+$/.test(s)) return Number(s);
|
|
279
|
+
if (/^-?\d+\.\d+$/.test(s)) return Number(s);
|
|
280
|
+
if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) {
|
|
281
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\").replace(/\\n/g, "\n");
|
|
282
|
+
}
|
|
283
|
+
if (s.startsWith("'") && s.endsWith("'") && s.length >= 2) {
|
|
284
|
+
return s.slice(1, -1).replace(/''/g, "'");
|
|
285
|
+
}
|
|
286
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
287
|
+
const inner = s.slice(1, -1).trim();
|
|
288
|
+
if (inner === "") return [];
|
|
289
|
+
return splitFlow(inner).map((x) => parseScalarInline(x));
|
|
290
|
+
}
|
|
291
|
+
return s;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function splitFlow(inner) {
|
|
295
|
+
// Split on commas that are not inside quotes.
|
|
296
|
+
const out = [];
|
|
297
|
+
let depth = 0;
|
|
298
|
+
let cur = "";
|
|
299
|
+
let inSingle = false;
|
|
300
|
+
let inDouble = false;
|
|
301
|
+
for (const c of inner) {
|
|
302
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
303
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
304
|
+
else if ((c === "[" || c === "{") && !inSingle && !inDouble) depth++;
|
|
305
|
+
else if ((c === "]" || c === "}") && !inSingle && !inDouble) depth--;
|
|
306
|
+
if (c === "," && !inSingle && !inDouble && depth === 0) {
|
|
307
|
+
out.push(cur.trim());
|
|
308
|
+
cur = "";
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
cur += c;
|
|
312
|
+
}
|
|
313
|
+
if (cur.trim() !== "") out.push(cur.trim());
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function findKeyColon(text) {
|
|
318
|
+
let inSingle = false;
|
|
319
|
+
let inDouble = false;
|
|
320
|
+
for (let i = 0; i < text.length; i++) {
|
|
321
|
+
const c = text[i];
|
|
322
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
323
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
324
|
+
else if (c === ":" && !inSingle && !inDouble) {
|
|
325
|
+
if (i === text.length - 1) return i;
|
|
326
|
+
if (text[i + 1] === " " || text[i + 1] === "\t") return i;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return -1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Renderer ────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
// Canonical top-level key order. Hand-tuned to match the authored shape
|
|
335
|
+
// in `guide/` — field order is a stable convention so that rebuilt
|
|
336
|
+
// frontmatter diffs cleanly against authored frontmatter. Keys not in
|
|
337
|
+
// this list retain insertion order after all known keys have been
|
|
338
|
+
// emitted, so unknown fields land at the bottom rather than scrambling
|
|
339
|
+
// the known ones.
|
|
340
|
+
const CANONICAL_KEY_ORDER = [
|
|
341
|
+
"id",
|
|
342
|
+
"type",
|
|
343
|
+
"depth_role",
|
|
344
|
+
"depth",
|
|
345
|
+
"focus",
|
|
346
|
+
"parents",
|
|
347
|
+
"shared_covers",
|
|
348
|
+
"covers",
|
|
349
|
+
"tags",
|
|
350
|
+
"domains",
|
|
351
|
+
"aliases",
|
|
352
|
+
"overlay_targets",
|
|
353
|
+
"nests_into",
|
|
354
|
+
"activation",
|
|
355
|
+
"activation_defaults",
|
|
356
|
+
"links",
|
|
357
|
+
"source",
|
|
358
|
+
"orientation",
|
|
359
|
+
"generator",
|
|
360
|
+
"mode",
|
|
361
|
+
"layout_contract_path",
|
|
362
|
+
"rebuild_needed",
|
|
363
|
+
"rebuild_reasons",
|
|
364
|
+
"rebuild_command",
|
|
365
|
+
"entries",
|
|
366
|
+
"children",
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
function orderedKeys(data, canonical) {
|
|
370
|
+
const known = new Set(canonical);
|
|
371
|
+
const present = new Set(Object.keys(data));
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const key of canonical) {
|
|
374
|
+
if (present.has(key)) out.push(key);
|
|
375
|
+
}
|
|
376
|
+
for (const key of Object.keys(data)) {
|
|
377
|
+
if (!known.has(key)) out.push(key);
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function renderYaml(data, indent) {
|
|
383
|
+
if (data == null || typeof data !== "object") return "";
|
|
384
|
+
let out = "";
|
|
385
|
+
// Only the top level (indent 0) is reordered canonically. Nested
|
|
386
|
+
// objects (e.g. activation.keyword_matches) keep insertion order so
|
|
387
|
+
// authored ordering inside complex values is never rewritten.
|
|
388
|
+
const keys =
|
|
389
|
+
indent === 0 ? orderedKeys(data, CANONICAL_KEY_ORDER) : Object.keys(data);
|
|
390
|
+
for (const key of keys) {
|
|
391
|
+
out += renderKey(key, data[key], indent);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderKey(key, val, indent) {
|
|
397
|
+
const pad = " ".repeat(indent);
|
|
398
|
+
if (val === null || val === undefined) return `${pad}${key}: null\n`;
|
|
399
|
+
if (typeof val === "boolean" || typeof val === "number") {
|
|
400
|
+
return `${pad}${key}: ${val}\n`;
|
|
401
|
+
}
|
|
402
|
+
if (typeof val === "string") {
|
|
403
|
+
if (val.includes("\n")) {
|
|
404
|
+
const childPad = " ".repeat(indent + 1);
|
|
405
|
+
const lines = val.split("\n");
|
|
406
|
+
return `${pad}${key}: |\n${lines.map((l) => (l === "" ? "" : childPad + l)).join("\n")}\n`;
|
|
407
|
+
}
|
|
408
|
+
return `${pad}${key}: ${renderScalar(val)}\n`;
|
|
409
|
+
}
|
|
410
|
+
if (Array.isArray(val)) {
|
|
411
|
+
if (val.length === 0) return `${pad}${key}: []\n`;
|
|
412
|
+
let out = `${pad}${key}:\n`;
|
|
413
|
+
for (const item of val) {
|
|
414
|
+
out += renderSeqItem(item, indent + 1);
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
// Plain object
|
|
419
|
+
const keys = Object.keys(val);
|
|
420
|
+
if (keys.length === 0) return `${pad}${key}: {}\n`;
|
|
421
|
+
let out = `${pad}${key}:\n`;
|
|
422
|
+
for (const k of keys) {
|
|
423
|
+
out += renderKey(k, val[k], indent + 1);
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderSeqItem(item, indent) {
|
|
429
|
+
const pad = " ".repeat(indent);
|
|
430
|
+
if (item === null || item === undefined) return `${pad}- null\n`;
|
|
431
|
+
if (typeof item === "boolean" || typeof item === "number") {
|
|
432
|
+
return `${pad}- ${item}\n`;
|
|
433
|
+
}
|
|
434
|
+
if (typeof item === "string") {
|
|
435
|
+
if (item.includes("\n")) {
|
|
436
|
+
// Rare in our frontmatter; fall back to scalar-with-escapes
|
|
437
|
+
return `${pad}- ${renderScalar(item.replace(/\n/g, "\\n"))}\n`;
|
|
438
|
+
}
|
|
439
|
+
return `${pad}- ${renderScalar(item)}\n`;
|
|
440
|
+
}
|
|
441
|
+
if (Array.isArray(item)) {
|
|
442
|
+
// Nested sequence inside a sequence — rare; recursive emission
|
|
443
|
+
let out = `${pad}-\n`;
|
|
444
|
+
for (const inner of item) out += renderSeqItem(inner, indent + 1);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
// Map item
|
|
448
|
+
const keys = Object.keys(item);
|
|
449
|
+
if (keys.length === 0) return `${pad}- {}\n`;
|
|
450
|
+
const firstKey = keys[0];
|
|
451
|
+
const firstVal = item[firstKey];
|
|
452
|
+
let out = "";
|
|
453
|
+
// Emit first key inline with the dash
|
|
454
|
+
if (
|
|
455
|
+
firstVal === null ||
|
|
456
|
+
typeof firstVal === "boolean" ||
|
|
457
|
+
typeof firstVal === "number" ||
|
|
458
|
+
(typeof firstVal === "string" && !firstVal.includes("\n"))
|
|
459
|
+
) {
|
|
460
|
+
out += `${pad}- ${firstKey}: ${firstVal === null ? "null" : typeof firstVal === "string" ? renderScalar(firstVal) : firstVal}\n`;
|
|
461
|
+
} else {
|
|
462
|
+
// Non-scalar first value: emit on separate line
|
|
463
|
+
out += `${pad}-\n`;
|
|
464
|
+
out += renderKey(firstKey, firstVal, indent + 1);
|
|
465
|
+
for (let i = 1; i < keys.length; i++) {
|
|
466
|
+
out += renderKey(keys[i], item[keys[i]], indent + 1);
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
// Subsequent keys at indent+1
|
|
471
|
+
for (let i = 1; i < keys.length; i++) {
|
|
472
|
+
out += renderKey(keys[i], item[keys[i]], indent + 1);
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function renderScalar(v) {
|
|
478
|
+
if (typeof v !== "string") return String(v);
|
|
479
|
+
if (v === "") return '""';
|
|
480
|
+
if (v === "null" || v === "true" || v === "false" || v === "~") return `"${v}"`;
|
|
481
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return `"${v}"`;
|
|
482
|
+
// YAML-unsafe chars — must be quoted for correctness.
|
|
483
|
+
if (/[:#\[\]{}&*!|>'"`%@\t]/.test(v)) {
|
|
484
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
485
|
+
}
|
|
486
|
+
// Defensive quoting: strings containing parens or slashes are quoted
|
|
487
|
+
// even when YAML does not strictly require it. Matches the hand-
|
|
488
|
+
// authored style in `guide/` and keeps rebuilt frontmatter textually
|
|
489
|
+
// close to authored frontmatter so diffs stay clean across rebuilds.
|
|
490
|
+
if (/[\/()]/.test(v)) {
|
|
491
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
492
|
+
}
|
|
493
|
+
if (/^\s|\s$/.test(v) || /^[-?]/.test(v)) {
|
|
494
|
+
return `"${v}"`;
|
|
495
|
+
}
|
|
496
|
+
return v;
|
|
497
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// git-commands.mjs — thin passthrough wrappers for the hidden-git
|
|
2
|
+
// subcommands exposed to Claude at runtime: log, show, diff, blame,
|
|
3
|
+
// reflog, history. These all run against the wiki's private repo via
|
|
4
|
+
// the isolation env in git.mjs; no user-git is ever consulted.
|
|
5
|
+
//
|
|
6
|
+
// The `diff` wrapper has extra sugar: a `--op <id>` flag expands to the
|
|
7
|
+
// commit range `pre-op/<id>..op/<id>`, which is the most common user
|
|
8
|
+
// question ("what did this operation do?"). Everything else is a plain
|
|
9
|
+
// passthrough so users can use their existing knowledge of git flags.
|
|
10
|
+
//
|
|
11
|
+
// `history <entry-id>` is a higher-level wrapper that walks the op-log
|
|
12
|
+
// plus `git log --follow` so Claude can answer "when and why did this
|
|
13
|
+
// entry change?" without stitching the two sources together itself.
|
|
14
|
+
|
|
15
|
+
import { gitRefExists, gitRun, redactUrl } from "./git.mjs";
|
|
16
|
+
import { readOpLog } from "./history.mjs";
|
|
17
|
+
|
|
18
|
+
// Shared: call git with the passed args and stream stdout to the caller.
|
|
19
|
+
// Exit code is the git exit code. We return it rather than exiting so
|
|
20
|
+
// the CLI can wrap errors in its usual way. stderr is routed through
|
|
21
|
+
// `redactUrl` on the off chance a remote URL surfaces in an error
|
|
22
|
+
// stream (D5 defence-in-depth from the Phase 8 security sweep).
|
|
23
|
+
function runPassthrough(wikiRoot, args) {
|
|
24
|
+
const r = gitRun(wikiRoot, args);
|
|
25
|
+
if (r.stdout) process.stdout.write(redactUrl(r.stdout));
|
|
26
|
+
if (r.stderr) process.stderr.write(redactUrl(r.stderr));
|
|
27
|
+
return r.status ?? 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// diff <wiki> [--op <id>] [extra git-diff args...]
|
|
31
|
+
// Defaults add --find-renames --find-copies so rename detection is on.
|
|
32
|
+
export function cmdDiff(wikiRoot, { op, args }) {
|
|
33
|
+
const gitArgs = ["diff", "--find-renames", "--find-copies"];
|
|
34
|
+
if (op) {
|
|
35
|
+
const preTag = `pre-op/${op}`;
|
|
36
|
+
const finalTag = `op/${op}`;
|
|
37
|
+
if (!gitRefExists(wikiRoot, preTag)) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`diff: tag ${preTag} not found in ${wikiRoot}/.llmwiki/git\n`,
|
|
40
|
+
);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
// If the final tag exists, show pre..final. Otherwise show what has
|
|
44
|
+
// landed since pre (typical during an in-flight operation).
|
|
45
|
+
if (gitRefExists(wikiRoot, finalTag)) {
|
|
46
|
+
gitArgs.push(`${preTag}..${finalTag}`);
|
|
47
|
+
} else {
|
|
48
|
+
gitArgs.push(`${preTag}..HEAD`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (args && args.length > 0) gitArgs.push(...args);
|
|
52
|
+
return runPassthrough(wikiRoot, gitArgs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// log <wiki> [--op <id>] [extra args...]
|
|
56
|
+
// Default: one-line oneline view of the op history. `--op <id>` narrows
|
|
57
|
+
// the output to the commits between `pre-op/<id>` and `op/<id>` (or
|
|
58
|
+
// `pre-op/<id>..HEAD` for an in-flight operation), matching the sugar
|
|
59
|
+
// `cmdDiff` already provides. Callers can pass any git-log arg to
|
|
60
|
+
// override the default format.
|
|
61
|
+
export function cmdLog(wikiRoot, { op, args }) {
|
|
62
|
+
const gitArgs = ["log"];
|
|
63
|
+
let rangeAppended = false;
|
|
64
|
+
if (op) {
|
|
65
|
+
const preTag = `pre-op/${op}`;
|
|
66
|
+
const finalTag = `op/${op}`;
|
|
67
|
+
if (!gitRefExists(wikiRoot, preTag)) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`log: tag ${preTag} not found in ${wikiRoot}/.llmwiki/git\n`,
|
|
70
|
+
);
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const range = gitRefExists(wikiRoot, finalTag)
|
|
74
|
+
? `${preTag}..${finalTag}`
|
|
75
|
+
: `${preTag}..HEAD`;
|
|
76
|
+
gitArgs.push("--oneline", "--decorate", range);
|
|
77
|
+
rangeAppended = true;
|
|
78
|
+
} else if (!args || args.length === 0) {
|
|
79
|
+
gitArgs.push("--oneline", "--decorate", "--all");
|
|
80
|
+
}
|
|
81
|
+
if (args && args.length > 0) {
|
|
82
|
+
// When --op was passed, still accept extra git-log args after the
|
|
83
|
+
// range; when no --op, the args replace the default shape entirely.
|
|
84
|
+
if (rangeAppended) {
|
|
85
|
+
gitArgs.push(...args);
|
|
86
|
+
} else {
|
|
87
|
+
gitArgs.length = 1;
|
|
88
|
+
gitArgs.push(...args);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return runPassthrough(wikiRoot, gitArgs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// show <wiki> <ref> [-- <path>]
|
|
95
|
+
export function cmdShow(wikiRoot, { ref, args }) {
|
|
96
|
+
if (!ref) {
|
|
97
|
+
process.stderr.write("show: <ref> is required\n");
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
return runPassthrough(wikiRoot, ["show", ref, ...(args || [])]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// blame <wiki> <path>
|
|
104
|
+
export function cmdBlame(wikiRoot, { path, args }) {
|
|
105
|
+
if (!path) {
|
|
106
|
+
process.stderr.write("blame: <path> is required\n");
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
return runPassthrough(wikiRoot, ["blame", ...(args || []), path]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// reflog <wiki>
|
|
113
|
+
export function cmdReflog(wikiRoot, { args }) {
|
|
114
|
+
return runPassthrough(wikiRoot, ["reflog", ...(args || [])]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// history <wiki> <entry-id>
|
|
118
|
+
// Higher-level: walk the op-log first (so Claude sees the op-level
|
|
119
|
+
// lineage), then run `git log --follow` on the entry's current path if
|
|
120
|
+
// we can resolve it, catching renames across operations.
|
|
121
|
+
export function cmdHistory(wikiRoot, { entryId }) {
|
|
122
|
+
if (!entryId) {
|
|
123
|
+
process.stderr.write("history: <entry-id> is required\n");
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const opLog = readOpLog(wikiRoot);
|
|
127
|
+
process.stdout.write(`# Op-log entries mentioning ${entryId}\n\n`);
|
|
128
|
+
let opHits = 0;
|
|
129
|
+
for (const entry of opLog) {
|
|
130
|
+
const summary = (entry.summary || "").includes(entryId);
|
|
131
|
+
if (summary || entry.op_id.includes(entryId)) {
|
|
132
|
+
opHits++;
|
|
133
|
+
process.stdout.write(
|
|
134
|
+
`${entry.op_id} ${entry.operation} ${entry.finished}\n` +
|
|
135
|
+
` ${entry.summary}\n\n`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (opHits === 0) {
|
|
140
|
+
process.stdout.write(" (no op-log entries match)\n\n");
|
|
141
|
+
}
|
|
142
|
+
process.stdout.write(`# Git history for files matching **/${entryId}.md\n\n`);
|
|
143
|
+
// `git log --follow` needs a path. We don't know the exact path — try
|
|
144
|
+
// the wildcard pattern and fall back to a ref-log search if empty.
|
|
145
|
+
const r = gitRun(wikiRoot, [
|
|
146
|
+
"log",
|
|
147
|
+
"--oneline",
|
|
148
|
+
"--follow",
|
|
149
|
+
"--",
|
|
150
|
+
`*${entryId}.md`,
|
|
151
|
+
]);
|
|
152
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
153
|
+
else process.stdout.write(" (no tracked file matches)\n");
|
|
154
|
+
return 0;
|
|
155
|
+
}
|