@ctxr/skill-llm-wiki 1.0.1 → 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/SKILL.md +7 -0
- package/guide/cli.md +6 -4
- package/guide/consumers/index.md +106 -0
- package/guide/consumers/quickstart.md +96 -0
- package/guide/consumers/recipes/ci-gate.md +125 -0
- package/guide/consumers/recipes/dated-wiki.md +131 -0
- package/guide/consumers/recipes/format-gate.md +126 -0
- package/guide/consumers/recipes/post-write-heal.md +125 -0
- package/guide/consumers/recipes/skill-absent.md +111 -0
- package/guide/consumers/recipes/subject-wiki.md +110 -0
- package/guide/consumers/recipes/testing.md +149 -0
- package/guide/index.md +9 -0
- package/guide/substrate/operators.md +1 -1
- package/guide/substrate/tiered-ai.md +6 -5
- package/guide/ux/user-intent.md +6 -5
- package/package.json +9 -3
- package/scripts/cli.mjs +565 -15
- package/scripts/lib/balance.mjs +579 -0
- package/scripts/lib/cluster-detect.mjs +482 -4
- package/scripts/lib/contract.mjs +257 -0
- package/scripts/lib/decision-log.mjs +121 -15
- package/scripts/lib/heal.mjs +167 -0
- package/scripts/lib/init.mjs +210 -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/json-envelope.mjs +190 -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/templates.mjs +78 -0
- package/scripts/lib/tiered.mjs +42 -18
- package/scripts/lib/validate.mjs +22 -0
- package/scripts/lib/where.mjs +71 -0
- package/scripts/testkit/assert-frontmatter.mjs +171 -0
- package/scripts/testkit/cli-run.mjs +95 -0
- package/scripts/testkit/make-wiki-fixture.mjs +301 -0
- package/scripts/testkit/stub-skill.mjs +107 -0
- package/templates/adrs.llmwiki.layout.yaml +33 -0
- package/templates/plans.llmwiki.layout.yaml +34 -0
- package/templates/regressions.llmwiki.layout.yaml +34 -0
- package/templates/reports.llmwiki.layout.yaml +33 -0
- package/templates/runbooks.llmwiki.layout.yaml +33 -0
- package/templates/sessions.llmwiki.layout.yaml +34 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// init.mjs — seed a topic directory with a shipped layout contract.
|
|
2
|
+
//
|
|
3
|
+
// `skill-llm-wiki init <topic> --kind dated|subject [--template <name>] [--force]`
|
|
4
|
+
//
|
|
5
|
+
// Removes the cp + edit + build-flag dance every consumer reinvents.
|
|
6
|
+
// Seeds the contract file and returns a structured envelope telling
|
|
7
|
+
// the consumer the exact build command to run next. Auto-build is
|
|
8
|
+
// intentionally not included here: running build internally pulls in
|
|
9
|
+
// the full orchestrator error surface (Tier 2 exits, validation
|
|
10
|
+
// rollback, non-interactive refusal). A follow-up can add a --build
|
|
11
|
+
// flag once the error-mapping story is proven out.
|
|
12
|
+
//
|
|
13
|
+
// Behaviour:
|
|
14
|
+
// 1. Resolve topic path relative to cwd; create parent dirs if needed.
|
|
15
|
+
// 2. Refuse if topic exists as a file, not a directory.
|
|
16
|
+
// 3. Select template: explicit --template wins; otherwise
|
|
17
|
+
// defaultTemplateForKind.
|
|
18
|
+
// 4. Refuse if <topic>/.llmwiki.layout.yaml already exists, unless
|
|
19
|
+
// --force.
|
|
20
|
+
// 5. Copy the template's body to <topic>/.llmwiki.layout.yaml.
|
|
21
|
+
// 6. Return a structured result; the CLI wraps it in the envelope.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
lstatSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { dirname, join, resolve as pathResolve } from "node:path";
|
|
31
|
+
import {
|
|
32
|
+
defaultTemplateForKind,
|
|
33
|
+
getTemplate,
|
|
34
|
+
listTemplates,
|
|
35
|
+
} from "./templates.mjs";
|
|
36
|
+
|
|
37
|
+
const CONTRACT_FILENAME = ".llmwiki.layout.yaml";
|
|
38
|
+
|
|
39
|
+
// Walk UP from `absTopic` to the first existing ancestor and
|
|
40
|
+
// lstat it. Refuse if that ancestor is a symbolic link. This
|
|
41
|
+
// catches the attack where a pre-existing symlink on the path to
|
|
42
|
+
// absTopic would let `mkdirSync(recursive: true)` follow it and
|
|
43
|
+
// create directories outside the user's intended tree.
|
|
44
|
+
//
|
|
45
|
+
// Non-existent segments need no check — mkdir creates them fresh.
|
|
46
|
+
// Segments ABOVE the first existing ancestor are user-environment
|
|
47
|
+
// territory (including OS symlinks like macOS's /var), not our
|
|
48
|
+
// concern, and walking into them would produce false positives.
|
|
49
|
+
function refuseSymlinkOnExistingAncestor(absTopic) {
|
|
50
|
+
let cursor = absTopic;
|
|
51
|
+
while (!existsSync(cursor)) {
|
|
52
|
+
const parent = dirname(cursor);
|
|
53
|
+
if (parent === cursor) return; // reached filesystem root, no anchor
|
|
54
|
+
cursor = parent;
|
|
55
|
+
}
|
|
56
|
+
const st = lstatSync(cursor);
|
|
57
|
+
if (st.isSymbolicLink()) {
|
|
58
|
+
throw new InitError(
|
|
59
|
+
"INIT-08",
|
|
60
|
+
`init: ${cursor} is a symbolic link on the path to ${absTopic}; refusing to write through it. Remove or resolve the symlink explicitly before initialising.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class InitError extends Error {
|
|
66
|
+
constructor(code, message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function runInit({
|
|
73
|
+
topic,
|
|
74
|
+
kind = null,
|
|
75
|
+
template = null,
|
|
76
|
+
force = false,
|
|
77
|
+
cwd = process.cwd(),
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!topic || typeof topic !== "string") {
|
|
80
|
+
throw new InitError("INIT-01", "init requires a <topic> path");
|
|
81
|
+
}
|
|
82
|
+
const absTopic = pathResolve(cwd, topic);
|
|
83
|
+
|
|
84
|
+
// Pick the template.
|
|
85
|
+
let templateName = template;
|
|
86
|
+
if (!templateName) {
|
|
87
|
+
if (!kind) {
|
|
88
|
+
throw new InitError(
|
|
89
|
+
"INIT-02",
|
|
90
|
+
"init requires either --template <name> or --kind <dated|subject>",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
templateName = defaultTemplateForKind(kind);
|
|
94
|
+
if (!templateName) {
|
|
95
|
+
throw new InitError(
|
|
96
|
+
"INIT-03",
|
|
97
|
+
`init: unknown --kind "${kind}". Accepted: dated, subject.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const tmpl = getTemplate(templateName);
|
|
102
|
+
if (!tmpl) {
|
|
103
|
+
const available = Object.keys(listTemplates()).join(", ");
|
|
104
|
+
throw new InitError(
|
|
105
|
+
"INIT-04",
|
|
106
|
+
`init: template "${templateName}" not found. Available: ${available}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
// If the caller supplied both --kind and --template, check that
|
|
110
|
+
// they're compatible so we catch "`--kind dated --template runbooks`"
|
|
111
|
+
// before the consumer is surprised by a dated mis-shape.
|
|
112
|
+
if (kind && tmpl.kind !== "unknown" && tmpl.kind !== kind) {
|
|
113
|
+
throw new InitError(
|
|
114
|
+
"INIT-05",
|
|
115
|
+
`init: template "${templateName}" is kind=${tmpl.kind}, but --kind ${kind} was requested.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Refuse to write through a pre-existing symlink in the topic
|
|
120
|
+
// path. Two shapes to catch:
|
|
121
|
+
// (a) absTopic itself is a symbolic link — covered by the
|
|
122
|
+
// existing-path branch below.
|
|
123
|
+
// (b) an intermediate segment on the way to absTopic is a
|
|
124
|
+
// symlink (e.g. `<parent>/sub -> /etc/` before init runs).
|
|
125
|
+
// `mkdirSync(recursive: true)` would follow it and create
|
|
126
|
+
// directories outside the user's intended topic tree.
|
|
127
|
+
//
|
|
128
|
+
// Algorithm: walk UP from absTopic until we find the first
|
|
129
|
+
// existing ancestor (the "anchor"), then lstat it. If the
|
|
130
|
+
// anchor is a symlink, refuse. Segments that do NOT yet exist
|
|
131
|
+
// are safe — mkdir creates them fresh. We deliberately do NOT
|
|
132
|
+
// walk past the anchor upward: OS-level symlinks above the
|
|
133
|
+
// user-chosen path (macOS's /var → /private/var on tmpdirs)
|
|
134
|
+
// would false-positive and block every test.
|
|
135
|
+
refuseSymlinkOnExistingAncestor(absTopic);
|
|
136
|
+
|
|
137
|
+
if (existsSync(absTopic)) {
|
|
138
|
+
const st = lstatSync(absTopic);
|
|
139
|
+
if (!st.isDirectory()) {
|
|
140
|
+
throw new InitError(
|
|
141
|
+
"INIT-06",
|
|
142
|
+
`init: ${absTopic} exists but is not a directory.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
mkdirSync(absTopic, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const contractPath = join(absTopic, CONTRACT_FILENAME);
|
|
150
|
+
// Same symlink guard for the contract file itself. An attacker
|
|
151
|
+
// who controls the topic directory could plant a symlink at
|
|
152
|
+
// <topic>/.llmwiki.layout.yaml pointing anywhere; without lstat
|
|
153
|
+
// we'd follow it on writeFileSync.
|
|
154
|
+
if (existsSync(contractPath)) {
|
|
155
|
+
const cst = lstatSync(contractPath);
|
|
156
|
+
if (cst.isSymbolicLink()) {
|
|
157
|
+
throw new InitError(
|
|
158
|
+
"INIT-08",
|
|
159
|
+
`init: ${contractPath} is a symbolic link; refusing to overwrite through it.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const alreadyPresent = existsSync(contractPath);
|
|
164
|
+
if (alreadyPresent && !force) {
|
|
165
|
+
throw new InitError(
|
|
166
|
+
"INIT-07",
|
|
167
|
+
`init: ${CONTRACT_FILENAME} already exists at ${absTopic}. Pass --force to overwrite, or use \`skill-llm-wiki rebuild\` to reconcile against the existing contract.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const body = readFileSync(tmpl.path, "utf8");
|
|
172
|
+
writeFileSync(contractPath, body, "utf8");
|
|
173
|
+
|
|
174
|
+
// The next step for the consumer. Passed back so the CLI can
|
|
175
|
+
// include it in the envelope both as a structured `next` field
|
|
176
|
+
// and as a human-readable NEXT-01 info diagnostic.
|
|
177
|
+
const buildCommand = [
|
|
178
|
+
"skill-llm-wiki",
|
|
179
|
+
"build",
|
|
180
|
+
absTopic,
|
|
181
|
+
"--layout-mode",
|
|
182
|
+
"hosted",
|
|
183
|
+
"--target",
|
|
184
|
+
absTopic,
|
|
185
|
+
"--json",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
topic: absTopic,
|
|
190
|
+
template: templateName,
|
|
191
|
+
kind: tmpl.kind,
|
|
192
|
+
contract_path: contractPath,
|
|
193
|
+
overwrote: alreadyPresent,
|
|
194
|
+
build_command: buildCommand,
|
|
195
|
+
next: { command: buildCommand[0], args: buildCommand.slice(1) },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Human-readable rendering of a runInit result. Lives here (not in
|
|
200
|
+
// cli.mjs) so the text and JSON output of init stay under the same
|
|
201
|
+
// roof and cannot drift. Mirrors the renderContractText /
|
|
202
|
+
// renderWhereText pattern.
|
|
203
|
+
export function renderInitText(result) {
|
|
204
|
+
return (
|
|
205
|
+
`init: seeded ${result.contract_path}\n` +
|
|
206
|
+
` template: ${result.template} (kind=${result.kind})\n` +
|
|
207
|
+
(result.overwrote ? ` overwrote existing contract\n` : "") +
|
|
208
|
+
` next: ${result.build_command.join(" ")}\n`
|
|
209
|
+
);
|
|
210
|
+
}
|
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";
|