@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,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
|
|
138
|
+
// Append an entry.
|
|
139
|
+
//
|
|
140
|
+
// Hot path: at large-corpus scale (596 leaves → 189k pairwise
|
|
141
|
+
// decisions observed) this is called once per decision. An earlier
|
|
142
|
+
// implementation read the whole file, concatenated the new entry,
|
|
143
|
+
// wrote to a temp, and renamed — O(file-size) per append. On a
|
|
144
|
+
// 45 MB decisions.yaml that's ~22 MB of avg-read per call × 189k
|
|
145
|
+
// calls ≈ 4 TB of I/O, which alone accounted for most of a 2h15m
|
|
146
|
+
// build's wall-clock time.
|
|
147
|
+
//
|
|
148
|
+
// Durability guarantees:
|
|
149
|
+
//
|
|
150
|
+
// - First call (file doesn't exist): writes header + first entry
|
|
151
|
+
// via temp+rename. The initial file materialises atomically —
|
|
152
|
+
// a crash during the first call leaves either no file or a
|
|
153
|
+
// well-formed single-entry file.
|
|
154
|
+
//
|
|
155
|
+
// - Subsequent calls: best-effort `appendFileSync`. Each call is
|
|
156
|
+
// a single `write(2)` syscall of the serialised entry. In the
|
|
157
|
+
// common case the kernel writes the full buffer atomically,
|
|
158
|
+
// but this is NOT a formal durability contract for regular
|
|
159
|
+
// files the way temp+rename is:
|
|
160
|
+
//
|
|
161
|
+
// * A crash mid-write can leave a torn trailing entry. On
|
|
162
|
+
// recovery the YAML parser will reject the truncated
|
|
163
|
+
// scalar; the audit log is recoverable by removing the
|
|
164
|
+
// last partial `- ...` block and re-running the op.
|
|
165
|
+
//
|
|
166
|
+
// * Node's `writeSync`/`appendFileSync` MAY split a large
|
|
167
|
+
// buffer into multiple `write(2)` calls. Typical entry
|
|
168
|
+
// blocks here are ~200 bytes — well under typical
|
|
169
|
+
// single-write thresholds — but there is no portable
|
|
170
|
+
// small-write atomicity guarantee for regular files
|
|
171
|
+
// (POSIX's PIPE_BUF atomicity applies to pipes/FIFOs, not
|
|
172
|
+
// disk files).
|
|
173
|
+
//
|
|
174
|
+
// * On Windows, `appendFileSync` has no equivalent of
|
|
175
|
+
// POSIX O_APPEND kernel serialisation under concurrent
|
|
176
|
+
// writers from multiple processes. This phase runs
|
|
177
|
+
// single-process though, so cross-process interleaving
|
|
178
|
+
// is not a concern in practice.
|
|
179
|
+
//
|
|
180
|
+
// The decision log is an audit trail, not a reproducibility
|
|
181
|
+
// artefact — lost tail bytes on a crash are annoying but
|
|
182
|
+
// recoverable, and the output tree's byte-reproducibility is
|
|
183
|
+
// independent of this file's exact contents. If stronger
|
|
184
|
+
// durability is needed for a specific use case, callers should
|
|
185
|
+
// batch-flush to a temp file and rename on phase boundaries.
|
|
186
|
+
//
|
|
187
|
+
// Cost per append: O(entry-size), not O(file-size). ~200 µs vs
|
|
188
|
+
// ~20 ms on a big log — a 100× speedup at scale.
|
|
134
189
|
export function appendDecision(wikiRoot, entry) {
|
|
135
190
|
validate(entry);
|
|
136
191
|
const path = decisionLogPath(wikiRoot);
|
|
137
192
|
mkdirSync(dirname(path), { recursive: true });
|
|
138
193
|
const block = emitEntry(entry) + "\n";
|
|
139
|
-
let payload;
|
|
140
194
|
if (!existsSync(path)) {
|
|
141
|
-
|
|
195
|
+
// First call: lay down the header atomically via temp+rename so
|
|
196
|
+
// a crash mid-creation doesn't leave an empty or orphan file.
|
|
197
|
+
const payload =
|
|
142
198
|
"# skill-llm-wiki tiered-AI decision log (append-only)\n" +
|
|
143
199
|
"version: 1\n" +
|
|
144
200
|
"entries:\n" +
|
|
145
201
|
block;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
202
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
203
|
+
writeFileSync(tmp, payload, "utf8");
|
|
204
|
+
renameSync(tmp, path);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Subsequent appends: O(entry-size) via POSIX append. Peek at
|
|
208
|
+
// the last byte first: if the existing file doesn't end in a
|
|
209
|
+
// newline (manual edit, prior torn-tail truncation, or a
|
|
210
|
+
// creative crash), appending directly would concatenate the new
|
|
211
|
+
// entry onto the previous line and produce invalid YAML. Prefix
|
|
212
|
+
// a newline in that case — a leading blank line inside the
|
|
213
|
+
// entries[] list is harmless and parses fine.
|
|
214
|
+
const needsLeadingNewline = !endsWithNewline(path);
|
|
215
|
+
appendFileSync(path, needsLeadingNewline ? "\n" + block : block, "utf8");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check the last byte of the decision log without reading the
|
|
219
|
+
// whole file. Uses a small anchored read rather than `readFileSync`
|
|
220
|
+
// so the hot append path still pays O(1) regardless of log size.
|
|
221
|
+
// An unreadable file (ENOENT, EACCES, race window) is treated as
|
|
222
|
+
// "already newline-terminated" so the caller doesn't double up on
|
|
223
|
+
// leading newlines on a transient read error.
|
|
224
|
+
function endsWithNewline(path) {
|
|
225
|
+
let fd;
|
|
226
|
+
try {
|
|
227
|
+
fd = openSync(path, "r");
|
|
228
|
+
const { size } = fstatSync(fd);
|
|
229
|
+
if (size === 0) return true; // empty file has no trailing content to collide
|
|
230
|
+
const buf = Buffer.alloc(1);
|
|
231
|
+
readSync(fd, buf, 0, 1, size - 1);
|
|
232
|
+
return buf[0] === 0x0a; // 0x0a == '\n'
|
|
233
|
+
} catch {
|
|
234
|
+
return true;
|
|
235
|
+
} finally {
|
|
236
|
+
if (fd !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
closeSync(fd);
|
|
239
|
+
} catch {
|
|
240
|
+
/* best-effort */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
150
243
|
}
|
|
151
|
-
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
152
|
-
writeFileSync(tmp, payload, "utf8");
|
|
153
|
-
renameSync(tmp, path);
|
|
154
244
|
}
|
|
155
245
|
|
|
156
246
|
// Convenience helper for cluster-NEST outcomes. The convergence
|
|
@@ -164,14 +254,18 @@ export function appendDecision(wikiRoot, entry) {
|
|
|
164
254
|
//
|
|
165
255
|
// op_id, operator="NEST" — as-is
|
|
166
256
|
// sources — leaf ids in the cluster
|
|
167
|
-
// tier_used —
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
257
|
+
// tier_used — caller-supplied (default 2
|
|
258
|
+
// for legacy Tier-2-touching
|
|
259
|
+
// NEST paths; 0 under
|
|
260
|
+
// `--quality-mode deterministic`
|
|
261
|
+
// since no sub-agent is
|
|
262
|
+
// consulted)
|
|
171
263
|
// similarity — average_affinity
|
|
172
264
|
// confidence_band — one of:
|
|
173
265
|
// "tier2-proposed",
|
|
266
|
+
// "tier2-and-math",
|
|
174
267
|
// "math-gated",
|
|
268
|
+
// "deterministic-math",
|
|
175
269
|
// "empty-partition",
|
|
176
270
|
// "rejected-by-metric",
|
|
177
271
|
// "rejected-by-gate"
|
|
@@ -187,16 +281,28 @@ export function appendDecision(wikiRoot, entry) {
|
|
|
187
281
|
// Coercion: average_affinity may be undefined for Tier-2-proposed
|
|
188
282
|
// clusters; we coerce to 0 so the finite-number validator does
|
|
189
283
|
// not reject the entry.
|
|
284
|
+
//
|
|
285
|
+
// tier_used default: pre-deterministic-mode every NEST decision
|
|
286
|
+
// touched Tier 2 via propose_structure or nest_decision, so the
|
|
287
|
+
// default of 2 was correct. Under `--quality-mode deterministic`
|
|
288
|
+
// Tier 2 is never consulted for math candidates; callers on that
|
|
289
|
+
// path pass `tier_used: 0` so the audit trail correctly reflects
|
|
290
|
+
// the fact that no sub-agent was invoked. The default remains 2
|
|
291
|
+
// for backward compatibility with every existing call site.
|
|
190
292
|
export function appendNestDecision(wikiRoot, entry) {
|
|
191
293
|
const similarity =
|
|
192
294
|
Number.isFinite(entry.similarity)
|
|
193
295
|
? entry.similarity
|
|
194
296
|
: (Number.isFinite(entry.average_affinity) ? entry.average_affinity : 0);
|
|
297
|
+
const tier_used =
|
|
298
|
+
typeof entry.tier_used === "number" && Number.isInteger(entry.tier_used)
|
|
299
|
+
? entry.tier_used
|
|
300
|
+
: 2;
|
|
195
301
|
appendDecision(wikiRoot, {
|
|
196
302
|
op_id: entry.op_id,
|
|
197
303
|
operator: "NEST",
|
|
198
304
|
sources: Array.isArray(entry.sources) ? entry.sources : [],
|
|
199
|
-
tier_used
|
|
305
|
+
tier_used,
|
|
200
306
|
similarity,
|
|
201
307
|
confidence_band: entry.confidence_band ?? null,
|
|
202
308
|
decision: entry.decision,
|
|
@@ -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
|
+
}
|