@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/SKILL.md +252 -0
  5. package/guide/basics/concepts.md +74 -0
  6. package/guide/basics/index.md +45 -0
  7. package/guide/basics/schema.md +140 -0
  8. package/guide/cli.md +256 -0
  9. package/guide/correctness/index.md +45 -0
  10. package/guide/correctness/invariants.md +89 -0
  11. package/guide/correctness/safety.md +96 -0
  12. package/guide/history/diff.md +110 -0
  13. package/guide/history/hidden-git.md +130 -0
  14. package/guide/history/index.md +52 -0
  15. package/guide/history/remote-sync.md +113 -0
  16. package/guide/index.md +134 -0
  17. package/guide/isolation/coexistence.md +134 -0
  18. package/guide/isolation/index.md +44 -0
  19. package/guide/isolation/scale.md +251 -0
  20. package/guide/layout/in-place-mode.md +97 -0
  21. package/guide/layout/index.md +53 -0
  22. package/guide/layout/layout-contract.md +131 -0
  23. package/guide/layout/layout-modes.md +115 -0
  24. package/guide/operations/index.md +76 -0
  25. package/guide/operations/ingest/build.md +75 -0
  26. package/guide/operations/ingest/extend.md +61 -0
  27. package/guide/operations/ingest/index.md +54 -0
  28. package/guide/operations/ingest/join.md +65 -0
  29. package/guide/operations/maintain/fix.md +66 -0
  30. package/guide/operations/maintain/index.md +47 -0
  31. package/guide/operations/maintain/rebuild.md +86 -0
  32. package/guide/operations/validate.md +48 -0
  33. package/guide/substrate/index.md +47 -0
  34. package/guide/substrate/operators.md +96 -0
  35. package/guide/substrate/tiered-ai.md +363 -0
  36. package/guide/ux/index.md +44 -0
  37. package/guide/ux/preflight.md +150 -0
  38. package/guide/ux/user-intent.md +135 -0
  39. package/package.json +55 -0
  40. package/scripts/cli.mjs +893 -0
  41. package/scripts/commands/remote.mjs +93 -0
  42. package/scripts/commands/review.mjs +253 -0
  43. package/scripts/commands/sync.mjs +84 -0
  44. package/scripts/lib/chunk.mjs +421 -0
  45. package/scripts/lib/cluster-detect.mjs +516 -0
  46. package/scripts/lib/decision-log.mjs +343 -0
  47. package/scripts/lib/draft.mjs +158 -0
  48. package/scripts/lib/embeddings.mjs +366 -0
  49. package/scripts/lib/frontmatter.mjs +497 -0
  50. package/scripts/lib/git-commands.mjs +155 -0
  51. package/scripts/lib/git.mjs +486 -0
  52. package/scripts/lib/gitignore.mjs +62 -0
  53. package/scripts/lib/history.mjs +331 -0
  54. package/scripts/lib/indices.mjs +510 -0
  55. package/scripts/lib/ingest.mjs +258 -0
  56. package/scripts/lib/intent.mjs +713 -0
  57. package/scripts/lib/interactive.mjs +99 -0
  58. package/scripts/lib/migrate.mjs +126 -0
  59. package/scripts/lib/nest-applier.mjs +260 -0
  60. package/scripts/lib/operators.mjs +1365 -0
  61. package/scripts/lib/orchestrator.mjs +718 -0
  62. package/scripts/lib/paths.mjs +197 -0
  63. package/scripts/lib/preflight.mjs +213 -0
  64. package/scripts/lib/provenance.mjs +672 -0
  65. package/scripts/lib/quality-metric.mjs +269 -0
  66. package/scripts/lib/query-fixture.mjs +71 -0
  67. package/scripts/lib/rollback.mjs +95 -0
  68. package/scripts/lib/shape-check.mjs +172 -0
  69. package/scripts/lib/similarity-cache.mjs +126 -0
  70. package/scripts/lib/similarity.mjs +230 -0
  71. package/scripts/lib/snapshot.mjs +54 -0
  72. package/scripts/lib/source-frontmatter.mjs +85 -0
  73. package/scripts/lib/tier2-protocol.mjs +470 -0
  74. package/scripts/lib/tiered.mjs +453 -0
  75. package/scripts/lib/validate.mjs +362 -0
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env node
2
+ // skill-llm-wiki CLI — deterministic script helpers Claude invokes
3
+ // while driving the methodology's operations.
4
+ //
5
+ // This is NOT the full operation pipeline. The LLM (Claude, invoking this
6
+ // skill) is the orchestrator: it reads SKILL.md, runs ingest + index-rebuild
7
+ // + validate + shape-check via this CLI, drafts frontmatter itself where
8
+ // heuristics are insufficient, and writes results using the standard Edit
9
+ // and Write tools. This CLI exists so the deterministic phases are fast,
10
+ // cheap, and identical across runs.
11
+ //
12
+ // Subcommands:
13
+ // ingest <source> — walk a source, emit candidate JSON
14
+ // draft-leaf <candidate-json> — deterministic leaf frontmatter draft
15
+ // index-rebuild <wiki> — regenerate all index.md files
16
+ // index-rebuild-one <dir> <wiki> — regenerate a single directory's index
17
+ // validate <wiki> — run hard invariants, print report
18
+ // shape-check <wiki> — detect operator candidates
19
+ // resolve-wiki <source> — print current live wiki path
20
+ // next-version <source> — print next version tag
21
+ // --version — print version
22
+ // --help — print usage
23
+
24
+ // ───────────────────────────────────────────────────────────────────────────
25
+ // Runtime preflight guard (defense-in-depth).
26
+ //
27
+ // The primary preflight is a Bash check Claude runs BEFORE invoking this CLI
28
+ // (see SKILL.md "Preflight: verify Node.js is installed"). This guard is the
29
+ // second layer.
30
+ //
31
+ // The inline Node-major check here runs BEFORE any `import` statement so
32
+ // that even on an ancient Node that rejects our modern syntax, we abort
33
+ // cleanly with a short stderr message instead of a cryptic parse error.
34
+ // The richer preflight (full semver, git version, wiki fsck) runs inside
35
+ // main() after imports have resolved — it cannot be earlier without
36
+ // creating a circular dependency between cli.mjs and preflight.mjs.
37
+ //
38
+ // Exit codes used by this CLI are:
39
+ // 0 ok · 1 usage · 2 validation · 3 resolve-wiki miss ·
40
+ // 4 Node too old · 5 git missing/too old · 6 wiki corrupt ·
41
+ // 7 NEEDS_TIER2 (suspend — wiki-runner must resolve pending
42
+ // tier2 requests and re-invoke; NOT a failure path) ·
43
+ // 8 DEPS_MISSING (required runtime dependency missing and the
44
+ // auto-install attempt was either declined or failed)
45
+ // ───────────────────────────────────────────────────────────────────────────
46
+ const REQUIRED_NODE_MAJOR = 18;
47
+ const _nodeVersionRaw = (process && process.version) || "";
48
+ const _nodeMajorMatch = /^v(\d+)\./.exec(_nodeVersionRaw);
49
+ const _nodeMajor = _nodeMajorMatch ? Number(_nodeMajorMatch[1]) : NaN;
50
+ if (!Number.isFinite(_nodeMajor) || _nodeMajor < REQUIRED_NODE_MAJOR) {
51
+ process.stderr.write(
52
+ "skill-llm-wiki: Node.js " + (_nodeVersionRaw || "<unknown>") +
53
+ " is below the required minimum (v" + REQUIRED_NODE_MAJOR + ".0.0).\n" +
54
+ "Please upgrade Node.js and retry. See SKILL.md " +
55
+ "'Preflight: verify Node.js is installed' for platform-specific " +
56
+ "install instructions.\n",
57
+ );
58
+ process.exit(4);
59
+ }
60
+
61
+ // ───────────────────────────────────────────────────────────────────────────
62
+ // Dependency preflight (defence-in-depth, runs BEFORE the static imports
63
+ // that would otherwise pull in `gray-matter`).
64
+ //
65
+ // The static import chain below transitively loads `gray-matter` via
66
+ // scripts/lib/source-frontmatter.mjs. If that package is missing from
67
+ // node_modules, the import throws ERR_MODULE_NOT_FOUND with no
68
+ // actionable context. By doing a synchronous resolve + prompt/install
69
+ // loop here — using only Node built-ins — we either fix the install or
70
+ // exit 8 cleanly before the failing import is reached.
71
+ //
72
+ // `--version` and `--help` deliberately bypass this check so an operator
73
+ // debugging a broken install can still sanity-check the binary. They are
74
+ // handled by an early-exit branch a few lines down.
75
+ // ───────────────────────────────────────────────────────────────────────────
76
+ import { createRequire as _createRequireDP } from "node:module";
77
+ import { spawnSync as _spawnSyncDP } from "node:child_process";
78
+ import { fileURLToPath as _fileURLToPathDP } from "node:url";
79
+ import { dirname as _dirnameDP, resolve, join as _joinDP } from "node:path";
80
+ import { readSync as _readSyncDP, readFileSync, mkdirSync } from "node:fs";
81
+
82
+ const _SKILL_ROOT_DP = _dirnameDP(_dirnameDP(_fileURLToPathDP(import.meta.url)));
83
+ const _REQUIRED_DEPS_DP = ["gray-matter", "@xenova/transformers"];
84
+
85
+ function _depPreflightCheck() {
86
+ // Test-only override: lets the e2e suite exercise the missing-dep
87
+ // path without renaming files inside the live node_modules tree
88
+ // (which would race with parallel test files sharing the same
89
+ // skill root). The value is a comma-separated list of dep names to
90
+ // pretend are missing.
91
+ const forced = process.env.LLM_WIKI_TEST_FORCE_DEPS_MISSING;
92
+ if (forced) {
93
+ return forced
94
+ .split(",")
95
+ .map((s) => s.trim())
96
+ .filter(Boolean);
97
+ }
98
+ let req;
99
+ try {
100
+ req = _createRequireDP(_joinDP(_SKILL_ROOT_DP, "package.json"));
101
+ } catch {
102
+ return _REQUIRED_DEPS_DP.slice();
103
+ }
104
+ const missing = [];
105
+ for (const d of _REQUIRED_DEPS_DP) {
106
+ try {
107
+ req.resolve(d);
108
+ } catch {
109
+ missing.push(d);
110
+ }
111
+ }
112
+ return missing;
113
+ }
114
+
115
+ function _depPreflightFailMessage(missing) {
116
+ return (
117
+ "skill-llm-wiki: required runtime dependencies are missing:\n" +
118
+ missing.map((d) => ` - ${d}`).join("\n") +
119
+ "\n" +
120
+ "Run `npm install` in the skill directory to install them, or see " +
121
+ "guide/ux/preflight.md Case E.\n"
122
+ );
123
+ }
124
+
125
+ // Skip the dep check entirely for --version and --help so an operator
126
+ // debugging a broken install can still get version/usage output. Every
127
+ // other invocation (including `--help` placed AFTER another arg, which
128
+ // is a malformed invocation we don't need to coddle) runs the check.
129
+ const _argvDP = process.argv.slice(2);
130
+ const _isVersionOrHelpDP =
131
+ _argvDP[0] === "--version" || _argvDP[0] === "--help" || _argvDP[0] === "-h";
132
+
133
+ if (!_isVersionOrHelpDP) {
134
+ let _missingDP = _depPreflightCheck();
135
+ if (_missingDP.length > 0) {
136
+ process.stderr.write(_depPreflightFailMessage(_missingDP));
137
+ const _interactiveDP =
138
+ Boolean(process.stdin && process.stdin.isTTY) &&
139
+ process.env.LLM_WIKI_NO_PROMPT !== "1";
140
+ let _proceedDP = true;
141
+ if (_interactiveDP) {
142
+ process.stderr.write("Install now? [Y/n] ");
143
+ let _ans = "";
144
+ try {
145
+ const buf = Buffer.alloc(64);
146
+ const n = _readSyncDP(process.stdin.fd, buf, 0, buf.length, null);
147
+ _ans = buf.subarray(0, n).toString("utf8").trim().toLowerCase();
148
+ } catch {
149
+ _ans = "";
150
+ }
151
+ if (_ans === "n" || _ans === "no") {
152
+ process.stderr.write("Cannot proceed without dependencies. Exit.\n");
153
+ process.exit(8);
154
+ }
155
+ _proceedDP = true;
156
+ }
157
+ if (_proceedDP) {
158
+ // Test-only knob: when LLM_WIKI_TEST_NO_AUTOINSTALL=1 is set,
159
+ // we skip the auto-install attempt entirely and exit 8
160
+ // immediately. This lets the e2e test exercise the failure
161
+ // path without ever risking a live npm install against the
162
+ // shared node_modules used by parallel test files.
163
+ if (process.env.LLM_WIKI_TEST_NO_AUTOINSTALL === "1") {
164
+ process.stderr.write(
165
+ "skill-llm-wiki: auto-install disabled by test harness. Exit.\n",
166
+ );
167
+ process.exit(8);
168
+ }
169
+ process.stderr.write(
170
+ `skill-llm-wiki: running \`npm install --silent\` in ${_SKILL_ROOT_DP}\n`,
171
+ );
172
+ const _ins = _spawnSyncDP("npm", ["install", "--silent"], {
173
+ cwd: _SKILL_ROOT_DP,
174
+ stdio: ["ignore", "inherit", "inherit"],
175
+ });
176
+ if (_ins.error || _ins.status !== 0) {
177
+ process.stderr.write(
178
+ "skill-llm-wiki: `npm install` failed. Cannot proceed without " +
179
+ "dependencies. Exit.\n",
180
+ );
181
+ process.exit(8);
182
+ }
183
+ _missingDP = _depPreflightCheck();
184
+ if (_missingDP.length > 0) {
185
+ process.stderr.write(_depPreflightFailMessage(_missingDP));
186
+ process.stderr.write(
187
+ "skill-llm-wiki: dependencies are still missing after `npm install`. " +
188
+ "Exit.\n",
189
+ );
190
+ process.exit(8);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // All skill-internal modules are loaded via dynamic `import()` inside
197
+ // `main()` so that the dependency preflight above this line gets a
198
+ // chance to run BEFORE any module that transitively imports
199
+ // `gray-matter` or `@xenova/transformers` is evaluated. ESM static
200
+ // imports are hoisted to the top of the file regardless of source
201
+ // position, so the only way to defer them past the preflight is to use
202
+ // dynamic import. The list of imported names is identical to the
203
+ // previous static block.
204
+ let ingestSource;
205
+ let draftLeafFrontmatter, draftCategory;
206
+ let rebuildAllIndices, rebuildIndex;
207
+ let validateWiki, summariseFindings;
208
+ let runShapeCheck;
209
+ let listVersions, nextVersionTag, resolveLiveWiki, writeCurrentPointer;
210
+ let formatAmbiguityJson, formatAmbiguityText, resolveIntent;
211
+ let rollbackOperation;
212
+ let defaultMigrationTarget, migrateLegacyWiki;
213
+ let NonInteractiveError;
214
+ let NeedsTier2Error, ReviewAbortedError, runOperation, ValidationError;
215
+ let TIER2_EXIT_CODE, listBatches;
216
+ let cmdBlame, cmdDiff, cmdHistory, cmdLog, cmdReflog, cmdShow;
217
+ let cmdRemote, cmdSync;
218
+
219
+ async function loadSkillModules() {
220
+ ({ ingestSource } = await import("./lib/ingest.mjs"));
221
+ ({ draftLeafFrontmatter, draftCategory } = await import("./lib/draft.mjs"));
222
+ ({ rebuildAllIndices, rebuildIndex } = await import("./lib/indices.mjs"));
223
+ ({ validateWiki, summariseFindings } = await import("./lib/validate.mjs"));
224
+ ({ runShapeCheck } = await import("./lib/shape-check.mjs"));
225
+ ({ listVersions, nextVersionTag, resolveLiveWiki, writeCurrentPointer } =
226
+ await import("./lib/paths.mjs"));
227
+ ({ formatAmbiguityJson, formatAmbiguityText, resolveIntent } = await import(
228
+ "./lib/intent.mjs"
229
+ ));
230
+ ({ rollbackOperation } = await import("./lib/rollback.mjs"));
231
+ ({ defaultMigrationTarget, migrateLegacyWiki } = await import(
232
+ "./lib/migrate.mjs"
233
+ ));
234
+ ({ NonInteractiveError } = await import("./lib/interactive.mjs"));
235
+ ({ NeedsTier2Error, ReviewAbortedError, runOperation, ValidationError } =
236
+ await import("./lib/orchestrator.mjs"));
237
+ ({ TIER2_EXIT_CODE, listBatches } = await import("./lib/tier2-protocol.mjs"));
238
+ ({ cmdBlame, cmdDiff, cmdHistory, cmdLog, cmdReflog, cmdShow } = await import(
239
+ "./lib/git-commands.mjs"
240
+ ));
241
+ ({ cmdRemote } = await import("./commands/remote.mjs"));
242
+ ({ cmdSync } = await import("./commands/sync.mjs"));
243
+ }
244
+
245
+ // Read the version from package.json at runtime. Resolved relative to this
246
+ // source file so it works both as a local clone (dev) and as a published
247
+ // npm artifact. @ctxr/kit historically stripped package.json from installed
248
+ // skill artifacts; if that environment is re-encountered we fall through to
249
+ // "unknown" rather than carrying a hand-maintained duplicate of the version
250
+ // string that inevitably drifts.
251
+ function getPackageVersion() {
252
+ try {
253
+ const pkgPath = new URL("../package.json", import.meta.url);
254
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
255
+ } catch {
256
+ return "unknown";
257
+ }
258
+ }
259
+
260
+ // Write usage to the appropriate stream. `--help` is a success path and
261
+ // must go to stdout so shells can pipe it (e.g. `cli --help | grep ...`);
262
+ // an unknown/malformed invocation is a failure path and goes to stderr.
263
+ function printUsage(stream = process.stdout) {
264
+ stream.write(`skill-llm-wiki CLI v${getPackageVersion()}
265
+
266
+ Usage: node scripts/cli.mjs <subcommand> [args] [flags]
267
+
268
+ Top-level operations:
269
+ build <source> Build a new wiki from a source folder
270
+ extend <wiki> Add new entries from a source
271
+ rebuild <wiki> Optimise structure in place
272
+ fix <wiki> Repair methodology divergences
273
+ join <wiki-a> <wiki-b> Merge two wikis into one
274
+ rollback <wiki> --to <ref> Restore a previous committed state
275
+ migrate <legacy-wiki> Migrate a legacy .llmwiki.v<N> folder
276
+
277
+ Hidden-git plumbing (Claude reads these to reason about history):
278
+ diff <wiki> [--op <id>] [...] Git-style diff (default --find-renames --find-copies)
279
+ log <wiki> [...] git log passthrough (default --oneline --all)
280
+ show <wiki> <ref> [-- <path>] git show passthrough
281
+ blame <wiki> <path> git blame passthrough
282
+ reflog <wiki> git reflog passthrough
283
+ history <wiki> <entry-id> Op-log + git-log walk for one entry
284
+
285
+ Remote mirroring (explicit user-invoked only, never auto-pushes):
286
+ remote <wiki> add <name> <url> Register a remote URL
287
+ remote <wiki> remove <name> Delete a configured remote
288
+ remote <wiki> list List configured remotes
289
+ sync <wiki> [--remote <name>] Fetch + push tag refs explicitly
290
+
291
+ Low-level script helpers (deterministic, called by Claude):
292
+ ingest <source> Walk source, emit candidate JSON
293
+ draft-leaf <candidate-file> Script-first frontmatter draft for one candidate
294
+ draft-category <candidate-file> Deterministic category assignment
295
+ index-rebuild <wiki> Regenerate all index.md files in a wiki
296
+ index-rebuild-one <dir> <wiki> Regenerate one directory's index.md
297
+ validate <wiki> Run hard invariants and print a report
298
+ shape-check <wiki> Detect pending operator candidates
299
+ resolve-wiki <source> Print current live wiki path for a source
300
+ next-version <source> Print next version tag for a source
301
+ list-versions <source> List all existing versions for a source
302
+ set-current <source> <version> Update the current-pointer for a source
303
+
304
+ Layout-mode flags (build/extend/rebuild/fix/join):
305
+ --layout-mode sibling|in-place|hosted
306
+ --target <path> Explicit destination (required for hosted)
307
+
308
+ Tiered-AI flags:
309
+ --quality-mode tiered-fast|claude-first|tier0-only
310
+ Default: tiered-fast (TF-IDF → embeddings
311
+ → Claude ladder). See guide/tiered-ai.md.
312
+
313
+ UX flags:
314
+ --no-prompt Never prompt; fail loud on ambiguity
315
+ --json-errors Emit ambiguity errors as JSON
316
+ --accept-dirty Operate on a dirty user git repo
317
+
318
+ Rollback flags:
319
+ --to <ref> genesis | <op-id> | pre-<op-id> | HEAD~N
320
+
321
+ Global:
322
+ --version Print CLI version
323
+ --help, -h Show this help
324
+
325
+ Exit codes: 0 ok · 1 usage · 2 ambiguous intent · 3 resolve-wiki miss ·
326
+ 4 Node too old · 5 git missing/too old · 6 wiki corrupt ·
327
+ 7 NEEDS_TIER2 (wiki-runner must resolve pending requests
328
+ and re-invoke this CLI — see SKILL.md delegation contract) ·
329
+ 8 DEPS_MISSING (required runtime dependency missing and the
330
+ install attempt was either declined or failed)
331
+ `);
332
+ }
333
+
334
+ // Parse a subcommand's remaining argv into { positionals, flags } for
335
+ // delegation to resolveIntent. Unknown flags bubble up as a structured
336
+ // error so we never silently swallow a typo.
337
+ const FLAG_WITH_VALUE = new Set([
338
+ "--layout-mode",
339
+ "--target",
340
+ "--to",
341
+ "--canonical",
342
+ "--quality-mode",
343
+ ]);
344
+ const FLAG_BOOLEAN = new Set([
345
+ "--no-prompt",
346
+ "--json-errors",
347
+ "--accept-dirty",
348
+ "--accept-foreign-target",
349
+ "--review",
350
+ ]);
351
+
352
+ function parseSubArgv(raw) {
353
+ const positionals = [];
354
+ const flags = {};
355
+ for (let i = 0; i < raw.length; i++) {
356
+ const tok = raw[i];
357
+ if (!tok.startsWith("--")) {
358
+ positionals.push(tok);
359
+ continue;
360
+ }
361
+ // Accept both `--flag value` and `--flag=value`.
362
+ let name = tok;
363
+ let inlineValue = null;
364
+ const eq = tok.indexOf("=");
365
+ if (eq !== -1) {
366
+ name = tok.slice(0, eq);
367
+ inlineValue = tok.slice(eq + 1);
368
+ }
369
+ if (FLAG_WITH_VALUE.has(name)) {
370
+ const value = inlineValue !== null ? inlineValue : raw[++i];
371
+ if (value === undefined || value === "" || value.startsWith("--")) {
372
+ return { error: `flag ${name} requires a non-empty value` };
373
+ }
374
+ const key = name.slice(2).replace(/-/g, "_");
375
+ flags[key] = value;
376
+ continue;
377
+ }
378
+ if (FLAG_BOOLEAN.has(name)) {
379
+ if (inlineValue !== null) {
380
+ return { error: `flag ${name} does not take a value` };
381
+ }
382
+ const key = name.slice(2).replace(/-/g, "_");
383
+ flags[key] = true;
384
+ continue;
385
+ }
386
+ return { error: `unknown flag: ${name}` };
387
+ }
388
+ return { positionals, flags };
389
+ }
390
+
391
+ // Emit an ambiguity or parse error through the configured formatter and
392
+ // exit 2. Never throws — returns through process.exit.
393
+ function emitIntentError(error, jsonMode) {
394
+ const body = jsonMode
395
+ ? formatAmbiguityJson(error)
396
+ : formatAmbiguityText(error);
397
+ process.stderr.write(body);
398
+ process.exit(2);
399
+ }
400
+
401
+ // Generate a stable op-id for a new top-level operation. Format:
402
+ // <operation>-<YYYYMMDD-HHMMSS>-<random>
403
+ // The wall-clock component is replaced by LLM_WIKI_FIXED_TIMESTAMP when
404
+ // set, so deterministic reruns produce identical op-ids.
405
+ function newOpId(operation) {
406
+ const now = process.env.LLM_WIKI_FIXED_TIMESTAMP
407
+ ? new Date(Number(process.env.LLM_WIKI_FIXED_TIMESTAMP) * 1000)
408
+ : new Date();
409
+ const y = now.getUTCFullYear();
410
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
411
+ const d = String(now.getUTCDate()).padStart(2, "0");
412
+ const hh = String(now.getUTCHours()).padStart(2, "0");
413
+ const mm = String(now.getUTCMinutes()).padStart(2, "0");
414
+ const ss = String(now.getUTCSeconds()).padStart(2, "0");
415
+ const rand = process.env.LLM_WIKI_FIXED_TIMESTAMP
416
+ ? "deterministic"
417
+ : Math.random().toString(36).slice(2, 8);
418
+ return `${operation}-${y}${m}${d}-${hh}${mm}${ss}-${rand}`;
419
+ }
420
+
421
+ async function main() {
422
+ const argv = process.argv.slice(2);
423
+ if (argv[0] === "--help" || argv[0] === "-h") {
424
+ printUsage(process.stdout);
425
+ process.exit(0);
426
+ }
427
+ if (argv.length === 0) {
428
+ printUsage(process.stderr);
429
+ process.exit(1);
430
+ }
431
+ if (argv[0] === "--version") {
432
+ // The dependency preflight is intentionally skipped for --version
433
+ // and --help so an operator debugging a broken install can still
434
+ // sanity-check the binary. Every other code path runs the
435
+ // preflight before any deterministic work begins.
436
+ console.log(getPackageVersion());
437
+ return;
438
+ }
439
+
440
+ // The dependency preflight has already run in the pre-import block
441
+ // at the top of this file. By the time we reach this point, every
442
+ // required runtime dep has been verified or the process has exited
443
+ // 8. See guide/ux/preflight.md Case E.
444
+ //
445
+ // Now load the skill-internal modules. They use `gray-matter` and
446
+ // `@xenova/transformers` transitively, so they MUST be loaded only
447
+ // after the dep preflight has confirmed both packages are
448
+ // resolvable.
449
+ await loadSkillModules();
450
+
451
+ const cmd = argv[0];
452
+ const args = argv.slice(1);
453
+
454
+ // ─── Remote + sync subcommands (Phase 7) ────────────────────────────
455
+ // Both take <wiki> as the first positional. `remote` takes a
456
+ // subcommand (add/remove/list); `sync` accepts --remote <name>
457
+ // and --push-branch <ref> flags.
458
+ if (cmd === "remote") {
459
+ if (args.length < 1) {
460
+ usageError("remote requires <wiki> as its first argument");
461
+ }
462
+ const wiki = resolve(args[0]);
463
+ const subcommand = args[1];
464
+ const subArgs = args.slice(2);
465
+ process.exit(cmdRemote(wiki, { subcommand, args: subArgs }));
466
+ }
467
+ if (cmd === "sync") {
468
+ if (args.length < 1) {
469
+ usageError("sync requires <wiki> as its first argument");
470
+ }
471
+ const wiki = resolve(args[0]);
472
+ // Parse --remote / --push-branch / --skip-fetch / --skip-push.
473
+ // Both `--flag value` and `--flag=value` are accepted to match
474
+ // the rest of the CLI's flag conventions. Empty values and
475
+ // leading-dash values are rejected loudly.
476
+ const rest = args.slice(1);
477
+ const opts = {};
478
+ for (let i = 0; i < rest.length; i++) {
479
+ const tok = rest[i];
480
+ // Accept --flag=value form.
481
+ let name = tok;
482
+ let inlineValue = null;
483
+ const eq = tok.indexOf("=");
484
+ if (tok.startsWith("--") && eq !== -1) {
485
+ name = tok.slice(0, eq);
486
+ inlineValue = tok.slice(eq + 1);
487
+ }
488
+ const readValue = (flagName) => {
489
+ const v = inlineValue !== null ? inlineValue : rest[++i];
490
+ if (v === undefined || v === "" || v.startsWith("--")) {
491
+ usageError(`sync: ${flagName} requires a non-empty value`);
492
+ }
493
+ return v;
494
+ };
495
+ if (name === "--remote") {
496
+ opts.remote = readValue("--remote");
497
+ } else if (name === "--push-branch") {
498
+ opts.pushBranch = readValue("--push-branch");
499
+ } else if (name === "--skip-fetch") {
500
+ if (inlineValue !== null) usageError("sync: --skip-fetch does not take a value");
501
+ opts.skipFetch = true;
502
+ } else if (name === "--skip-push") {
503
+ if (inlineValue !== null) usageError("sync: --skip-push does not take a value");
504
+ opts.skipPush = true;
505
+ } else {
506
+ usageError(`sync: unknown argument "${tok}"`);
507
+ }
508
+ }
509
+ process.exit(cmdSync(wiki, opts));
510
+ }
511
+
512
+ // ─── Hidden-git passthrough subcommands ─────────────────────────────
513
+ // These wrap scripts/lib/git.mjs with the full isolation env so
514
+ // Claude (or a user) can inspect history without ever touching the
515
+ // user's own git repo. Every hidden-git command takes <wiki> as its
516
+ // first positional; remaining args pass through to git.
517
+ const HIDDEN_GIT_SUBCOMMANDS = new Set([
518
+ "diff",
519
+ "log",
520
+ "show",
521
+ "blame",
522
+ "reflog",
523
+ "history",
524
+ ]);
525
+ if (HIDDEN_GIT_SUBCOMMANDS.has(cmd)) {
526
+ if (args.length < 1) {
527
+ usageError(`${cmd} requires <wiki> as its first argument`);
528
+ }
529
+ const wiki = resolve(args[0]);
530
+ // Parse a minimal set of our own flags; everything else passes
531
+ // through to the underlying git command.
532
+ const rest = args.slice(1);
533
+ const opIdx = rest.indexOf("--op");
534
+ let op = null;
535
+ let passthrough = rest.slice();
536
+ if (opIdx !== -1) {
537
+ op = rest[opIdx + 1];
538
+ passthrough = rest.slice(0, opIdx).concat(rest.slice(opIdx + 2));
539
+ }
540
+ let code = 0;
541
+ switch (cmd) {
542
+ case "diff":
543
+ code = cmdDiff(wiki, { op, args: passthrough });
544
+ break;
545
+ case "log":
546
+ code = cmdLog(wiki, { op, args: passthrough });
547
+ break;
548
+ case "show": {
549
+ const ref = passthrough[0];
550
+ const showArgs = passthrough.slice(1);
551
+ code = cmdShow(wiki, { ref, args: showArgs });
552
+ break;
553
+ }
554
+ case "blame": {
555
+ const path = passthrough[0];
556
+ const blameArgs = passthrough.slice(1);
557
+ code = cmdBlame(wiki, { path: path && resolve(path), args: blameArgs });
558
+ break;
559
+ }
560
+ case "reflog":
561
+ code = cmdReflog(wiki, { args: passthrough });
562
+ break;
563
+ case "history": {
564
+ const entryId = passthrough[0];
565
+ code = cmdHistory(wiki, { entryId });
566
+ break;
567
+ }
568
+ }
569
+ process.exit(code);
570
+ }
571
+
572
+ // ─── Top-level operations routed through intent.mjs ─────────────────
573
+ // build / extend / rebuild / fix / join share the same intent-
574
+ // resolution → dispatch flow. rollback and migrate have tiny bespoke
575
+ // paths (still routed through intent for the ambiguity surface).
576
+ // Phase 2 wires the plumbing; Phase 3 will extend the handlers with
577
+ // full phased orchestration.
578
+ const INTENT_SUBCOMMANDS = new Set([
579
+ "build",
580
+ "extend",
581
+ "rebuild",
582
+ "fix",
583
+ "join",
584
+ "rollback",
585
+ "migrate",
586
+ ]);
587
+ if (INTENT_SUBCOMMANDS.has(cmd)) {
588
+ const parsed = parseSubArgv(args);
589
+ if (parsed.error) {
590
+ const jsonMode = args.includes("--json-errors");
591
+ emitIntentError(
592
+ {
593
+ code: "INT-11",
594
+ message: parsed.error,
595
+ options: [],
596
+ resolving_flag: "correct the flag",
597
+ },
598
+ jsonMode,
599
+ );
600
+ }
601
+ const { positionals, flags } = parsed;
602
+ const jsonMode = Boolean(flags.json_errors);
603
+
604
+ // `migrate` has its own resolution path — the intent resolver would
605
+ // reject the legacy folder shape as ambiguous.
606
+ if (cmd === "migrate") {
607
+ if (positionals.length !== 1) {
608
+ emitIntentError(
609
+ {
610
+ code: "INT-06",
611
+ message: "migrate requires exactly one <legacy-wiki> positional",
612
+ options: [
613
+ {
614
+ description: "specify the legacy wiki",
615
+ flag: "migrate <legacy-path>",
616
+ },
617
+ ],
618
+ resolving_flag: "positional legacy path",
619
+ },
620
+ jsonMode,
621
+ );
622
+ }
623
+ const legacyPath = resolve(positionals[0]);
624
+ const target = flags.target
625
+ ? resolve(flags.target)
626
+ : defaultMigrationTarget(legacyPath);
627
+ try {
628
+ const opId = newOpId("migrate");
629
+ const r = migrateLegacyWiki(legacyPath, target, { opId });
630
+ process.stdout.write(
631
+ `migrated ${legacyPath} (v${r.version}) → ${target}\n` +
632
+ ` op-id: ${r.opId}\n` +
633
+ ` sha: ${r.sha}\n`,
634
+ );
635
+ return;
636
+ } catch (err) {
637
+ if (err && err.message && /already exists/.test(err.message)) {
638
+ emitIntentError(
639
+ {
640
+ code: "INT-01",
641
+ message: err.message,
642
+ options: [
643
+ {
644
+ description: "write to a different target",
645
+ flag: "--target <other-path>",
646
+ },
647
+ ],
648
+ resolving_flag: "--target",
649
+ },
650
+ jsonMode,
651
+ );
652
+ }
653
+ throw err;
654
+ }
655
+ }
656
+
657
+ const intent = resolveIntent({
658
+ subcommand: cmd,
659
+ args: positionals,
660
+ flags,
661
+ cwd: process.cwd(),
662
+ });
663
+ if (intent.status === "ambiguous") {
664
+ emitIntentError(intent.error, jsonMode);
665
+ }
666
+ const plan = intent.plan;
667
+
668
+ if (cmd === "rollback") {
669
+ const result = rollbackOperation(plan.target, flags.to);
670
+ process.stdout.write(
671
+ `rolled back ${plan.target} to ${result.ref} (${result.sha ?? "n/a"})\n`,
672
+ );
673
+ return;
674
+ }
675
+
676
+ // build / extend / rebuild / fix / join: Phase 3 runs the full
677
+ // phased orchestrator. The orchestrator handles snapshot → ingest
678
+ // → draft-frontmatter → index-generation → validation →
679
+ // commit-finalize, with automatic rollback on validation failure.
680
+ if (plan.is_new_wiki) {
681
+ mkdirSync(plan.target, { recursive: true });
682
+ }
683
+ const opId = newOpId(cmd);
684
+ const startedIso = new Date().toISOString();
685
+ let result;
686
+ try {
687
+ result = await runOperation(plan, {
688
+ opId,
689
+ source: plan.source,
690
+ startedIso,
691
+ });
692
+ } catch (err) {
693
+ if (err instanceof NonInteractiveError) {
694
+ emitIntentError(
695
+ {
696
+ code: "INT-12",
697
+ message: err.message,
698
+ options: [
699
+ {
700
+ description: "run with stdin attached to a TTY",
701
+ flag: "(interactive terminal)",
702
+ },
703
+ ],
704
+ resolving_flag: "explicit flag set",
705
+ },
706
+ jsonMode,
707
+ );
708
+ }
709
+ if (err instanceof ValidationError) {
710
+ process.stderr.write(
711
+ `${cmd}: validation failed — working tree rolled back to pre-op state\n` +
712
+ err.message +
713
+ "\n",
714
+ );
715
+ process.exit(2);
716
+ }
717
+ if (err instanceof NeedsTier2Error) {
718
+ // Exit-7 handshake: a phase accumulated Tier 2 requests
719
+ // that only a wiki-runner sub-agent can resolve. The
720
+ // working tree is NOT rolled back — the partial-converge
721
+ // commits in the private git are preserved so the resume
722
+ // invocation can continue from the last completed
723
+ // iteration. The wiki-runner reads the pending batch,
724
+ // spawns one Agent per request, writes the responses, and
725
+ // re-invokes this CLI with the same op-id (same
726
+ // source/target positional args) so the orchestrator
727
+ // resumes. See SKILL.md "Agent delegation contract" and
728
+ // guide/tiered-ai.md "exit-7 handshake" for details.
729
+ const batches = listBatches(plan.target);
730
+ process.stderr.write(
731
+ `${cmd}: NEEDS_TIER2 — ${err.message}\n` +
732
+ ` op-id: ${opId}\n` +
733
+ ` pending: ${err.pendingPath ?? "(no path)"}\n` +
734
+ ` total batches waiting: ${batches.length}\n` +
735
+ ` Wiki-runner: read every pending-*.json under ` +
736
+ `${plan.target}/.work/tier2/, spawn one Agent per request, ` +
737
+ `write responses-*.json next to it, and re-invoke this CLI ` +
738
+ `with the same positional args.\n`,
739
+ );
740
+ process.exit(TIER2_EXIT_CODE);
741
+ }
742
+ if (err instanceof ReviewAbortedError) {
743
+ process.stderr.write(
744
+ `${cmd}: ${err.message}\n` +
745
+ "No changes were committed to the wiki.\n",
746
+ );
747
+ process.exit(2);
748
+ }
749
+ throw err;
750
+ }
751
+ process.stdout.write(
752
+ `${cmd}: complete\n` +
753
+ ` target: ${plan.target}\n` +
754
+ ` mode: ${plan.layout_mode}\n` +
755
+ ` op-id: ${opId}\n` +
756
+ ` sha: ${result.final_sha ?? "n/a"}\n` +
757
+ ` phases: ${result.phases.length}\n`,
758
+ );
759
+ for (const p of result.phases) {
760
+ process.stdout.write(` • ${p.name}: ${p.summary}\n`);
761
+ }
762
+ return;
763
+ }
764
+
765
+ switch (cmd) {
766
+ case "ingest": {
767
+ if (args.length < 1) usageError("ingest requires <source>");
768
+ const result = ingestSource(resolve(args[0]));
769
+ // The CLI-level `ingest` helper exposes both the leaf candidates
770
+ // and the index sources so that downstream tooling (and human
771
+ // inspection via `node scripts/cli.mjs ingest`) sees the full
772
+ // picture now that index inputs are classified separately.
773
+ process.stdout.write(
774
+ JSON.stringify(
775
+ {
776
+ candidates: result.leaves ?? result.candidates ?? [],
777
+ indexSources: result.indexSources ?? [],
778
+ },
779
+ null,
780
+ 2,
781
+ ) + "\n",
782
+ );
783
+ break;
784
+ }
785
+ case "draft-leaf": {
786
+ if (args.length < 1) usageError("draft-leaf requires <candidate-file>");
787
+ const candidate = JSON.parse(readFileSync(args[0], "utf8"));
788
+ const result = draftLeafFrontmatter(candidate, {
789
+ categoryPath: draftCategory(candidate),
790
+ });
791
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
792
+ break;
793
+ }
794
+ case "draft-category": {
795
+ if (args.length < 1)
796
+ usageError("draft-category requires <candidate-file>");
797
+ const candidate = JSON.parse(readFileSync(args[0], "utf8"));
798
+ process.stdout.write(draftCategory(candidate) + "\n");
799
+ break;
800
+ }
801
+ case "index-rebuild": {
802
+ if (args.length < 1) usageError("index-rebuild requires <wiki>");
803
+ const wiki = resolve(args[0]);
804
+ const out = rebuildAllIndices(wiki);
805
+ process.stdout.write(`rebuilt ${out.length} index.md files\n`);
806
+ break;
807
+ }
808
+ case "index-rebuild-one": {
809
+ if (args.length < 2)
810
+ usageError("index-rebuild-one requires <dir> <wiki>");
811
+ const dir = resolve(args[0]);
812
+ const wiki = resolve(args[1]);
813
+ const out = rebuildIndex(dir, wiki);
814
+ process.stdout.write(`rebuilt ${out.path}\n`);
815
+ break;
816
+ }
817
+ case "validate": {
818
+ if (args.length < 1) usageError("validate requires <wiki>");
819
+ const wiki = resolve(args[0]);
820
+ const findings = validateWiki(wiki);
821
+ const summary = summariseFindings(findings);
822
+ for (const f of findings) {
823
+ const tag =
824
+ f.severity === "error"
825
+ ? "ERR "
826
+ : f.severity === "warning"
827
+ ? "WARN"
828
+ : "INFO";
829
+ console.log(`[${tag}] ${f.code} ${f.target}`);
830
+ console.log(` ${f.message}`);
831
+ }
832
+ console.log(
833
+ `\n${summary.errors} error(s), ${summary.warnings} warning(s)`,
834
+ );
835
+ process.exit(summary.errors > 0 ? 2 : 0);
836
+ break;
837
+ }
838
+ case "shape-check": {
839
+ if (args.length < 1) usageError("shape-check requires <wiki>");
840
+ const wiki = resolve(args[0]);
841
+ const suggestions = runShapeCheck(wiki);
842
+ console.log(`${suggestions.length} pending shape candidate(s)`);
843
+ for (const s of suggestions) {
844
+ const t = Array.isArray(s.target) ? s.target.join(", ") : s.target;
845
+ console.log(` ${s.operator} ${t}`);
846
+ console.log(` ${s.reason}`);
847
+ }
848
+ break;
849
+ }
850
+ case "resolve-wiki": {
851
+ if (args.length < 1) usageError("resolve-wiki requires <source>");
852
+ const live = resolveLiveWiki(resolve(args[0]));
853
+ if (!live) {
854
+ process.stderr.write("no wiki exists for this source yet\n");
855
+ process.exit(3);
856
+ }
857
+ process.stdout.write(live.path + "\n");
858
+ break;
859
+ }
860
+ case "next-version": {
861
+ if (args.length < 1) usageError("next-version requires <source>");
862
+ process.stdout.write(nextVersionTag(resolve(args[0])) + "\n");
863
+ break;
864
+ }
865
+ case "list-versions": {
866
+ if (args.length < 1) usageError("list-versions requires <source>");
867
+ const versions = listVersions(resolve(args[0]));
868
+ for (const v of versions) process.stdout.write(`${v.tag}\t${v.path}\n`);
869
+ break;
870
+ }
871
+ case "set-current": {
872
+ if (args.length < 2)
873
+ usageError("set-current requires <source> <version>");
874
+ writeCurrentPointer(resolve(args[0]), args[1]);
875
+ process.stdout.write(`current → ${args[1]}\n`);
876
+ break;
877
+ }
878
+ default:
879
+ printUsage();
880
+ process.exit(1);
881
+ }
882
+ }
883
+
884
+ function usageError(msg) {
885
+ process.stderr.write(`error: ${msg}\n`);
886
+ printUsage(process.stderr);
887
+ process.exit(1);
888
+ }
889
+
890
+ main().catch((err) => {
891
+ console.error(`error: ${err.message}`);
892
+ process.exit(1);
893
+ });