@ctxr/skill-llm-wiki 1.0.2 → 1.2.0

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.
@@ -33,6 +33,16 @@
33
33
  // INT-12 ambiguity reached interactive resolution in a non-TTY
34
34
  // context (emitted by cli.mjs when NonInteractiveError fires)
35
35
  // INT-13 unknown --quality-mode value
36
+ // INT-14 invalid --fanout-target value (must be a positive integer
37
+ // in [FANOUT_TARGET_MIN, FANOUT_TARGET_MAX])
38
+ // INT-14a --fanout-target used on a subcommand other than build /
39
+ // rebuild (balance enforcement is build/rebuild-only)
40
+ // INT-15 invalid --max-depth value (must be a positive integer in
41
+ // [MAX_DEPTH_MIN, MAX_DEPTH_MAX])
42
+ // INT-15a --max-depth used on a subcommand other than build /
43
+ // rebuild (balance enforcement is build/rebuild-only)
44
+ // INT-16a --soft-dag-parents used on a subcommand other than build /
45
+ // rebuild (soft-DAG synthesis is build/rebuild-only)
36
46
  //
37
47
  // Plan shape (status === "ok"):
38
48
  // {
@@ -50,7 +60,11 @@
50
60
 
51
61
  import { spawnSync } from "node:child_process";
52
62
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
53
- import { dirname, isAbsolute, join, resolve } from "node:path";
63
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
64
+ import {
65
+ DEFAULT_COLLISION_POLICY,
66
+ VALID_COLLISION_POLICIES,
67
+ } from "./join-constants.mjs";
54
68
  import {
55
69
  defaultSiblingPath,
56
70
  hasPrivateGit,
@@ -64,14 +78,53 @@ export const VALID_LAYOUT_MODES = Object.freeze(["sibling", "in-place", "hosted"
64
78
  // rejects typos BEFORE the orchestrator runs, avoiding expensive
65
79
  // rollbacks on a trivial flag error. Must stay in sync with
66
80
  // tiered.mjs:QUALITY_MODES — the unit test
67
- // tests/unit/intent-resolve.test.mjs:valid-quality-modes verifies
68
- // this.
81
+ // tests/unit/intent-resolve.test.mjs::"VALID_QUALITY_MODES is in
82
+ // sync with tiered.mjs::QUALITY_MODES" pins that both lists contain
83
+ // the same modes in the same order so a future drift fails loud
84
+ // instead of silently letting one layer accept a mode the other
85
+ // rejects.
69
86
  export const VALID_QUALITY_MODES = Object.freeze([
70
87
  "tiered-fast",
71
88
  "claude-first",
72
- "tier0-only",
89
+ "deterministic",
73
90
  ]);
74
91
 
92
+ // Range bounds for the balance-enforcement flags (`--fanout-target`,
93
+ // `--max-depth`). Exported so tests and the balance module can
94
+ // reference them directly without re-stating the literal values.
95
+ //
96
+ // The ranges cover the band where a post-convergence rebalance pass
97
+ // has a meaningful effect. Fanout 1 is degenerate (every split forces
98
+ // single-child chains); 100+ is effectively unbounded for any real
99
+ // corpus (the cluster detector caps individual clusters at 8).
100
+ // Max depth starts at 1 — the "no nesting"/flat-layout case is out
101
+ // of this flag's scope; balance's purpose is to flatten OVERDEEP
102
+ // branches, not to undo nesting the pipeline already decided to
103
+ // create, and the regular NEST operator handles the no-nesting
104
+ // starting condition anyway. 11+ max-depth is deeper than any
105
+ // hand-authored corpus anyone has reported, so the flag becomes a
106
+ // silent no-op above that. Out-of-range values are treated as user
107
+ // errors at intent time so the flag is never a silent no-op at
108
+ // runtime.
109
+ export const FANOUT_TARGET_MIN = 2;
110
+ export const FANOUT_TARGET_MAX = 100;
111
+ export const MAX_DEPTH_MIN = 1;
112
+ export const MAX_DEPTH_MAX = 10;
113
+
114
+ // Parse + validate an integer-in-range flag value. Returns a short
115
+ // human-readable reason string when the value is invalid (for use in
116
+ // the ambiguity error), or null when valid. Factored out so INT-14
117
+ // and INT-15 share one implementation.
118
+ function invalidIntInRange(raw, min, max) {
119
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) {
120
+ return `expected a positive integer, got "${raw}"`;
121
+ }
122
+ const n = Number.parseInt(raw, 10);
123
+ if (n < min) return `${n} is below the minimum ${min}`;
124
+ if (n > max) return `${n} is above the maximum ${max}`;
125
+ return null;
126
+ }
127
+
75
128
  export function ok(plan) {
76
129
  return { status: "ok", plan };
77
130
  }
@@ -265,6 +318,153 @@ export function resolveIntent(ctx) {
265
318
  "--quality-mode",
266
319
  );
267
320
  }
321
+ // Validate `LLM_WIKI_QUALITY_MODE` here too, but only for
322
+ // subcommands that actually consult runtime quality-mode
323
+ // resolution (build / extend / rebuild / fix / join all call
324
+ // through `resolveQualityMode` in the orchestrator). Commands
325
+ // like `rollback`, `validate`, `init`, `heal` never touch
326
+ // tiered.mjs, so blocking them on a stale env var — especially
327
+ // `rollback`, which is a recovery path — would make a trivial
328
+ // shell-config typo catastrophic. Without this gate, a stale
329
+ // `LLM_WIKI_QUALITY_MODE=tier0-only` would lock the user out of
330
+ // recovering the wiki they were trying to rollback.
331
+ //
332
+ // Symmetric with the flag path — same code, same suggestions,
333
+ // same exit-2 ambiguous shape — for the subcommands where the
334
+ // env var is actually consumed.
335
+ const QUALITY_MODE_CONSUMERS = new Set([
336
+ "build",
337
+ "extend",
338
+ "rebuild",
339
+ "fix",
340
+ "join",
341
+ ]);
342
+ const envQualityMode = process.env.LLM_WIKI_QUALITY_MODE;
343
+ if (
344
+ QUALITY_MODE_CONSUMERS.has(subcommand) &&
345
+ !f.quality_mode &&
346
+ envQualityMode &&
347
+ !VALID_QUALITY_MODES.includes(envQualityMode)
348
+ ) {
349
+ return ambiguous(
350
+ "INT-13",
351
+ `unknown LLM_WIKI_QUALITY_MODE value "${envQualityMode}"`,
352
+ VALID_QUALITY_MODES.map((m) => ({
353
+ description: `use ${m} quality mode`,
354
+ flag: `--quality-mode ${m}`,
355
+ })),
356
+ "LLM_WIKI_QUALITY_MODE",
357
+ );
358
+ }
359
+ // Balance-enforcement flags are build/rebuild-only. The CLI parser
360
+ // accepts them globally (to produce a uniform flag table), so intent
361
+ // has to gate the subcommand explicitly — otherwise `fix`, `join`,
362
+ // `rollback` etc. would silently carry them through to the
363
+ // orchestrator, where the hook would fire and apply unexpected
364
+ // structural mutations outside the documented surface.
365
+ const BALANCE_FLAG_SUBCOMMANDS = new Set(["build", "rebuild"]);
366
+ // Subcommands that operate on an existing wiki (take a wiki path).
367
+ // For these, the natural remediation is `rebuild` (which walks an
368
+ // existing wiki). For source-operating subcommands with no wiki
369
+ // path, `build` is the right suggestion. `build` itself isn't in
370
+ // either set since it can't reach this branch.
371
+ const WIKI_OPERATING = new Set(["fix", "validate", "extend", "rebuild", "rollback", "join"]);
372
+ const suggestedSub = WIKI_OPERATING.has(subcommand) ? "rebuild" : "build";
373
+ if (f.fanout_target !== undefined) {
374
+ if (!BALANCE_FLAG_SUBCOMMANDS.has(subcommand)) {
375
+ return ambiguous(
376
+ "INT-14a",
377
+ `--fanout-target is only supported on build / rebuild (got "${subcommand}")`,
378
+ [
379
+ {
380
+ description: "drop --fanout-target",
381
+ flag: "(remove --fanout-target)",
382
+ },
383
+ {
384
+ description: `run on a ${suggestedSub} instead`,
385
+ flag: `${suggestedSub} ... --fanout-target <N>`,
386
+ },
387
+ ],
388
+ "--fanout-target",
389
+ );
390
+ }
391
+ const bad = invalidIntInRange(
392
+ f.fanout_target,
393
+ FANOUT_TARGET_MIN,
394
+ FANOUT_TARGET_MAX,
395
+ );
396
+ if (bad) {
397
+ return ambiguous(
398
+ "INT-14",
399
+ `invalid --fanout-target "${f.fanout_target}" (${bad})`,
400
+ [
401
+ {
402
+ description: `use a positive integer in [${FANOUT_TARGET_MIN}, ${FANOUT_TARGET_MAX}]`,
403
+ flag: "--fanout-target <integer>",
404
+ },
405
+ ],
406
+ "--fanout-target",
407
+ );
408
+ }
409
+ }
410
+ if (f.max_depth !== undefined) {
411
+ if (!BALANCE_FLAG_SUBCOMMANDS.has(subcommand)) {
412
+ return ambiguous(
413
+ "INT-15a",
414
+ `--max-depth is only supported on build / rebuild (got "${subcommand}")`,
415
+ [
416
+ {
417
+ description: "drop --max-depth",
418
+ flag: "(remove --max-depth)",
419
+ },
420
+ {
421
+ description: `run on a ${suggestedSub} instead`,
422
+ flag: `${suggestedSub} ... --max-depth <D>`,
423
+ },
424
+ ],
425
+ "--max-depth",
426
+ );
427
+ }
428
+ const bad = invalidIntInRange(f.max_depth, MAX_DEPTH_MIN, MAX_DEPTH_MAX);
429
+ if (bad) {
430
+ return ambiguous(
431
+ "INT-15",
432
+ `invalid --max-depth "${f.max_depth}" (${bad})`,
433
+ [
434
+ {
435
+ description: `use a positive integer in [${MAX_DEPTH_MIN}, ${MAX_DEPTH_MAX}]`,
436
+ flag: "--max-depth <integer>",
437
+ },
438
+ ],
439
+ "--max-depth",
440
+ );
441
+ }
442
+ }
443
+ // --soft-dag-parents shares balance's subcommand scope (build /
444
+ // rebuild only). Same defense-in-depth as INT-14a / INT-15a: the
445
+ // CLI parser accepts the flag globally, so intent has to gate
446
+ // explicitly — otherwise `fix`, `join`, `rollback` etc. would
447
+ // silently carry the flag through to the orchestrator and rewrite
448
+ // parents[] on every leaf, outside the documented surface.
449
+ if (f.soft_dag_parents === true) {
450
+ if (!BALANCE_FLAG_SUBCOMMANDS.has(subcommand)) {
451
+ return ambiguous(
452
+ "INT-16a",
453
+ `--soft-dag-parents is only supported on build / rebuild (got "${subcommand}")`,
454
+ [
455
+ {
456
+ description: "drop --soft-dag-parents",
457
+ flag: "(remove --soft-dag-parents)",
458
+ },
459
+ {
460
+ description: `run on a ${suggestedSub} instead`,
461
+ flag: `${suggestedSub} ... --soft-dag-parents`,
462
+ },
463
+ ],
464
+ "--soft-dag-parents",
465
+ );
466
+ }
467
+ }
268
468
  if (f.layout_mode === "in-place" && f.target) {
269
469
  return ambiguous(
270
470
  "INT-09a",
@@ -324,6 +524,172 @@ export function resolveIntent(ctx) {
324
524
  });
325
525
  }
326
526
 
527
+ // ─── Join: N >= 2 existing wikis into a unified output ───────────
528
+ //
529
+ // Positional shape: `join <wiki-a> <wiki-b> [<wiki-c>...]`. The
530
+ // `--target` flag is REQUIRED (where to write the unified output)
531
+ // — join is inherently hosted-mode because the output belongs
532
+ // neither next to source A nor source B. Each source must exist
533
+ // and be a skill-managed wiki (have `.llmwiki/git`). Target must
534
+ // not already exist (or must be empty): the CLI materialises into
535
+ // a fresh tree so the source wikis stay strictly read-only.
536
+ //
537
+ // `--id-collision` selects the cross-source id-collision policy
538
+ // (`namespace` / `merge` / `ask`); defaults to `namespace`. The
539
+ // value is validated here against the same canonical list
540
+ // scripts/lib/join.mjs exports, so a typo fails at the intent
541
+ // layer with structured INT-17 rather than burning a pre-op
542
+ // snapshot and rolling back.
543
+ if (subcommand === "join") {
544
+ if (args.length < 2) {
545
+ return ambiguous(
546
+ "INT-06",
547
+ `join requires at least 2 <wiki-path> positionals (got ${args.length})`,
548
+ [
549
+ {
550
+ description: "specify two or more source wikis",
551
+ flag: "join <wiki-a> <wiki-b> [<wiki-c>...]",
552
+ },
553
+ ],
554
+ "positional wiki paths",
555
+ );
556
+ }
557
+ const sources = args.map((a) => absolute(cwd, a));
558
+ for (const s of sources) {
559
+ if (!existsSync(s)) {
560
+ return ambiguous(
561
+ "INT-06",
562
+ `join: source wiki ${s} does not exist`,
563
+ [
564
+ {
565
+ description: "point at an existing skill-managed wiki",
566
+ flag: `join <existing-wiki> <existing-wiki>`,
567
+ },
568
+ ],
569
+ "each positional must exist",
570
+ );
571
+ }
572
+ if (!hasPrivateGit(s)) {
573
+ return ambiguous(
574
+ "INT-06",
575
+ `join: ${s} is not a skill-managed wiki (no .llmwiki/git)`,
576
+ [
577
+ {
578
+ description: "build each source first",
579
+ flag: `build ${s} --layout-mode in-place`,
580
+ },
581
+ ],
582
+ "each source must be a managed wiki",
583
+ );
584
+ }
585
+ }
586
+ if (!f.target) {
587
+ return ambiguous(
588
+ "INT-09b",
589
+ `join requires --target <path> (join output is always hosted; see guide/operations/ingest/join.md)`,
590
+ [
591
+ {
592
+ description: "set an explicit output path",
593
+ flag: "--target <path/for/unified/wiki>",
594
+ },
595
+ ],
596
+ "--target",
597
+ );
598
+ }
599
+ const target = absolute(cwd, f.target);
600
+ // Source immutability guard: --target must not equal, nest
601
+ // under, or contain any source wiki. Join writes to target;
602
+ // any path relationship between target and a source means the
603
+ // write would either clobber source files or place output
604
+ // inside the source tree (violating the documented "sources
605
+ // are read-only" guarantee). Detect both subpath directions
606
+ // with `path.relative` and reject with INT-18 before the
607
+ // pre-op snapshot fires. This check runs BEFORE the
608
+ // empty-target check — a target that equals or contains a
609
+ // source would otherwise be (correctly) flagged as non-empty
610
+ // under INT-01, masking the more specific semantic problem.
611
+ for (const s of sources) {
612
+ const relTargetFromSource = relative(s, target);
613
+ const relSourceFromTarget = relative(target, s);
614
+ const targetUnderSource =
615
+ relTargetFromSource === "" ||
616
+ (!relTargetFromSource.startsWith("..") &&
617
+ !isAbsolute(relTargetFromSource));
618
+ const sourceUnderTarget =
619
+ relSourceFromTarget !== "" &&
620
+ !relSourceFromTarget.startsWith("..") &&
621
+ !isAbsolute(relSourceFromTarget);
622
+ if (targetUnderSource || sourceUnderTarget) {
623
+ return ambiguous(
624
+ "INT-18",
625
+ `join: --target ${target} must not equal, nest under, or contain any source wiki (conflicts with ${s})`,
626
+ [
627
+ {
628
+ description:
629
+ "pick a target path outside every source wiki tree",
630
+ flag: "--target <path-outside-sources>",
631
+ },
632
+ ],
633
+ "--target must not overlap sources",
634
+ );
635
+ }
636
+ }
637
+ if (existsSync(target)) {
638
+ // Allow an empty directory but nothing else — the target must
639
+ // be safe to materialise into without clobbering user data.
640
+ let entries;
641
+ try {
642
+ entries = readdirSync(target);
643
+ } catch {
644
+ entries = null;
645
+ }
646
+ if (!entries || entries.length > 0) {
647
+ return ambiguous(
648
+ "INT-01",
649
+ `join: --target path ${target} already exists and is not empty`,
650
+ [
651
+ {
652
+ description: "pick a fresh output path",
653
+ flag: "--target <new-path>",
654
+ },
655
+ ],
656
+ "--target must be empty or new",
657
+ );
658
+ }
659
+ }
660
+ // Share VALID_COLLISION_POLICIES with `join.mjs` — the single
661
+ // canonical list. A local copy here risked silent drift if the
662
+ // set gained or lost a policy; importing keeps the intent
663
+ // validator and the runtime in lockstep.
664
+ const policy = f.id_collision || DEFAULT_COLLISION_POLICY;
665
+ if (!VALID_COLLISION_POLICIES.includes(policy)) {
666
+ return ambiguous(
667
+ "INT-17",
668
+ `unknown --id-collision value "${policy}"`,
669
+ VALID_COLLISION_POLICIES.map((p) => ({
670
+ description: `use ${p} policy`,
671
+ flag: `--id-collision ${p}`,
672
+ })),
673
+ "--id-collision",
674
+ );
675
+ }
676
+ return ok({
677
+ operation: "join",
678
+ layout_mode: "hosted",
679
+ source: null,
680
+ sources,
681
+ target,
682
+ is_new_wiki: true,
683
+ flags: {
684
+ accept_dirty: f.accept_dirty === true,
685
+ no_prompt: f.no_prompt === true,
686
+ json_errors: f.json_errors === true || f.json === true,
687
+ id_collision: policy,
688
+ quality_mode: f.quality_mode,
689
+ },
690
+ });
691
+ }
692
+
327
693
  // ─── Rebuild / fix: the positional IS the wiki, not a source ──────
328
694
  // These operations read frontmatter from an existing wiki and write
329
695
  // back in place. There is no separate source — the wiki is both.
@@ -0,0 +1,22 @@
1
+ // join-constants.mjs — the minimal shared constants between the
2
+ // runtime pipeline (`join.mjs`) and the intent layer
3
+ // (`intent.mjs`).
4
+ //
5
+ // Kept in its own file so that importing the policy allow-list
6
+ // at intent time does NOT drag in the full join pipeline module
7
+ // (ingest + convergence + indices + validation + every transitive
8
+ // dependency). CLI paths that never run a join — build, rebuild,
9
+ // fix, validate, rollback, init, heal, where — would otherwise
10
+ // pay the cold-start cost of loading `join.mjs` and its
11
+ // dependency graph on every invocation just to resolve the
12
+ // `--id-collision` flag. Keeping this module dependency-light
13
+ // (zero imports, plain string constants) keeps every non-join
14
+ // CLI startup path fast.
15
+
16
+ export const VALID_COLLISION_POLICIES = Object.freeze([
17
+ "namespace",
18
+ "merge",
19
+ "ask",
20
+ ]);
21
+
22
+ export const DEFAULT_COLLISION_POLICY = "namespace";