@ctxr/skill-llm-wiki 1.0.2 → 1.1.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.
@@ -16,9 +16,14 @@
16
16
  // queryable even after the op is reset.
17
17
 
18
18
  import {
19
+ appendFileSync,
20
+ closeSync,
19
21
  existsSync,
22
+ fstatSync,
20
23
  mkdirSync,
24
+ openSync,
21
25
  readFileSync,
26
+ readSync,
22
27
  renameSync,
23
28
  writeFileSync,
24
29
  } from "node:fs";
@@ -130,27 +135,112 @@ function emitEntry(entry) {
130
135
  return lines.join("\n");
131
136
  }
132
137
 
133
- // Append an entry atomically.
138
+ // Append an entry.
139
+ //
140
+ // Hot path: at large-corpus scale (596 leaves → 189k pairwise
141
+ // decisions observed) this is called once per decision. An earlier
142
+ // implementation read the whole file, concatenated the new entry,
143
+ // wrote to a temp, and renamed — O(file-size) per append. On a
144
+ // 45 MB decisions.yaml that's ~22 MB of avg-read per call × 189k
145
+ // calls ≈ 4 TB of I/O, which alone accounted for most of a 2h15m
146
+ // build's wall-clock time.
147
+ //
148
+ // Durability guarantees:
149
+ //
150
+ // - First call (file doesn't exist): writes header + first entry
151
+ // via temp+rename. The initial file materialises atomically —
152
+ // a crash during the first call leaves either no file or a
153
+ // well-formed single-entry file.
154
+ //
155
+ // - Subsequent calls: best-effort `appendFileSync`. Each call is
156
+ // a single `write(2)` syscall of the serialised entry. In the
157
+ // common case the kernel writes the full buffer atomically,
158
+ // but this is NOT a formal durability contract for regular
159
+ // files the way temp+rename is:
160
+ //
161
+ // * A crash mid-write can leave a torn trailing entry. On
162
+ // recovery the YAML parser will reject the truncated
163
+ // scalar; the audit log is recoverable by removing the
164
+ // last partial `- ...` block and re-running the op.
165
+ //
166
+ // * Node's `writeSync`/`appendFileSync` MAY split a large
167
+ // buffer into multiple `write(2)` calls. Typical entry
168
+ // blocks here are ~200 bytes — well under typical
169
+ // single-write thresholds — but there is no portable
170
+ // small-write atomicity guarantee for regular files
171
+ // (POSIX's PIPE_BUF atomicity applies to pipes/FIFOs, not
172
+ // disk files).
173
+ //
174
+ // * On Windows, `appendFileSync` has no equivalent of
175
+ // POSIX O_APPEND kernel serialisation under concurrent
176
+ // writers from multiple processes. This phase runs
177
+ // single-process though, so cross-process interleaving
178
+ // is not a concern in practice.
179
+ //
180
+ // The decision log is an audit trail, not a reproducibility
181
+ // artefact — lost tail bytes on a crash are annoying but
182
+ // recoverable, and the output tree's byte-reproducibility is
183
+ // independent of this file's exact contents. If stronger
184
+ // durability is needed for a specific use case, callers should
185
+ // batch-flush to a temp file and rename on phase boundaries.
186
+ //
187
+ // Cost per append: O(entry-size), not O(file-size). ~200 µs vs
188
+ // ~20 ms on a big log — a 100× speedup at scale.
134
189
  export function appendDecision(wikiRoot, entry) {
135
190
  validate(entry);
136
191
  const path = decisionLogPath(wikiRoot);
137
192
  mkdirSync(dirname(path), { recursive: true });
138
193
  const block = emitEntry(entry) + "\n";
139
- let payload;
140
194
  if (!existsSync(path)) {
141
- payload =
195
+ // First call: lay down the header atomically via temp+rename so
196
+ // a crash mid-creation doesn't leave an empty or orphan file.
197
+ const payload =
142
198
  "# skill-llm-wiki tiered-AI decision log (append-only)\n" +
143
199
  "version: 1\n" +
144
200
  "entries:\n" +
145
201
  block;
146
- } else {
147
- const existing = readFileSync(path, "utf8");
148
- const prefix = existing.endsWith("\n") ? existing : existing + "\n";
149
- payload = prefix + block;
202
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
203
+ writeFileSync(tmp, payload, "utf8");
204
+ renameSync(tmp, path);
205
+ return;
206
+ }
207
+ // Subsequent appends: O(entry-size) via POSIX append. Peek at
208
+ // the last byte first: if the existing file doesn't end in a
209
+ // newline (manual edit, prior torn-tail truncation, or a
210
+ // creative crash), appending directly would concatenate the new
211
+ // entry onto the previous line and produce invalid YAML. Prefix
212
+ // a newline in that case — a leading blank line inside the
213
+ // entries[] list is harmless and parses fine.
214
+ const needsLeadingNewline = !endsWithNewline(path);
215
+ appendFileSync(path, needsLeadingNewline ? "\n" + block : block, "utf8");
216
+ }
217
+
218
+ // Check the last byte of the decision log without reading the
219
+ // whole file. Uses a small anchored read rather than `readFileSync`
220
+ // so the hot append path still pays O(1) regardless of log size.
221
+ // An unreadable file (ENOENT, EACCES, race window) is treated as
222
+ // "already newline-terminated" so the caller doesn't double up on
223
+ // leading newlines on a transient read error.
224
+ function endsWithNewline(path) {
225
+ let fd;
226
+ try {
227
+ fd = openSync(path, "r");
228
+ const { size } = fstatSync(fd);
229
+ if (size === 0) return true; // empty file has no trailing content to collide
230
+ const buf = Buffer.alloc(1);
231
+ readSync(fd, buf, 0, 1, size - 1);
232
+ return buf[0] === 0x0a; // 0x0a == '\n'
233
+ } catch {
234
+ return true;
235
+ } finally {
236
+ if (fd !== undefined) {
237
+ try {
238
+ closeSync(fd);
239
+ } catch {
240
+ /* best-effort */
241
+ }
242
+ }
150
243
  }
151
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
152
- writeFileSync(tmp, payload, "utf8");
153
- renameSync(tmp, path);
154
244
  }
155
245
 
156
246
  // Convenience helper for cluster-NEST outcomes. The convergence
@@ -164,14 +254,18 @@ export function appendDecision(wikiRoot, entry) {
164
254
  //
165
255
  // op_id, operator="NEST" — as-is
166
256
  // sources — leaf ids in the cluster
167
- // tier_used — 2 (every NEST decision
168
- // touches Tier 2 either
169
- // via propose_structure
170
- // or nest_decision)
257
+ // tier_used — caller-supplied (default 2
258
+ // for legacy Tier-2-touching
259
+ // NEST paths; 0 under
260
+ // `--quality-mode deterministic`
261
+ // since no sub-agent is
262
+ // consulted)
171
263
  // similarity — average_affinity
172
264
  // confidence_band — one of:
173
265
  // "tier2-proposed",
266
+ // "tier2-and-math",
174
267
  // "math-gated",
268
+ // "deterministic-math",
175
269
  // "empty-partition",
176
270
  // "rejected-by-metric",
177
271
  // "rejected-by-gate"
@@ -187,16 +281,28 @@ export function appendDecision(wikiRoot, entry) {
187
281
  // Coercion: average_affinity may be undefined for Tier-2-proposed
188
282
  // clusters; we coerce to 0 so the finite-number validator does
189
283
  // not reject the entry.
284
+ //
285
+ // tier_used default: pre-deterministic-mode every NEST decision
286
+ // touched Tier 2 via propose_structure or nest_decision, so the
287
+ // default of 2 was correct. Under `--quality-mode deterministic`
288
+ // Tier 2 is never consulted for math candidates; callers on that
289
+ // path pass `tier_used: 0` so the audit trail correctly reflects
290
+ // the fact that no sub-agent was invoked. The default remains 2
291
+ // for backward compatibility with every existing call site.
190
292
  export function appendNestDecision(wikiRoot, entry) {
191
293
  const similarity =
192
294
  Number.isFinite(entry.similarity)
193
295
  ? entry.similarity
194
296
  : (Number.isFinite(entry.average_affinity) ? entry.average_affinity : 0);
297
+ const tier_used =
298
+ typeof entry.tier_used === "number" && Number.isInteger(entry.tier_used)
299
+ ? entry.tier_used
300
+ : 2;
195
301
  appendDecision(wikiRoot, {
196
302
  op_id: entry.op_id,
197
303
  operator: "NEST",
198
304
  sources: Array.isArray(entry.sources) ? entry.sources : [],
199
- tier_used: 2,
305
+ tier_used,
200
306
  similarity,
201
307
  confidence_band: entry.confidence_band ?? null,
202
308
  decision: entry.decision,
@@ -52,6 +52,11 @@ export const FINDING_ACTIONS = Object.freeze({
52
52
  "DANGLING-LINK": "fix",
53
53
  "DANGLING-OVERLAY": "fix",
54
54
 
55
+ // X.11 root-leaf containment invariant — `fix` runs Phase 4.4.5
56
+ // root-containment to move outlier leaves into per-slug
57
+ // subcategories:
58
+ "LEAF-AT-WIKI-ROOT": "fix",
59
+
55
60
  // Size cap is a warning surface only:
56
61
  "SIZE-CAP": "none",
57
62
  });
@@ -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";