@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/SKILL.md +252 -0
  5. package/guide/basics/concepts.md +74 -0
  6. package/guide/basics/index.md +45 -0
  7. package/guide/basics/schema.md +140 -0
  8. package/guide/cli.md +256 -0
  9. package/guide/correctness/index.md +45 -0
  10. package/guide/correctness/invariants.md +89 -0
  11. package/guide/correctness/safety.md +96 -0
  12. package/guide/history/diff.md +110 -0
  13. package/guide/history/hidden-git.md +130 -0
  14. package/guide/history/index.md +52 -0
  15. package/guide/history/remote-sync.md +113 -0
  16. package/guide/index.md +134 -0
  17. package/guide/isolation/coexistence.md +134 -0
  18. package/guide/isolation/index.md +44 -0
  19. package/guide/isolation/scale.md +251 -0
  20. package/guide/layout/in-place-mode.md +97 -0
  21. package/guide/layout/index.md +53 -0
  22. package/guide/layout/layout-contract.md +131 -0
  23. package/guide/layout/layout-modes.md +115 -0
  24. package/guide/operations/index.md +76 -0
  25. package/guide/operations/ingest/build.md +75 -0
  26. package/guide/operations/ingest/extend.md +61 -0
  27. package/guide/operations/ingest/index.md +54 -0
  28. package/guide/operations/ingest/join.md +65 -0
  29. package/guide/operations/maintain/fix.md +66 -0
  30. package/guide/operations/maintain/index.md +47 -0
  31. package/guide/operations/maintain/rebuild.md +86 -0
  32. package/guide/operations/validate.md +48 -0
  33. package/guide/substrate/index.md +47 -0
  34. package/guide/substrate/operators.md +96 -0
  35. package/guide/substrate/tiered-ai.md +363 -0
  36. package/guide/ux/index.md +44 -0
  37. package/guide/ux/preflight.md +150 -0
  38. package/guide/ux/user-intent.md +135 -0
  39. package/package.json +55 -0
  40. package/scripts/cli.mjs +893 -0
  41. package/scripts/commands/remote.mjs +93 -0
  42. package/scripts/commands/review.mjs +253 -0
  43. package/scripts/commands/sync.mjs +84 -0
  44. package/scripts/lib/chunk.mjs +421 -0
  45. package/scripts/lib/cluster-detect.mjs +516 -0
  46. package/scripts/lib/decision-log.mjs +343 -0
  47. package/scripts/lib/draft.mjs +158 -0
  48. package/scripts/lib/embeddings.mjs +366 -0
  49. package/scripts/lib/frontmatter.mjs +497 -0
  50. package/scripts/lib/git-commands.mjs +155 -0
  51. package/scripts/lib/git.mjs +486 -0
  52. package/scripts/lib/gitignore.mjs +62 -0
  53. package/scripts/lib/history.mjs +331 -0
  54. package/scripts/lib/indices.mjs +510 -0
  55. package/scripts/lib/ingest.mjs +258 -0
  56. package/scripts/lib/intent.mjs +713 -0
  57. package/scripts/lib/interactive.mjs +99 -0
  58. package/scripts/lib/migrate.mjs +126 -0
  59. package/scripts/lib/nest-applier.mjs +260 -0
  60. package/scripts/lib/operators.mjs +1365 -0
  61. package/scripts/lib/orchestrator.mjs +718 -0
  62. package/scripts/lib/paths.mjs +197 -0
  63. package/scripts/lib/preflight.mjs +213 -0
  64. package/scripts/lib/provenance.mjs +672 -0
  65. package/scripts/lib/quality-metric.mjs +269 -0
  66. package/scripts/lib/query-fixture.mjs +71 -0
  67. package/scripts/lib/rollback.mjs +95 -0
  68. package/scripts/lib/shape-check.mjs +172 -0
  69. package/scripts/lib/similarity-cache.mjs +126 -0
  70. package/scripts/lib/similarity.mjs +230 -0
  71. package/scripts/lib/snapshot.mjs +54 -0
  72. package/scripts/lib/source-frontmatter.mjs +85 -0
  73. package/scripts/lib/tier2-protocol.mjs +470 -0
  74. package/scripts/lib/tiered.mjs +453 -0
  75. 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
+ }