@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.
- package/CHANGELOG.md +118 -0
- package/README.md +2 -2
- package/guide/cli.md +3 -2
- package/guide/substrate/operators.md +1 -1
- package/guide/substrate/tiered-ai.md +6 -5
- package/guide/ux/user-intent.md +1 -1
- package/package.json +4 -2
- package/scripts/cli.mjs +92 -2
- package/scripts/lib/balance.mjs +579 -0
- package/scripts/lib/cluster-detect.mjs +482 -4
- package/scripts/lib/contract.mjs +31 -3
- package/scripts/lib/decision-log.mjs +121 -15
- package/scripts/lib/heal.mjs +5 -0
- package/scripts/lib/intent.mjs +370 -4
- package/scripts/lib/join-constants.mjs +22 -0
- package/scripts/lib/join.mjs +917 -0
- package/scripts/lib/nest-applier.mjs +395 -32
- package/scripts/lib/operators.mjs +472 -38
- package/scripts/lib/orchestrator.mjs +419 -12
- package/scripts/lib/root-containment.mjs +351 -0
- package/scripts/lib/similarity-cache.mjs +115 -20
- package/scripts/lib/similarity.mjs +11 -0
- package/scripts/lib/soft-dag.mjs +726 -0
- package/scripts/lib/tiered.mjs +42 -18
- package/scripts/lib/validate.mjs +22 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 —
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
|
305
|
+
tier_used,
|
|
200
306
|
similarity,
|
|
201
307
|
confidence_band: entry.confidence_band ?? null,
|
|
202
308
|
decision: entry.decision,
|
package/scripts/lib/heal.mjs
CHANGED
|
@@ -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
|
});
|
package/scripts/lib/intent.mjs
CHANGED
|
@@ -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
|
|
68
|
-
//
|
|
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
|
-
"
|
|
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";
|