@ctxr/skill-llm-wiki 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- package/scripts/lib/validate.mjs +362 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
// intent.mjs — resolve a CLI invocation into a concrete operation plan,
|
|
2
|
+
// or return a structured ambiguity error if the user's intent cannot be
|
|
3
|
+
// determined without guessing. This module is the sole enforcement point
|
|
4
|
+
// of the "ask, don't guess" rule from methodology section 9.4.3.
|
|
5
|
+
//
|
|
6
|
+
// Callers (cli.mjs) pass the raw argv tail plus a context object:
|
|
7
|
+
// resolveIntent({ subcommand, args, flags, cwd })
|
|
8
|
+
// Return shape:
|
|
9
|
+
// { status: "ok", plan } — go ahead and execute `plan`
|
|
10
|
+
// { status: "ambiguous", error } — print the structured error and exit 2
|
|
11
|
+
//
|
|
12
|
+
// An ambiguity `error` carries:
|
|
13
|
+
// code a stable machine-readable identifier
|
|
14
|
+
// message one-line human-readable summary
|
|
15
|
+
// options[] numbered interpretations with the resolving_flag for each
|
|
16
|
+
// resolving_flag the single flag / env var / action that would remove the
|
|
17
|
+
// ambiguity by making the user's intent explicit
|
|
18
|
+
//
|
|
19
|
+
// Scenarios refused (see methodology §9.4.3):
|
|
20
|
+
// INT-01 default sibling name collides with a foreign directory
|
|
21
|
+
// INT-01b explicit --target points at a foreign non-empty directory
|
|
22
|
+
// INT-02 source is already a managed wiki (implicit in-place)
|
|
23
|
+
// INT-03 target wiki exists but no subcommand specified
|
|
24
|
+
// INT-04 legacy `.llmwiki.v<N>` folder detected
|
|
25
|
+
// INT-05 rollback invoked without --to
|
|
26
|
+
// INT-06 source is a bare file, not a directory
|
|
27
|
+
// INT-07 multi-source build/extend without explicit canonical
|
|
28
|
+
// INT-08 source is inside a user git repo with a dirty working tree
|
|
29
|
+
// INT-09a --layout-mode in-place combined with --target
|
|
30
|
+
// INT-09b --layout-mode hosted invoked without --target
|
|
31
|
+
// INT-10 unknown --layout-mode value
|
|
32
|
+
// INT-11 unknown CLI flag (emitted from cli.mjs's parseSubArgv)
|
|
33
|
+
// INT-12 ambiguity reached interactive resolution in a non-TTY
|
|
34
|
+
// context (emitted by cli.mjs when NonInteractiveError fires)
|
|
35
|
+
// INT-13 unknown --quality-mode value
|
|
36
|
+
//
|
|
37
|
+
// Plan shape (status === "ok"):
|
|
38
|
+
// {
|
|
39
|
+
// operation: "build" | "extend" | "rebuild" | "fix" | "join" | "rollback"
|
|
40
|
+
// layout_mode: "sibling" | "in-place" | "hosted"
|
|
41
|
+
// source: absolute path | null (the corpus to read)
|
|
42
|
+
// target: absolute path (where the wiki lives)
|
|
43
|
+
// is_new_wiki: boolean (true ⇒ create from scratch)
|
|
44
|
+
// flags: { accept_dirty, no_prompt, json_errors }
|
|
45
|
+
// }
|
|
46
|
+
//
|
|
47
|
+
// This module is pure — no I/O beyond filesystem probes. No git calls, no
|
|
48
|
+
// prompts, no network. Prompts and migration actions are orchestrated by
|
|
49
|
+
// the caller based on the returned structured error.
|
|
50
|
+
|
|
51
|
+
import { spawnSync } from "node:child_process";
|
|
52
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
53
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
54
|
+
import {
|
|
55
|
+
defaultSiblingPath,
|
|
56
|
+
hasPrivateGit,
|
|
57
|
+
isLegacyVersionedWiki,
|
|
58
|
+
} from "./paths.mjs";
|
|
59
|
+
|
|
60
|
+
export const VALID_LAYOUT_MODES = Object.freeze(["sibling", "in-place", "hosted"]);
|
|
61
|
+
|
|
62
|
+
// Tiered-AI quality modes accepted by --quality-mode. Duplicated
|
|
63
|
+
// here (rather than imported from tiered.mjs) so the intent layer
|
|
64
|
+
// rejects typos BEFORE the orchestrator runs, avoiding expensive
|
|
65
|
+
// rollbacks on a trivial flag error. Must stay in sync with
|
|
66
|
+
// tiered.mjs:QUALITY_MODES — the unit test
|
|
67
|
+
// tests/unit/intent-resolve.test.mjs:valid-quality-modes verifies
|
|
68
|
+
// this.
|
|
69
|
+
export const VALID_QUALITY_MODES = Object.freeze([
|
|
70
|
+
"tiered-fast",
|
|
71
|
+
"claude-first",
|
|
72
|
+
"tier0-only",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export function ok(plan) {
|
|
76
|
+
return { status: "ok", plan };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ambiguous(code, message, options, resolving_flag) {
|
|
80
|
+
return {
|
|
81
|
+
status: "ambiguous",
|
|
82
|
+
error: { code, message, options, resolving_flag },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function absolute(cwd, p) {
|
|
87
|
+
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isDir(p) {
|
|
91
|
+
try {
|
|
92
|
+
return statSync(p).isDirectory();
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isFile(p) {
|
|
99
|
+
try {
|
|
100
|
+
return statSync(p).isFile();
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// True when a managed wiki target carries an in-progress build that
|
|
107
|
+
// the exit-7 handshake parked. The signal is conservative on purpose:
|
|
108
|
+
// either there is at least one `pending-*.json` waiting in
|
|
109
|
+
// `<target>/.work/tier2/`, or the work area carries a `build-*` op
|
|
110
|
+
// folder with no matching final tag in the op-log. Both shapes are
|
|
111
|
+
// produced exclusively by a build that exited 7 (or crashed mid-way)
|
|
112
|
+
// and there is no other production code path that creates them, so
|
|
113
|
+
// allowing INT-03 to short-circuit when this returns true cannot
|
|
114
|
+
// silently overwrite a healthy wiki.
|
|
115
|
+
//
|
|
116
|
+
// Pure filesystem probe — no git calls.
|
|
117
|
+
function hasIncompleteBuild(targetDir) {
|
|
118
|
+
if (!isDir(targetDir)) return false;
|
|
119
|
+
// Signal A: pending Tier 2 batches waiting to be re-fed.
|
|
120
|
+
const tier2Dir = join(targetDir, ".work", "tier2");
|
|
121
|
+
if (isDir(tier2Dir)) {
|
|
122
|
+
try {
|
|
123
|
+
for (const name of readdirSync(tier2Dir)) {
|
|
124
|
+
if (name.startsWith("pending-") && name.endsWith(".json")) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* fall through to signal B */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Signal B: a `build-*` workdir exists with `candidates.json` but
|
|
133
|
+
// its op-id has no entry in the op-log (i.e. commit-finalize never
|
|
134
|
+
// ran). Read the op-log via a string substring match so we do not
|
|
135
|
+
// pull in the YAML parser at intent time — this module is meant to
|
|
136
|
+
// stay free of orchestrator/library imports.
|
|
137
|
+
const workRoot = join(targetDir, ".work");
|
|
138
|
+
if (!isDir(workRoot)) return false;
|
|
139
|
+
let workEntries;
|
|
140
|
+
try {
|
|
141
|
+
workEntries = readdirSync(workRoot, { withFileTypes: true });
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
let opLogText = "";
|
|
146
|
+
try {
|
|
147
|
+
opLogText = readFileSync(join(targetDir, ".llmwiki", "op-log.yaml"), "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
opLogText = "";
|
|
150
|
+
}
|
|
151
|
+
for (const entry of workEntries) {
|
|
152
|
+
if (!entry.isDirectory()) continue;
|
|
153
|
+
if (!entry.name.startsWith("build-")) continue;
|
|
154
|
+
const candidatesPath = join(workRoot, entry.name, "candidates.json");
|
|
155
|
+
if (!isFile(candidatesPath)) continue;
|
|
156
|
+
// If the op-log mentions this op-id, the build finalised — not a
|
|
157
|
+
// resume candidate. The op-log emitter (history.mjs) writes the
|
|
158
|
+
// op-id unquoted unless it would round-trip ambiguously, but the
|
|
159
|
+
// hyphen-separated form we generate never trips needsQuoting; for
|
|
160
|
+
// safety we check both quoted and unquoted forms with a
|
|
161
|
+
// surrounding-character anchor so a substring of a longer op-id
|
|
162
|
+
// does not accidentally match.
|
|
163
|
+
const idLine = `op_id: ${entry.name}`;
|
|
164
|
+
const idLineQuoted = `op_id: "${entry.name}"`;
|
|
165
|
+
if (!opLogText.includes(idLine) && !opLogText.includes(idLineQuoted)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// True if a directory is foreign-to-us: it exists, is non-empty, and has
|
|
173
|
+
// no `.llmwiki/git/HEAD` marker identifying it as a skill-managed wiki.
|
|
174
|
+
function isForeignNonEmptyDir(p) {
|
|
175
|
+
if (!isDir(p)) return false;
|
|
176
|
+
if (hasPrivateGit(p)) return false;
|
|
177
|
+
try {
|
|
178
|
+
return readdirSync(p).length > 0;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Walk `startPath` upward until we find a `.git` directory or hit the
|
|
185
|
+
// filesystem root. Returns the repo root (the directory containing
|
|
186
|
+
// `.git/`) or null when the path is not inside any git repository. This
|
|
187
|
+
// is a READ-ONLY probe — we never spawn git through the isolated env
|
|
188
|
+
// block because we are intentionally inspecting the user's own repo,
|
|
189
|
+
// not our private one.
|
|
190
|
+
function findEnclosingUserRepo(startPath) {
|
|
191
|
+
let cur = startPath;
|
|
192
|
+
while (true) {
|
|
193
|
+
if (isDir(resolve(cur, ".git"))) return cur;
|
|
194
|
+
const parent = dirname(cur);
|
|
195
|
+
if (parent === cur) return null;
|
|
196
|
+
cur = parent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Run `git status --porcelain` against the user's own repo (no isolation
|
|
201
|
+
// env — this is the user's repo, not ours) and return an array of dirty
|
|
202
|
+
// path entries. Empty array ⇒ clean working tree. Returns null if the
|
|
203
|
+
// probe cannot run (git missing, permission denied, etc.) so callers
|
|
204
|
+
// can fall through rather than falsely flag.
|
|
205
|
+
function userRepoDirtyPaths(repoPath) {
|
|
206
|
+
const r = spawnSync("git", ["status", "--porcelain"], {
|
|
207
|
+
cwd: repoPath,
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
env: {
|
|
210
|
+
...process.env,
|
|
211
|
+
// Narrow the env so our own GIT_DIR (if set from an outer call)
|
|
212
|
+
// cannot hijack this probe into the skill's private repo.
|
|
213
|
+
GIT_DIR: undefined,
|
|
214
|
+
GIT_WORK_TREE: undefined,
|
|
215
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
if (r.error || r.status !== 0) return null;
|
|
219
|
+
return (r.stdout || "")
|
|
220
|
+
.split(/\r?\n/)
|
|
221
|
+
.map((l) => l.trim())
|
|
222
|
+
.filter((l) => l.length > 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Resolve the canonical operation plan or return an ambiguity error.
|
|
226
|
+
//
|
|
227
|
+
// ctx = {
|
|
228
|
+
// subcommand: "build" | "extend" | ...
|
|
229
|
+
// args: string[] — positional args after the subcommand
|
|
230
|
+
// flags: {
|
|
231
|
+
// layout_mode?: string,
|
|
232
|
+
// target?: string,
|
|
233
|
+
// no_prompt?: boolean,
|
|
234
|
+
// json_errors?: boolean,
|
|
235
|
+
// accept_dirty?: boolean,
|
|
236
|
+
// accept_foreign_target?: boolean,
|
|
237
|
+
// to?: string, // for rollback
|
|
238
|
+
// }
|
|
239
|
+
// cwd: string — process working directory
|
|
240
|
+
// }
|
|
241
|
+
export function resolveIntent(ctx) {
|
|
242
|
+
const { subcommand, args, flags, cwd } = ctx;
|
|
243
|
+
const f = flags || {};
|
|
244
|
+
|
|
245
|
+
// ─── Global flag validation ──────────────────────────────────────────
|
|
246
|
+
if (f.layout_mode && !VALID_LAYOUT_MODES.includes(f.layout_mode)) {
|
|
247
|
+
return ambiguous(
|
|
248
|
+
"INT-10",
|
|
249
|
+
`unknown --layout-mode value "${f.layout_mode}"`,
|
|
250
|
+
VALID_LAYOUT_MODES.map((m) => ({
|
|
251
|
+
description: `use ${m} mode`,
|
|
252
|
+
flag: `--layout-mode ${m}`,
|
|
253
|
+
})),
|
|
254
|
+
"--layout-mode",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (f.quality_mode && !VALID_QUALITY_MODES.includes(f.quality_mode)) {
|
|
258
|
+
return ambiguous(
|
|
259
|
+
"INT-13",
|
|
260
|
+
`unknown --quality-mode value "${f.quality_mode}"`,
|
|
261
|
+
VALID_QUALITY_MODES.map((m) => ({
|
|
262
|
+
description: `use ${m} quality mode`,
|
|
263
|
+
flag: `--quality-mode ${m}`,
|
|
264
|
+
})),
|
|
265
|
+
"--quality-mode",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (f.layout_mode === "in-place" && f.target) {
|
|
269
|
+
return ambiguous(
|
|
270
|
+
"INT-09a",
|
|
271
|
+
"--layout-mode in-place cannot be combined with --target",
|
|
272
|
+
[
|
|
273
|
+
{
|
|
274
|
+
description: "transform the source folder itself in place",
|
|
275
|
+
flag: "--layout-mode in-place (drop --target)",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
description: "write to a separate target directory",
|
|
279
|
+
flag: "--target <path> (drop --layout-mode in-place)",
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
"drop one of --layout-mode in-place or --target",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Rollback: --to is mandatory ─────────────────────────────────────
|
|
287
|
+
if (subcommand === "rollback") {
|
|
288
|
+
if (args.length === 0) {
|
|
289
|
+
return ambiguous(
|
|
290
|
+
"INT-05",
|
|
291
|
+
"rollback requires a wiki path",
|
|
292
|
+
[{ description: "specify the wiki", flag: "rollback <wiki-path> --to <ref>" }],
|
|
293
|
+
"positional wiki path",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (!f.to) {
|
|
297
|
+
return ambiguous(
|
|
298
|
+
"INT-05",
|
|
299
|
+
"rollback invoked without --to <ref>",
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
description: "roll back to the state just before a specific op",
|
|
303
|
+
flag: "--to pre-<op-id>",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
description: "roll back to the state just after a specific op",
|
|
307
|
+
flag: "--to <op-id>",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
description: "roll back to the wiki's first tracked state",
|
|
311
|
+
flag: "--to genesis",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
"--to",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return ok({
|
|
318
|
+
operation: "rollback",
|
|
319
|
+
layout_mode: null,
|
|
320
|
+
source: null,
|
|
321
|
+
target: absolute(cwd, args[0]),
|
|
322
|
+
is_new_wiki: false,
|
|
323
|
+
flags: f,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Rebuild / fix: the positional IS the wiki, not a source ──────
|
|
328
|
+
// These operations read frontmatter from an existing wiki and write
|
|
329
|
+
// back in place. There is no separate source — the wiki is both.
|
|
330
|
+
if (subcommand === "rebuild" || subcommand === "fix") {
|
|
331
|
+
if (args.length !== 1) {
|
|
332
|
+
return ambiguous(
|
|
333
|
+
"INT-06",
|
|
334
|
+
`${subcommand} requires exactly one <wiki-path> positional`,
|
|
335
|
+
[
|
|
336
|
+
{
|
|
337
|
+
description: "specify the wiki to operate on",
|
|
338
|
+
flag: `${subcommand} <wiki-path>`,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
"positional wiki path",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const wikiPath = absolute(cwd, args[0]);
|
|
345
|
+
if (!existsSync(wikiPath)) {
|
|
346
|
+
return ambiguous(
|
|
347
|
+
"INT-06",
|
|
348
|
+
`${subcommand}: wiki path ${wikiPath} does not exist`,
|
|
349
|
+
[
|
|
350
|
+
{
|
|
351
|
+
description: "point at an existing skill-managed wiki",
|
|
352
|
+
flag: `${subcommand} <existing-wiki-path>`,
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
"positional wiki path must exist",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (!hasPrivateGit(wikiPath)) {
|
|
359
|
+
return ambiguous(
|
|
360
|
+
"INT-06",
|
|
361
|
+
`${subcommand}: ${wikiPath} is not a skill-managed wiki (no .llmwiki/git)`,
|
|
362
|
+
[
|
|
363
|
+
{
|
|
364
|
+
description: "build the wiki first",
|
|
365
|
+
flag: `build ${args[0]} --layout-mode in-place`,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
description: "point at an existing wiki",
|
|
369
|
+
flag: `${subcommand} <path/with/.llmwiki>`,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
"target must be a managed wiki",
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return ok({
|
|
376
|
+
operation: subcommand,
|
|
377
|
+
layout_mode: "in-place",
|
|
378
|
+
source: wikiPath,
|
|
379
|
+
target: wikiPath,
|
|
380
|
+
is_new_wiki: false,
|
|
381
|
+
flags: f,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Multi-source check (INT-07) ────────────────────────────────────
|
|
386
|
+
if (args.length > 1 && (subcommand === "build" || subcommand === "extend")) {
|
|
387
|
+
return ambiguous(
|
|
388
|
+
"INT-07",
|
|
389
|
+
`${subcommand} received ${args.length} positional arguments; ` +
|
|
390
|
+
"the canonical source determines the sibling location",
|
|
391
|
+
args.map((_, i) => ({
|
|
392
|
+
description: `treat ${args[i]} as canonical; merge the rest`,
|
|
393
|
+
flag: `--canonical ${args[i]}`,
|
|
394
|
+
})),
|
|
395
|
+
"--canonical <path>",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Operations that take exactly one positional path ───────────────
|
|
400
|
+
if (args.length === 0) {
|
|
401
|
+
return ambiguous(
|
|
402
|
+
"INT-06",
|
|
403
|
+
`${subcommand} requires a path to a source folder or existing wiki`,
|
|
404
|
+
[
|
|
405
|
+
{
|
|
406
|
+
description: "provide the source folder to build from",
|
|
407
|
+
flag: `${subcommand} <source-path>`,
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
"positional source path",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const rawInput = args[0];
|
|
415
|
+
const input = absolute(cwd, rawInput);
|
|
416
|
+
|
|
417
|
+
// Source must be a directory (or a not-yet-existing path for build target).
|
|
418
|
+
if (existsSync(input) && !isDir(input)) {
|
|
419
|
+
if (isFile(input)) {
|
|
420
|
+
return ambiguous(
|
|
421
|
+
"INT-06",
|
|
422
|
+
`${input} is a file, not a directory`,
|
|
423
|
+
[
|
|
424
|
+
{
|
|
425
|
+
description:
|
|
426
|
+
"treat the file as a one-entry wiki by wrapping it in a folder",
|
|
427
|
+
flag: `mkdir <folder> && mv ${rawInput} <folder>/ && ${subcommand} <folder>`,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
description: "point at the containing folder",
|
|
431
|
+
flag: `${subcommand} ${dirname(rawInput)}`,
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
"positional source path must be a directory",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Legacy wiki detection (INT-04) ─────────────────────────────────
|
|
440
|
+
if (isLegacyVersionedWiki(input) && isDir(input)) {
|
|
441
|
+
return ambiguous(
|
|
442
|
+
"INT-04",
|
|
443
|
+
`${input} uses the legacy .llmwiki.v<N> layout; migrate before operating on it`,
|
|
444
|
+
[
|
|
445
|
+
{
|
|
446
|
+
description: "migrate to the new sibling layout <source>.wiki/",
|
|
447
|
+
flag: `migrate ${rawInput}`,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
description: "keep using the legacy folder (not supported)",
|
|
451
|
+
flag: "(not supported — migrate first)",
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
"migrate",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── Implicit in-place detection (INT-02) ───────────────────────────
|
|
459
|
+
// The user pointed at a folder that is already a managed wiki. Unless
|
|
460
|
+
// they explicitly asked for a mode that makes sense (rebuild, fix,
|
|
461
|
+
// extend, in-place build), refuse.
|
|
462
|
+
const inputIsWiki = existsSync(input) && hasPrivateGit(input);
|
|
463
|
+
if (inputIsWiki) {
|
|
464
|
+
if (subcommand === "build" && !f.layout_mode) {
|
|
465
|
+
return ambiguous(
|
|
466
|
+
"INT-02",
|
|
467
|
+
`${input} is already a skill-managed wiki`,
|
|
468
|
+
[
|
|
469
|
+
{
|
|
470
|
+
description: "update it with new entries from another source",
|
|
471
|
+
flag: `extend ${rawInput} <new-source>`,
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
description: "rebuild in place (optimise structure)",
|
|
475
|
+
flag: `rebuild ${rawInput}`,
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
description: "repair methodology divergences",
|
|
479
|
+
flag: `fix ${rawInput}`,
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
description:
|
|
483
|
+
"build a fresh wiki at a different location from the same source",
|
|
484
|
+
flag: `build ${rawInput} --target <new-path>`,
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
"pick a non-build subcommand or --target",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── Determine layout_mode and target ────────────────────────────────
|
|
493
|
+
const explicitMode = f.layout_mode || null;
|
|
494
|
+
const explicitTarget = f.target ? absolute(cwd, f.target) : null;
|
|
495
|
+
|
|
496
|
+
// Build on an existing wiki via explicit in-place is allowed.
|
|
497
|
+
if (subcommand === "build" && explicitMode === "in-place") {
|
|
498
|
+
return ok({
|
|
499
|
+
operation: "build",
|
|
500
|
+
layout_mode: "in-place",
|
|
501
|
+
source: input,
|
|
502
|
+
target: input,
|
|
503
|
+
is_new_wiki: !inputIsWiki,
|
|
504
|
+
flags: f,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Hosted mode requires --target.
|
|
509
|
+
if (explicitMode === "hosted") {
|
|
510
|
+
if (!explicitTarget) {
|
|
511
|
+
return ambiguous(
|
|
512
|
+
"INT-09b",
|
|
513
|
+
"--layout-mode hosted requires --target <path>",
|
|
514
|
+
[
|
|
515
|
+
{
|
|
516
|
+
description: "specify where the hosted wiki lives",
|
|
517
|
+
flag: "--target <path>",
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
"--target",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
// INT-01b: explicit hosted target that is foreign and non-empty is
|
|
524
|
+
// refused unless --accept-foreign-target is set. Hosted mode is
|
|
525
|
+
// supposed to respect a pre-existing layout contract — if the user
|
|
526
|
+
// points us at a random non-empty directory, we want them to make
|
|
527
|
+
// the intent explicit.
|
|
528
|
+
if (
|
|
529
|
+
isForeignNonEmptyDir(explicitTarget) &&
|
|
530
|
+
!existsSync(resolve(explicitTarget, ".llmwiki.layout.yaml")) &&
|
|
531
|
+
!f.accept_foreign_target
|
|
532
|
+
) {
|
|
533
|
+
return ambiguous(
|
|
534
|
+
"INT-01b",
|
|
535
|
+
`--target ${explicitTarget} is a non-empty directory with no layout contract`,
|
|
536
|
+
[
|
|
537
|
+
{
|
|
538
|
+
description: "create or supply a .llmwiki.layout.yaml first",
|
|
539
|
+
flag: "<author the contract at the target>",
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
description: "pick a different target",
|
|
543
|
+
flag: "--target <other-path>",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
description: "accept the foreign target and write into it",
|
|
547
|
+
flag: "--accept-foreign-target",
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
"--accept-foreign-target or --target",
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return ok({
|
|
554
|
+
operation: subcommand,
|
|
555
|
+
layout_mode: "hosted",
|
|
556
|
+
source: input,
|
|
557
|
+
target: explicitTarget,
|
|
558
|
+
is_new_wiki: !existsSync(explicitTarget) || !hasPrivateGit(explicitTarget),
|
|
559
|
+
flags: f,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Default: sibling mode. Target = <source>.wiki or explicit --target.
|
|
564
|
+
const target = explicitTarget || defaultSiblingPath(input);
|
|
565
|
+
|
|
566
|
+
// INT-01: default sibling exists but is not ours.
|
|
567
|
+
if (!explicitTarget && isForeignNonEmptyDir(target)) {
|
|
568
|
+
return ambiguous(
|
|
569
|
+
"INT-01",
|
|
570
|
+
`default sibling target ${target} already exists and is not a skill-managed wiki`,
|
|
571
|
+
[
|
|
572
|
+
{
|
|
573
|
+
description: "write to a different target path",
|
|
574
|
+
flag: `--target <other-path>`,
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
description: "transform the source folder itself",
|
|
578
|
+
flag: "--layout-mode in-place",
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
description: "remove the existing folder (destructive)",
|
|
582
|
+
flag: `rm -rf ${target} && ${subcommand} ${rawInput}`,
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
"--target or --layout-mode in-place",
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// INT-01b: explicit --target points at a non-empty foreign directory.
|
|
590
|
+
// Same protection as the implicit-default path; the user could easily
|
|
591
|
+
// have typed a typo and we do not want to start writing inside an
|
|
592
|
+
// unrelated folder. Escape hatch: --accept-foreign-target.
|
|
593
|
+
if (
|
|
594
|
+
explicitTarget &&
|
|
595
|
+
isForeignNonEmptyDir(explicitTarget) &&
|
|
596
|
+
!f.accept_foreign_target
|
|
597
|
+
) {
|
|
598
|
+
return ambiguous(
|
|
599
|
+
"INT-01b",
|
|
600
|
+
`--target ${explicitTarget} is a non-empty directory not managed by this skill`,
|
|
601
|
+
[
|
|
602
|
+
{
|
|
603
|
+
description: "pick a different target",
|
|
604
|
+
flag: "--target <other-path>",
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
description: "accept the foreign target and write into it",
|
|
608
|
+
flag: "--accept-foreign-target",
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
description: "remove the existing folder (destructive)",
|
|
612
|
+
flag: `rm -rf ${explicitTarget} && ${subcommand} ${rawInput} --target ${explicitTarget}`,
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
"--accept-foreign-target or --target",
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// INT-08: source is inside a user git repository with dirty working
|
|
620
|
+
// tree. Refuse unless the user passes --accept-dirty. We probe the
|
|
621
|
+
// user's own .git (not our private one) read-only. Fail-open on probe
|
|
622
|
+
// failure: if we cannot determine dirtiness, we proceed (the rest of
|
|
623
|
+
// the envelope still protects the user's repo).
|
|
624
|
+
if (!f.accept_dirty) {
|
|
625
|
+
const userRepo = findEnclosingUserRepo(input);
|
|
626
|
+
if (userRepo) {
|
|
627
|
+
const dirty = userRepoDirtyPaths(userRepo);
|
|
628
|
+
if (dirty && dirty.length > 0) {
|
|
629
|
+
return ambiguous(
|
|
630
|
+
"INT-08",
|
|
631
|
+
`${userRepo} is a git repository with uncommitted changes; refusing to operate on a dirty working tree`,
|
|
632
|
+
[
|
|
633
|
+
{
|
|
634
|
+
description: "commit or stash the changes first",
|
|
635
|
+
flag: "<git commit> or <git stash>",
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
description: "proceed anyway — you take the risk",
|
|
639
|
+
flag: "--accept-dirty",
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
"--accept-dirty or clean the working tree",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// INT-03: target exists, is ours, but subcommand is `build`.
|
|
649
|
+
//
|
|
650
|
+
// Resume escape hatch: if the target carries an in-progress build
|
|
651
|
+
// (pending Tier 2 batches and/or a `build-*` workdir whose op-id
|
|
652
|
+
// never appeared in the op-log), the user is RESUMING — typically
|
|
653
|
+
// after the wiki-runner wrote responses for an exit-7 batch and
|
|
654
|
+
// re-invoked us. Treat this as a normal sibling build so the
|
|
655
|
+
// orchestrator's idempotent ingest path takes over. We only allow
|
|
656
|
+
// the bypass when the target shows a build-shaped incomplete state;
|
|
657
|
+
// a healthy completed wiki still trips INT-03.
|
|
658
|
+
if (
|
|
659
|
+
!explicitTarget &&
|
|
660
|
+
subcommand === "build" &&
|
|
661
|
+
existsSync(target) &&
|
|
662
|
+
hasPrivateGit(target) &&
|
|
663
|
+
!f.layout_mode
|
|
664
|
+
) {
|
|
665
|
+
if (!hasIncompleteBuild(target)) {
|
|
666
|
+
return ambiguous(
|
|
667
|
+
"INT-03",
|
|
668
|
+
`${target} is already a managed wiki; choose an operation`,
|
|
669
|
+
[
|
|
670
|
+
{ description: "add new entries from the source", flag: `extend ${rawInput}` },
|
|
671
|
+
{ description: "optimise structure in place", flag: `rebuild ${rawInput}` },
|
|
672
|
+
{ description: "repair methodology divergences", flag: `fix ${rawInput}` },
|
|
673
|
+
],
|
|
674
|
+
"pick extend / rebuild / fix",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
// Fall through to the happy path so the orchestrator resumes.
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Happy path: a plain sibling build/extend/rebuild/fix.
|
|
681
|
+
const isNew = !existsSync(target) || !hasPrivateGit(target);
|
|
682
|
+
return ok({
|
|
683
|
+
operation: subcommand,
|
|
684
|
+
layout_mode: explicitMode || "sibling",
|
|
685
|
+
source: input,
|
|
686
|
+
target,
|
|
687
|
+
is_new_wiki: isNew,
|
|
688
|
+
flags: f,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Format an ambiguity error for human consumption (stderr text mode).
|
|
693
|
+
export function formatAmbiguityText(error) {
|
|
694
|
+
const lines = [
|
|
695
|
+
`error: ${error.message}`,
|
|
696
|
+
`(code ${error.code})`,
|
|
697
|
+
"",
|
|
698
|
+
"Options:",
|
|
699
|
+
];
|
|
700
|
+
for (let i = 0; i < error.options.length; i++) {
|
|
701
|
+
const o = error.options[i];
|
|
702
|
+
lines.push(` ${i + 1}. ${o.description}`);
|
|
703
|
+
lines.push(` → ${o.flag}`);
|
|
704
|
+
}
|
|
705
|
+
lines.push("");
|
|
706
|
+
lines.push(`Disambiguating flag: ${error.resolving_flag}`);
|
|
707
|
+
return lines.join("\n") + "\n";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Format an ambiguity error for machine consumption (--json-errors).
|
|
711
|
+
export function formatAmbiguityJson(error) {
|
|
712
|
+
return JSON.stringify({ error }, null, 2) + "\n";
|
|
713
|
+
}
|