@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +7 -0
  4. package/guide/cli.md +6 -4
  5. package/guide/consumers/index.md +106 -0
  6. package/guide/consumers/quickstart.md +96 -0
  7. package/guide/consumers/recipes/ci-gate.md +125 -0
  8. package/guide/consumers/recipes/dated-wiki.md +131 -0
  9. package/guide/consumers/recipes/format-gate.md +126 -0
  10. package/guide/consumers/recipes/post-write-heal.md +125 -0
  11. package/guide/consumers/recipes/skill-absent.md +111 -0
  12. package/guide/consumers/recipes/subject-wiki.md +110 -0
  13. package/guide/consumers/recipes/testing.md +149 -0
  14. package/guide/index.md +9 -0
  15. package/guide/substrate/operators.md +1 -1
  16. package/guide/substrate/tiered-ai.md +6 -5
  17. package/guide/ux/user-intent.md +6 -5
  18. package/package.json +9 -3
  19. package/scripts/cli.mjs +565 -15
  20. package/scripts/lib/balance.mjs +579 -0
  21. package/scripts/lib/cluster-detect.mjs +482 -4
  22. package/scripts/lib/contract.mjs +257 -0
  23. package/scripts/lib/decision-log.mjs +121 -15
  24. package/scripts/lib/heal.mjs +167 -0
  25. package/scripts/lib/init.mjs +210 -0
  26. package/scripts/lib/intent.mjs +370 -4
  27. package/scripts/lib/join-constants.mjs +22 -0
  28. package/scripts/lib/join.mjs +917 -0
  29. package/scripts/lib/json-envelope.mjs +190 -0
  30. package/scripts/lib/nest-applier.mjs +395 -32
  31. package/scripts/lib/operators.mjs +472 -38
  32. package/scripts/lib/orchestrator.mjs +419 -12
  33. package/scripts/lib/root-containment.mjs +351 -0
  34. package/scripts/lib/similarity-cache.mjs +115 -20
  35. package/scripts/lib/similarity.mjs +11 -0
  36. package/scripts/lib/soft-dag.mjs +726 -0
  37. package/scripts/lib/templates.mjs +78 -0
  38. package/scripts/lib/tiered.mjs +42 -18
  39. package/scripts/lib/validate.mjs +22 -0
  40. package/scripts/lib/where.mjs +71 -0
  41. package/scripts/testkit/assert-frontmatter.mjs +171 -0
  42. package/scripts/testkit/cli-run.mjs +95 -0
  43. package/scripts/testkit/make-wiki-fixture.mjs +301 -0
  44. package/scripts/testkit/stub-skill.mjs +107 -0
  45. package/templates/adrs.llmwiki.layout.yaml +33 -0
  46. package/templates/plans.llmwiki.layout.yaml +34 -0
  47. package/templates/regressions.llmwiki.layout.yaml +34 -0
  48. package/templates/reports.llmwiki.layout.yaml +33 -0
  49. package/templates/runbooks.llmwiki.layout.yaml +33 -0
  50. package/templates/sessions.llmwiki.layout.yaml +34 -0
@@ -0,0 +1,257 @@
1
+ // contract.mjs — stable, machine-readable description of what this
2
+ // skill speaks to consumers. The single source of truth for the
3
+ // skill's format + CLI surface version.
4
+ //
5
+ // Consumers (other skills, agents, CI jobs) invoke
6
+ // `node scripts/cli.mjs contract --json` and assert
7
+ // `format_version >= <their required>` rather than drift-testing
8
+ // against SKILL.md prose. Bump FORMAT_VERSION on any breaking change
9
+ // to: leaf frontmatter schema, layout-contract grammar, CLI wire
10
+ // protocol, or exit-code meanings. Additive changes do not bump.
11
+ //
12
+ // The shape returned by `getContract()` is documented in
13
+ // guide/consumers/recipes/format-gate.md.
14
+
15
+ import { readFileSync } from "node:fs";
16
+
17
+ // ─── Version constants ──────────────────────────────────────────────
18
+ // `FORMAT_VERSION` is an integer. Bumps are breaking changes to the
19
+ // consumer-visible contract. Start at 1.
20
+ export const FORMAT_VERSION = 1;
21
+
22
+ // `MIN_CONSUMER_FORMAT_VERSION` is the oldest consumer-declared
23
+ // format_version this skill still speaks to. A consumer whose
24
+ // `required_format_version` is below this refuses to run; a consumer
25
+ // whose `required_format_version` is between this and FORMAT_VERSION
26
+ // runs unchanged. Bumps here signal a deprecation window closing.
27
+ export const MIN_CONSUMER_FORMAT_VERSION = 1;
28
+
29
+ // ─── Canonical shape ────────────────────────────────────────────────
30
+
31
+ // Leaf + index frontmatter fields the skill reads and writes.
32
+ // Mirrors scripts/lib/draft.mjs AUTHORED_LEAF_FIELDS for leaves
33
+ // and scripts/lib/indices.mjs / scripts/lib/validate.mjs for
34
+ // indices. Enums here are the canonical values the skill's own
35
+ // code emits and validate.mjs accepts; consumers authoring their
36
+ // own frontmatter must pick from these.
37
+ const FRONTMATTER_SCHEMA = {
38
+ leaf: {
39
+ required: ["id", "type", "depth_role", "focus", "parents", "source"],
40
+ fields: {
41
+ id: { kind: "string", description: "Unique leaf identifier; derived from source path." },
42
+ // Leaves are `primary` by default; `overlay` is a dedicated
43
+ // type that carries a smaller body budget (see validate.mjs
44
+ // SIZE-CAP) and mandates overlay_targets[].
45
+ type: { kind: "enum", values: ["primary", "overlay"] },
46
+ // Leaves only ever carry depth_role "leaf". Indices have
47
+ // their own depth_role vocabulary (see index.depth_role
48
+ // below).
49
+ depth_role: { kind: "enum", values: ["leaf"] },
50
+ focus: { kind: "string", description: "One-line subject of the leaf." },
51
+ covers: { kind: "string[]", description: "Sub-topics or H2 headings." },
52
+ parents: { kind: "string[]", description: "Relative paths to parent index.md files (e.g. [index.md] or [../index.md])." },
53
+ tags: { kind: "string[]" },
54
+ source: {
55
+ kind: "object",
56
+ fields: {
57
+ origin: { kind: "enum", values: ["file", "synthetic"] },
58
+ path: { kind: "string", description: "POSIX-relative path from the source root." },
59
+ hash: { kind: "string", description: "SHA-256 of the source content." },
60
+ },
61
+ },
62
+ activation: { kind: "object", description: "Routing hints: keyword_matches, tag_matches, etc." },
63
+ domains: { kind: "string[]" },
64
+ aliases: { kind: "string[]" },
65
+ shared_covers: { kind: "string[]" },
66
+ // Only present (and required) when type === "overlay".
67
+ overlay_targets: { kind: "string[]" },
68
+ links: { kind: "string[]" },
69
+ },
70
+ },
71
+ index: {
72
+ required: ["id", "type", "depth_role", "focus"],
73
+ fields: {
74
+ id: { kind: "string", description: "Must match the containing directory's basename." },
75
+ type: { kind: "enum", values: ["index"] },
76
+ // `category` is the root index of a wiki; `subcategory` is
77
+ // any nested index. There is no `root` or `branch` role in
78
+ // the canonical shape; the skill refuses those.
79
+ depth_role: { kind: "enum", values: ["category", "subcategory"] },
80
+ focus: { kind: "string", description: "One-line subject of this branch." },
81
+ depth: { kind: "integer", description: "0 for root category, 1+ for subcategories." },
82
+ parents: { kind: "string[]", description: "Empty at the root; [../index.md] for nested." },
83
+ children: { kind: "string[]", description: "Relative paths to nested index files." },
84
+ entries: { kind: "object[]", description: "Per-leaf summaries (id, file, type, focus, tags)." },
85
+ shared_covers: { kind: "string[]", description: "Inherited by this branch's leaves." },
86
+ orientation: { kind: "string", description: "Authored guidance block; preserved across rebuilds." },
87
+ rebuild_command: { kind: "string" },
88
+ // Skill-generated marker; validate rejects a wiki root
89
+ // whose root index.md lacks it.
90
+ generator: { kind: "string", description: "e.g. \"skill-llm-wiki/v1\"; required on the root index." },
91
+ },
92
+ },
93
+ };
94
+
95
+ // Dynamic-subdirs template tokens supported inside
96
+ // `.llmwiki.layout.yaml` under `layout[].dynamic_subdirs.template`.
97
+ const LAYOUT_TOKENS = [
98
+ { token: "{yyyy}", description: "4-digit year." },
99
+ { token: "{mm}", description: "2-digit month." },
100
+ { token: "{dd}", description: "2-digit day of month." },
101
+ { token: "{slug}", description: "Leaf slug derived from source filename." },
102
+ ];
103
+
104
+ // Exit code contract. Mirrors the banner in cli.mjs printUsage().
105
+ // Listed here so consumers have a single machine-readable source.
106
+ const EXIT_CODES = {
107
+ 0: "ok",
108
+ 1: "usage error",
109
+ 2: "validation or ambiguity error",
110
+ 3: "resolve-wiki miss",
111
+ 4: "Node.js too old",
112
+ 5: "git missing or too old",
113
+ 6: "wiki corrupt",
114
+ 7: "NEEDS_TIER2 — suspend and resume; not a failure",
115
+ 8: "DEPS_MISSING — required runtime dep missing",
116
+ };
117
+
118
+ // Top-level subcommands consumers are expected to invoke. Low-level
119
+ // helpers (`ingest`, `draft-leaf`, etc.) are deliberately omitted
120
+ // from the contract: they are internal tools, subject to change
121
+ // without a format_version bump. Keep this list in sync with
122
+ // cli.mjs printUsage() top-level operations.
123
+ // Keep this table in sync with scripts/cli.mjs. A drift test in
124
+ // tests/unit/contract.test.mjs asserts every flag listed here is
125
+ // actually accepted by the CLI's shared parser or one of the
126
+ // per-subcommand handlers. `SUBCOMMANDS[*].flags` lists canonical
127
+ // consumer-surface flags only; legacy aliases accepted by the CLI
128
+ // (for example `--json-errors` as an alias of `--json`) are
129
+ // deliberately omitted so consumers standardise on the current
130
+ // flag form.
131
+ const SUBCOMMANDS = {
132
+ build: {
133
+ positionals: ["source"],
134
+ flags: [
135
+ "--layout-mode",
136
+ "--target",
137
+ "--quality-mode",
138
+ "--fanout-target",
139
+ "--max-depth",
140
+ "--soft-dag-parents",
141
+ "--no-prompt",
142
+ "--accept-dirty",
143
+ "--accept-foreign-target",
144
+ "--json",
145
+ ],
146
+ },
147
+ extend: {
148
+ positionals: ["wiki"],
149
+ flags: [
150
+ "--quality-mode",
151
+ "--no-prompt",
152
+ "--json",
153
+ ],
154
+ },
155
+ validate: { positionals: ["wiki"], flags: ["--json"] },
156
+ rebuild: {
157
+ positionals: ["wiki"],
158
+ flags: [
159
+ "--quality-mode",
160
+ "--fanout-target",
161
+ "--max-depth",
162
+ "--soft-dag-parents",
163
+ "--review",
164
+ "--no-prompt",
165
+ "--json",
166
+ ],
167
+ },
168
+ fix: { positionals: ["wiki"], flags: ["--json"] },
169
+ join: {
170
+ // Variadic positionals — the CLI accepts
171
+ // `join <wiki-a> <wiki-b> [<wiki-c>...]`. `positionals` lists
172
+ // the minimum shape; `min_positionals` / `variadic` describe
173
+ // the full contract so consumers generating invocations or
174
+ // validating argument counts don't assume exactly two sources.
175
+ positionals: ["wiki-a", "wiki-b"],
176
+ min_positionals: 2,
177
+ variadic: true,
178
+ flags: [
179
+ "--target",
180
+ "--canonical",
181
+ "--id-collision",
182
+ "--quality-mode",
183
+ "--json",
184
+ ],
185
+ },
186
+ rollback: { positionals: ["wiki"], flags: ["--to", "--json"] },
187
+ init: {
188
+ positionals: ["topic"],
189
+ flags: ["--kind", "--template", "--force", "--json"],
190
+ },
191
+ heal: { positionals: ["wiki"], flags: ["--dry-run", "--json"] },
192
+ where: { positionals: [], flags: ["--json"] },
193
+ contract: { positionals: [], flags: ["--json"] },
194
+ "testkit-stub": { positionals: [], flags: ["--at", "--layout"] },
195
+ };
196
+
197
+ // Envelope schema that --json stdout follows across every command.
198
+ // Full JSON Schema lives in scripts/lib/json-envelope.mjs (Feature
199
+ // 5). Consumers gate on the `schema` discriminator.
200
+ const ENVELOPE_SCHEMA = {
201
+ schema: "skill-llm-wiki/v1",
202
+ fields: {
203
+ schema: { kind: "string", const: "skill-llm-wiki/v1" },
204
+ command: { kind: "string" },
205
+ target: { kind: "string|null" },
206
+ verdict: { kind: "string" },
207
+ exit: { kind: "integer" },
208
+ diagnostics: { kind: "object[]" },
209
+ artifacts: { kind: "object" },
210
+ timing_ms: { kind: "integer" },
211
+ // `next` is optional: present only when the subcommand wants
212
+ // to hand the consumer a machine-readable follow-up command
213
+ // (init emits `{command:"skill-llm-wiki", args:["build",...]}`,
214
+ // heal emits the fix/rebuild invocation). When absent, the
215
+ // consumer has nothing to run.
216
+ next: { kind: "object|null", fields: { command: "string", args: "string[]" } },
217
+ },
218
+ };
219
+
220
+ // ─── Assembly ───────────────────────────────────────────────────────
221
+
222
+ function packageVersion() {
223
+ try {
224
+ const pkgUrl = new URL("../../package.json", import.meta.url);
225
+ return JSON.parse(readFileSync(pkgUrl, "utf8")).version;
226
+ } catch {
227
+ return "unknown";
228
+ }
229
+ }
230
+
231
+ export function getContract() {
232
+ return {
233
+ schema: "skill-llm-wiki/contract/v1",
234
+ format_version: FORMAT_VERSION,
235
+ min_consumer_format_version: MIN_CONSUMER_FORMAT_VERSION,
236
+ package_version: packageVersion(),
237
+ frontmatter_schema: FRONTMATTER_SCHEMA,
238
+ layout_tokens: LAYOUT_TOKENS,
239
+ subcommands: SUBCOMMANDS,
240
+ envelope_schema: ENVELOPE_SCHEMA,
241
+ exit_codes: EXIT_CODES,
242
+ };
243
+ }
244
+
245
+ // Human-readable summary. Used when `contract` is invoked without
246
+ // --json. Keep it short: anyone who wants detail takes --json.
247
+ export function renderContractText(contract) {
248
+ const lines = [];
249
+ lines.push(`skill-llm-wiki contract`);
250
+ lines.push(` package_version: ${contract.package_version}`);
251
+ lines.push(` format_version: ${contract.format_version}`);
252
+ lines.push(` min_consumer_format_version: ${contract.min_consumer_format_version}`);
253
+ lines.push(` subcommands: ${Object.keys(contract.subcommands).join(", ")}`);
254
+ lines.push(` layout_tokens: ${contract.layout_tokens.map((t) => t.token).join(" ")}`);
255
+ lines.push(` envelope: ${contract.envelope_schema.schema}`);
256
+ return lines.join("\n") + "\n";
257
+ }
@@ -16,9 +16,14 @@
16
16
  // queryable even after the op is reset.
17
17
 
18
18
  import {
19
+ appendFileSync,
20
+ closeSync,
19
21
  existsSync,
22
+ fstatSync,
20
23
  mkdirSync,
24
+ openSync,
21
25
  readFileSync,
26
+ readSync,
22
27
  renameSync,
23
28
  writeFileSync,
24
29
  } from "node:fs";
@@ -130,27 +135,112 @@ function emitEntry(entry) {
130
135
  return lines.join("\n");
131
136
  }
132
137
 
133
- // Append an entry atomically.
138
+ // Append an entry.
139
+ //
140
+ // Hot path: at large-corpus scale (596 leaves → 189k pairwise
141
+ // decisions observed) this is called once per decision. An earlier
142
+ // implementation read the whole file, concatenated the new entry,
143
+ // wrote to a temp, and renamed — O(file-size) per append. On a
144
+ // 45 MB decisions.yaml that's ~22 MB of avg-read per call × 189k
145
+ // calls ≈ 4 TB of I/O, which alone accounted for most of a 2h15m
146
+ // build's wall-clock time.
147
+ //
148
+ // Durability guarantees:
149
+ //
150
+ // - First call (file doesn't exist): writes header + first entry
151
+ // via temp+rename. The initial file materialises atomically —
152
+ // a crash during the first call leaves either no file or a
153
+ // well-formed single-entry file.
154
+ //
155
+ // - Subsequent calls: best-effort `appendFileSync`. Each call is
156
+ // a single `write(2)` syscall of the serialised entry. In the
157
+ // common case the kernel writes the full buffer atomically,
158
+ // but this is NOT a formal durability contract for regular
159
+ // files the way temp+rename is:
160
+ //
161
+ // * A crash mid-write can leave a torn trailing entry. On
162
+ // recovery the YAML parser will reject the truncated
163
+ // scalar; the audit log is recoverable by removing the
164
+ // last partial `- ...` block and re-running the op.
165
+ //
166
+ // * Node's `writeSync`/`appendFileSync` MAY split a large
167
+ // buffer into multiple `write(2)` calls. Typical entry
168
+ // blocks here are ~200 bytes — well under typical
169
+ // single-write thresholds — but there is no portable
170
+ // small-write atomicity guarantee for regular files
171
+ // (POSIX's PIPE_BUF atomicity applies to pipes/FIFOs, not
172
+ // disk files).
173
+ //
174
+ // * On Windows, `appendFileSync` has no equivalent of
175
+ // POSIX O_APPEND kernel serialisation under concurrent
176
+ // writers from multiple processes. This phase runs
177
+ // single-process though, so cross-process interleaving
178
+ // is not a concern in practice.
179
+ //
180
+ // The decision log is an audit trail, not a reproducibility
181
+ // artefact — lost tail bytes on a crash are annoying but
182
+ // recoverable, and the output tree's byte-reproducibility is
183
+ // independent of this file's exact contents. If stronger
184
+ // durability is needed for a specific use case, callers should
185
+ // batch-flush to a temp file and rename on phase boundaries.
186
+ //
187
+ // Cost per append: O(entry-size), not O(file-size). ~200 µs vs
188
+ // ~20 ms on a big log — a 100× speedup at scale.
134
189
  export function appendDecision(wikiRoot, entry) {
135
190
  validate(entry);
136
191
  const path = decisionLogPath(wikiRoot);
137
192
  mkdirSync(dirname(path), { recursive: true });
138
193
  const block = emitEntry(entry) + "\n";
139
- let payload;
140
194
  if (!existsSync(path)) {
141
- payload =
195
+ // First call: lay down the header atomically via temp+rename so
196
+ // a crash mid-creation doesn't leave an empty or orphan file.
197
+ const payload =
142
198
  "# skill-llm-wiki tiered-AI decision log (append-only)\n" +
143
199
  "version: 1\n" +
144
200
  "entries:\n" +
145
201
  block;
146
- } else {
147
- const existing = readFileSync(path, "utf8");
148
- const prefix = existing.endsWith("\n") ? existing : existing + "\n";
149
- payload = prefix + block;
202
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
203
+ writeFileSync(tmp, payload, "utf8");
204
+ renameSync(tmp, path);
205
+ return;
206
+ }
207
+ // Subsequent appends: O(entry-size) via POSIX append. Peek at
208
+ // the last byte first: if the existing file doesn't end in a
209
+ // newline (manual edit, prior torn-tail truncation, or a
210
+ // creative crash), appending directly would concatenate the new
211
+ // entry onto the previous line and produce invalid YAML. Prefix
212
+ // a newline in that case — a leading blank line inside the
213
+ // entries[] list is harmless and parses fine.
214
+ const needsLeadingNewline = !endsWithNewline(path);
215
+ appendFileSync(path, needsLeadingNewline ? "\n" + block : block, "utf8");
216
+ }
217
+
218
+ // Check the last byte of the decision log without reading the
219
+ // whole file. Uses a small anchored read rather than `readFileSync`
220
+ // so the hot append path still pays O(1) regardless of log size.
221
+ // An unreadable file (ENOENT, EACCES, race window) is treated as
222
+ // "already newline-terminated" so the caller doesn't double up on
223
+ // leading newlines on a transient read error.
224
+ function endsWithNewline(path) {
225
+ let fd;
226
+ try {
227
+ fd = openSync(path, "r");
228
+ const { size } = fstatSync(fd);
229
+ if (size === 0) return true; // empty file has no trailing content to collide
230
+ const buf = Buffer.alloc(1);
231
+ readSync(fd, buf, 0, 1, size - 1);
232
+ return buf[0] === 0x0a; // 0x0a == '\n'
233
+ } catch {
234
+ return true;
235
+ } finally {
236
+ if (fd !== undefined) {
237
+ try {
238
+ closeSync(fd);
239
+ } catch {
240
+ /* best-effort */
241
+ }
242
+ }
150
243
  }
151
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
152
- writeFileSync(tmp, payload, "utf8");
153
- renameSync(tmp, path);
154
244
  }
155
245
 
156
246
  // Convenience helper for cluster-NEST outcomes. The convergence
@@ -164,14 +254,18 @@ export function appendDecision(wikiRoot, entry) {
164
254
  //
165
255
  // op_id, operator="NEST" — as-is
166
256
  // sources — leaf ids in the cluster
167
- // tier_used — 2 (every NEST decision
168
- // touches Tier 2 either
169
- // via propose_structure
170
- // or nest_decision)
257
+ // tier_used — caller-supplied (default 2
258
+ // for legacy Tier-2-touching
259
+ // NEST paths; 0 under
260
+ // `--quality-mode deterministic`
261
+ // since no sub-agent is
262
+ // consulted)
171
263
  // similarity — average_affinity
172
264
  // confidence_band — one of:
173
265
  // "tier2-proposed",
266
+ // "tier2-and-math",
174
267
  // "math-gated",
268
+ // "deterministic-math",
175
269
  // "empty-partition",
176
270
  // "rejected-by-metric",
177
271
  // "rejected-by-gate"
@@ -187,16 +281,28 @@ export function appendDecision(wikiRoot, entry) {
187
281
  // Coercion: average_affinity may be undefined for Tier-2-proposed
188
282
  // clusters; we coerce to 0 so the finite-number validator does
189
283
  // not reject the entry.
284
+ //
285
+ // tier_used default: pre-deterministic-mode every NEST decision
286
+ // touched Tier 2 via propose_structure or nest_decision, so the
287
+ // default of 2 was correct. Under `--quality-mode deterministic`
288
+ // Tier 2 is never consulted for math candidates; callers on that
289
+ // path pass `tier_used: 0` so the audit trail correctly reflects
290
+ // the fact that no sub-agent was invoked. The default remains 2
291
+ // for backward compatibility with every existing call site.
190
292
  export function appendNestDecision(wikiRoot, entry) {
191
293
  const similarity =
192
294
  Number.isFinite(entry.similarity)
193
295
  ? entry.similarity
194
296
  : (Number.isFinite(entry.average_affinity) ? entry.average_affinity : 0);
297
+ const tier_used =
298
+ typeof entry.tier_used === "number" && Number.isInteger(entry.tier_used)
299
+ ? entry.tier_used
300
+ : 2;
195
301
  appendDecision(wikiRoot, {
196
302
  op_id: entry.op_id,
197
303
  operator: "NEST",
198
304
  sources: Array.isArray(entry.sources) ? entry.sources : [],
199
- tier_used: 2,
305
+ tier_used,
200
306
  similarity,
201
307
  confidence_band: entry.confidence_band ?? null,
202
308
  decision: entry.decision,
@@ -0,0 +1,167 @@
1
+ // heal.mjs — classify validate findings and name the next command.
2
+ //
3
+ // `skill-llm-wiki heal <wiki>` runs validate internally, then maps
4
+ // the findings to one of five verdicts:
5
+ //
6
+ // ok → nothing to do
7
+ // fixable → `skill-llm-wiki fix <wiki>` will resolve it
8
+ // needs-rebuild → `skill-llm-wiki rebuild <wiki>` is required
9
+ // broken → the wiki is corrupt; manual intervention needed
10
+ // ambiguous → validate itself failed before producing findings
11
+ //
12
+ // The consumer invokes the recommended command as a separate step.
13
+ // Heal does not run fix/rebuild directly: doing so pulls in the
14
+ // orchestrator's error surface (Tier 2 exits, validation rollback,
15
+ // non-interactive refusal) that a classify-only shape deliberately
16
+ // avoids. A follow-up can add --apply once that error-mapping is
17
+ // proven.
18
+ //
19
+ // The classification table below is the public contract: consumers
20
+ // who want to pre-classify findings themselves (e.g. for a CI dash)
21
+ // can import FINDING_ACTIONS.
22
+
23
+ import { validateWiki } from "./validate.mjs";
24
+
25
+ // Map every known finding code to the minimum action that resolves
26
+ // it. Ranked from cheapest ("none") to most invasive ("manual"):
27
+ //
28
+ // none → a warning; no mutating step required
29
+ // fix → `skill-llm-wiki fix` is sufficient
30
+ // rebuild → `skill-llm-wiki rebuild` is required
31
+ // manual → the wiki is broken in a way the skill cannot self-heal
32
+ export const FINDING_ACTIONS = Object.freeze({
33
+ // Wiki substrate / git:
34
+ "WIKI-01": "manual", // not a valid wiki root
35
+ "GIT-01": "manual", // private git is broken / divergent
36
+
37
+ // Content loss:
38
+ "LOSS-01": "rebuild", // source bytes not accounted for in target
39
+
40
+ // Parse / malformed frontmatter:
41
+ PARSE: "manual", // cannot parse YAML; user must edit
42
+
43
+ // Frontmatter field issues — fix regenerates most of these:
44
+ "MISSING-FIELD": "fix",
45
+ "DUP-ID": "rebuild",
46
+ "ALIAS-COLLIDES-ID": "fix",
47
+ "ID-MISMATCH-DIR": "rebuild",
48
+ "ID-MISMATCH-FILE": "rebuild",
49
+ "DEPTH-ROLE": "rebuild",
50
+ "PARENTS-REQUIRED": "rebuild",
51
+ "PARENT-CONTRACT": "rebuild",
52
+ "DANGLING-LINK": "fix",
53
+ "DANGLING-OVERLAY": "fix",
54
+
55
+ // X.11 root-leaf containment invariant — `fix` runs Phase 4.4.5
56
+ // root-containment to move outlier leaves into per-slug
57
+ // subcategories:
58
+ "LEAF-AT-WIKI-ROOT": "fix",
59
+
60
+ // Size cap is a warning surface only:
61
+ "SIZE-CAP": "none",
62
+ });
63
+
64
+ // Priority ranking: if any finding maps to a higher-priority action,
65
+ // that action wins for the whole wiki.
66
+ const PRIORITY = Object.freeze({ none: 0, fix: 1, rebuild: 2, manual: 3 });
67
+
68
+ // Verdict that corresponds to each action tier. Keeps the four
69
+ // tables (FINDING_ACTIONS, PRIORITY, NEXT_COMMAND_BY_ACTION,
70
+ // VERDICT_BY_ACTION) in parallel so adding a new action tier means
71
+ // adding one row in each.
72
+ const VERDICT_BY_ACTION = Object.freeze({
73
+ none: "ok",
74
+ fix: "fixable",
75
+ rebuild: "needs-rebuild",
76
+ manual: "broken",
77
+ });
78
+
79
+ function actionFor(code) {
80
+ return FINDING_ACTIONS[code] ?? "rebuild";
81
+ }
82
+
83
+ // Core routing. Pure: call validateWiki separately if you want to
84
+ // avoid re-validating.
85
+ export function classifyFindings(findings) {
86
+ const actions = new Set();
87
+ for (const f of findings) {
88
+ // Warnings never trigger a mutating verdict — they're advisory.
89
+ if (f.severity !== "error") continue;
90
+ actions.add(actionFor(f.code));
91
+ }
92
+ if (actions.size === 0) {
93
+ return { action: "none", verdict: "ok" };
94
+ }
95
+ let best = "none";
96
+ for (const a of actions) {
97
+ if (PRIORITY[a] > PRIORITY[best]) best = a;
98
+ }
99
+ return { action: best, verdict: VERDICT_BY_ACTION[best] };
100
+ }
101
+
102
+ // Full heal run against a wiki path. Returns an object the CLI
103
+ // wraps into an envelope.
104
+ export function runHeal(wikiPath) {
105
+ let findings;
106
+ try {
107
+ findings = validateWiki(wikiPath);
108
+ } catch (err) {
109
+ return {
110
+ target: wikiPath,
111
+ verdict: "ambiguous",
112
+ action: "manual",
113
+ findings: [],
114
+ error: err.message,
115
+ next_command: null,
116
+ };
117
+ }
118
+ const { action, verdict } = classifyFindings(findings);
119
+ const next_command = buildNextCommand(action, wikiPath);
120
+ return {
121
+ target: wikiPath,
122
+ verdict,
123
+ action,
124
+ findings,
125
+ error: null,
126
+ next_command,
127
+ };
128
+ }
129
+
130
+ // Map every action to the CLI invocation that resolves it. A map
131
+ // rather than an if/else chain keeps the action vocabulary in one
132
+ // place next to FINDING_ACTIONS / PRIORITY / VERDICT_BY_ACTION. To
133
+ // add a new action tier, add a row here; anything else falls back
134
+ // to `null` (no auto-step).
135
+ const NEXT_COMMAND_BY_ACTION = Object.freeze({
136
+ none: null,
137
+ fix: (wikiPath) => ["skill-llm-wiki", "fix", wikiPath, "--json"],
138
+ rebuild: (wikiPath) => ["skill-llm-wiki", "rebuild", wikiPath, "--json"],
139
+ manual: null,
140
+ });
141
+
142
+ function buildNextCommand(action, wikiPath) {
143
+ const builder = NEXT_COMMAND_BY_ACTION[action];
144
+ if (typeof builder !== "function") return null;
145
+ return builder(wikiPath);
146
+ }
147
+
148
+ // Human-readable rendering of a runHeal result. Lives here so the
149
+ // text and JSON output of heal stay under the same roof and cannot
150
+ // drift. Mirrors the renderContractText / renderInitText pattern.
151
+ export function renderHealText(result) {
152
+ const lines = [`heal: ${result.verdict} (${result.action})`];
153
+ for (const f of result.findings) {
154
+ const tag =
155
+ f.severity === "error"
156
+ ? "ERR "
157
+ : f.severity === "warning"
158
+ ? "WARN"
159
+ : "INFO";
160
+ lines.push(` [${tag}] ${f.code} ${f.target}`);
161
+ lines.push(` ${f.message}`);
162
+ }
163
+ if (result.next_command) {
164
+ lines.push(` next: ${result.next_command.join(" ")}`);
165
+ }
166
+ return lines.join("\n") + "\n";
167
+ }