@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,78 @@
|
|
|
1
|
+
// templates.mjs — discovery + metadata for the shipped layout
|
|
2
|
+
// templates under <SKILL_ROOT>/templates/*.llmwiki.layout.yaml.
|
|
3
|
+
//
|
|
4
|
+
// Feature 1 (this module) exposes the list and paths. Feature 2
|
|
5
|
+
// (init) uses them to seed a consumer's topic wiki with one command.
|
|
6
|
+
// Consumers can also copy templates by hand via `skill-llm-wiki
|
|
7
|
+
// where --json` + templates_dir.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { SKILL_ROOT } from "./where.mjs";
|
|
12
|
+
|
|
13
|
+
const TEMPLATES_DIR = join(SKILL_ROOT, "templates");
|
|
14
|
+
const TEMPLATE_SUFFIX = ".llmwiki.layout.yaml";
|
|
15
|
+
|
|
16
|
+
// Metadata layer ON TOP of the template files. Keeps the per-template
|
|
17
|
+
// YAML focused on the layout contract itself while the "what kind of
|
|
18
|
+
// topic am I?" mapping lives in code. A template whose name isn't
|
|
19
|
+
// listed here is still usable, just not recommended via the CLI's
|
|
20
|
+
// `--kind` short-hand.
|
|
21
|
+
const TEMPLATE_META = {
|
|
22
|
+
reports: { kind: "dated", description: "Generated reports filed by day." },
|
|
23
|
+
sessions: { kind: "dated", description: "Daily session logs filed by day." },
|
|
24
|
+
regressions: { kind: "dated", description: "Regression notes filed by month." },
|
|
25
|
+
plans: { kind: "dated", description: "Plans filed by day, with subject subfolders for families." },
|
|
26
|
+
runbooks: { kind: "subject", description: "Runbooks grouped by subject." },
|
|
27
|
+
adrs: { kind: "subject", description: "Architecture decision records, numbered by subject." },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function templatesDir() {
|
|
31
|
+
return TEMPLATES_DIR;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Return a map of template name (e.g. "reports") -> { path, kind,
|
|
35
|
+
// description }. Only templates that actually exist on disk are
|
|
36
|
+
// returned — package.json `files` ships the templates/ dir, but a
|
|
37
|
+
// broken install or an older skill version may lack a given file.
|
|
38
|
+
export function listTemplates() {
|
|
39
|
+
if (!existsSync(TEMPLATES_DIR)) return {};
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const entry of readdirSync(TEMPLATES_DIR)) {
|
|
42
|
+
if (!entry.endsWith(TEMPLATE_SUFFIX)) continue;
|
|
43
|
+
const name = entry.slice(0, -TEMPLATE_SUFFIX.length);
|
|
44
|
+
const abs = join(TEMPLATES_DIR, entry);
|
|
45
|
+
try {
|
|
46
|
+
if (!statSync(abs).isFile()) continue;
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const meta = TEMPLATE_META[name] ?? {
|
|
51
|
+
kind: "unknown",
|
|
52
|
+
description: "",
|
|
53
|
+
};
|
|
54
|
+
out[name] = { path: abs, kind: meta.kind, description: meta.description };
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getTemplate(name) {
|
|
60
|
+
const all = listTemplates();
|
|
61
|
+
return all[name] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readTemplate(name) {
|
|
65
|
+
const t = getTemplate(name);
|
|
66
|
+
if (!t) return null;
|
|
67
|
+
return readFileSync(t.path, "utf8");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns the canonical default template name for a given --kind.
|
|
71
|
+
// Consumers who don't want to name a specific template can pass
|
|
72
|
+
// --kind and get a sensible default (dated -> reports, subject ->
|
|
73
|
+
// runbooks).
|
|
74
|
+
export function defaultTemplateForKind(kind) {
|
|
75
|
+
if (kind === "dated") return "reports";
|
|
76
|
+
if (kind === "subject") return "runbooks";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
package/scripts/lib/tiered.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// circuits the whole ladder.
|
|
11
11
|
//
|
|
12
12
|
// Three quality modes, selected via --quality-mode or the
|
|
13
|
-
// LLM_WIKI_QUALITY_MODE env var:
|
|
13
|
+
// LLM_WIKI_QUALITY_MODE env var (see resolveQualityMode):
|
|
14
14
|
//
|
|
15
15
|
// tiered-fast (default):
|
|
16
16
|
// Tier 0 → Tier 1 → Tier 2, the full ladder. Mid-band Tier 0
|
|
@@ -21,9 +21,16 @@
|
|
|
21
21
|
// obvious decisions) but anything in the Tier 0 mid-band goes
|
|
22
22
|
// straight to Tier 2, skipping Tier 1.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
// Tier 0
|
|
26
|
-
//
|
|
24
|
+
// deterministic:
|
|
25
|
+
// Tier 0 + Tier 1 ladder, but the ladder terminates at Tier 1:
|
|
26
|
+
// mid-band Tier 0 escalates to Tier 1 (as in tiered-fast), but
|
|
27
|
+
// mid-band Tier 1 is resolved by a deterministic threshold
|
|
28
|
+
// (`TIER1_DETERMINISTIC_THRESHOLD`) instead of escalating to
|
|
29
|
+
// Tier 2. No LLM/sub-agent is ever consulted — every decision
|
|
30
|
+
// is produced from TF-IDF + MiniLM cosine alone, so repeated
|
|
31
|
+
// runs on the same inputs are byte-reproducible. This is the
|
|
32
|
+
// mode the clustering pipeline pairs with algorithmic HAC +
|
|
33
|
+
// auto-slug to produce deterministic wiki builds end-to-end.
|
|
27
34
|
//
|
|
28
35
|
// Tier 2 escalation contract: the skill's CLI runs under Node with
|
|
29
36
|
// no access to Claude Code's `Agent` tool, so it cannot spawn
|
|
@@ -64,11 +71,27 @@ import {
|
|
|
64
71
|
export const QUALITY_MODES = Object.freeze([
|
|
65
72
|
"tiered-fast",
|
|
66
73
|
"claude-first",
|
|
67
|
-
"
|
|
74
|
+
"deterministic",
|
|
68
75
|
]);
|
|
69
76
|
|
|
70
77
|
export const DEFAULT_QUALITY_MODE = "tiered-fast";
|
|
71
78
|
|
|
79
|
+
// Deterministic-mode split point for resolving mid-band Tier 1
|
|
80
|
+
// similarities. Derived as the midpoint of the Tier 1 mid-band so
|
|
81
|
+
// future tuning of the decisive-same / decisive-different thresholds
|
|
82
|
+
// propagates here without a separate code-change — no drift between
|
|
83
|
+
// "where the ladder says 'escalate'" and "where deterministic mode
|
|
84
|
+
// says 'same vs different'". Any pair whose Tier 1 cosine sits
|
|
85
|
+
// strictly above this is routed to "same"; anything at-or-below is
|
|
86
|
+
// routed to "different". In this mode there is no mid-band
|
|
87
|
+
// "undecidable" / pending-Tier-2 outcome — Tier 1 always produces a
|
|
88
|
+
// concrete branch without an LLM in the loop. (Note: Tier 0 can still
|
|
89
|
+
// produce an "undecidable" result on insufficient-text inputs — two
|
|
90
|
+
// empty frontmatters — independent of quality mode; that predates
|
|
91
|
+
// deterministic mode and is by design.)
|
|
92
|
+
export const TIER1_DETERMINISTIC_THRESHOLD =
|
|
93
|
+
(TIER1_DECISIVE_SAME + TIER1_DECISIVE_DIFFERENT) / 2;
|
|
94
|
+
|
|
72
95
|
export function resolveQualityMode(flags = {}) {
|
|
73
96
|
const fromFlag = flags.quality_mode;
|
|
74
97
|
const fromEnv = process.env.LLM_WIKI_QUALITY_MODE;
|
|
@@ -244,18 +267,6 @@ export async function decide(
|
|
|
244
267
|
}
|
|
245
268
|
|
|
246
269
|
// Mid-band Tier 0 → escalate. Behaviour depends on quality mode.
|
|
247
|
-
if (qualityMode === "tier0-only") {
|
|
248
|
-
const result = {
|
|
249
|
-
tier: 0,
|
|
250
|
-
similarity: t0.similarity,
|
|
251
|
-
decision: "undecidable",
|
|
252
|
-
confidence_band: t0.confidence_band,
|
|
253
|
-
reason: "tier0-only quality mode — mid-band left unresolved",
|
|
254
|
-
};
|
|
255
|
-
finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
270
|
if (qualityMode === "claude-first") {
|
|
260
271
|
// Skip Tier 1 entirely, go straight to Tier 2.
|
|
261
272
|
return await escalateToTier2(
|
|
@@ -308,7 +319,20 @@ export async function decide(
|
|
|
308
319
|
finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
|
|
309
320
|
return result;
|
|
310
321
|
}
|
|
311
|
-
// Mid-band Tier 1
|
|
322
|
+
// Mid-band Tier 1. Branch on quality mode: deterministic resolves
|
|
323
|
+
// algorithmically, tiered-fast escalates to Tier 2.
|
|
324
|
+
if (qualityMode === "deterministic") {
|
|
325
|
+
const decision = sim > TIER1_DETERMINISTIC_THRESHOLD ? "same" : "different";
|
|
326
|
+
const result = {
|
|
327
|
+
tier: 1,
|
|
328
|
+
similarity: sim,
|
|
329
|
+
decision,
|
|
330
|
+
confidence_band: "deterministic-mid-band",
|
|
331
|
+
reason: `deterministic mode: sim ${sim.toFixed(3)} ${decision === "same" ? ">" : "≤"} ${TIER1_DETERMINISTIC_THRESHOLD}`,
|
|
332
|
+
};
|
|
333
|
+
finaliseDecision(result, { a, b, hashA, hashB, wikiRoot, opId, operator, writeLog, writeCache });
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
312
336
|
return await escalateToTier2(
|
|
313
337
|
a, b, hashA, hashB, wikiRoot, opId, operator,
|
|
314
338
|
sim, "tier1 mid-band", writeLog, writeCache,
|
package/scripts/lib/validate.mjs
CHANGED
|
@@ -133,6 +133,28 @@ export function validateWiki(wikiRoot) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
// LEAF-AT-WIKI-ROOT — the wiki root must hold only `index.md`
|
|
138
|
+
// plus subdirectories. Any `.md` file at the wiki root other
|
|
139
|
+
// than `index.md` itself violates the invariant, regardless of
|
|
140
|
+
// what its frontmatter `type:` claims. Keying off path rather
|
|
141
|
+
// than `data.type` catches the edge case of a hand-authored
|
|
142
|
+
// `foo.md` at root declared as `type: index` — it's still a
|
|
143
|
+
// loose root file the navigational model forbids. The rule is
|
|
144
|
+
// navigational: Claude reading `<root>/index.md` and following
|
|
145
|
+
// its `entries[]` should reach every leaf via a
|
|
146
|
+
// semantically-named category; loose root files bypass that
|
|
147
|
+
// mental model and bloat the top-level index.
|
|
148
|
+
const absDir = dirname(e.absolute);
|
|
149
|
+
const absName = basename(e.absolute);
|
|
150
|
+
if (absDir === wikiRoot && absName !== "index.md") {
|
|
151
|
+
push(
|
|
152
|
+
"error",
|
|
153
|
+
"LEAF-AT-WIKI-ROOT",
|
|
154
|
+
e.absolute,
|
|
155
|
+
`non-index markdown file at wiki root — must live in a subcategory (run 'fix' to contain)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
return findings;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// where.mjs — canonical "where am I installed?" report.
|
|
2
|
+
//
|
|
3
|
+
// Consumers need a reliable way to resolve the skill's install path
|
|
4
|
+
// without hard-coding `~/.claude/skills/...` or duplicating the
|
|
5
|
+
// @ctxr/kit path list. `skill-llm-wiki where` answers:
|
|
6
|
+
// - where is the skill root?
|
|
7
|
+
// - where is SKILL.md?
|
|
8
|
+
// - where is the templates/ directory?
|
|
9
|
+
// - where is the scripts/testkit/ directory?
|
|
10
|
+
// - what are the current package and format versions?
|
|
11
|
+
//
|
|
12
|
+
// Safe to invoke before the runtime-dep preflight resolves; uses
|
|
13
|
+
// only node:fs + node:path + node:url. No gray-matter, no transformers.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { FORMAT_VERSION } from "./contract.mjs";
|
|
19
|
+
|
|
20
|
+
// `where.mjs` lives at <SKILL_ROOT>/scripts/lib/where.mjs. The skill
|
|
21
|
+
// root is two directories up. Exported so other lib / testkit
|
|
22
|
+
// modules that need the skill root import this single source of
|
|
23
|
+
// truth (contract.mjs, templates.mjs, cli-run.mjs).
|
|
24
|
+
export const SKILL_ROOT = dirname(
|
|
25
|
+
dirname(dirname(fileURLToPath(import.meta.url))),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function readPackageVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(
|
|
31
|
+
readFileSync(join(SKILL_ROOT, "package.json"), "utf8"),
|
|
32
|
+
);
|
|
33
|
+
return pkg.version ?? "unknown";
|
|
34
|
+
} catch {
|
|
35
|
+
return "unknown";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pathIfExists(p) {
|
|
40
|
+
return existsSync(p) ? p : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getWhere() {
|
|
44
|
+
return {
|
|
45
|
+
schema: "skill-llm-wiki/where/v1",
|
|
46
|
+
skill_root: SKILL_ROOT,
|
|
47
|
+
skill_md: join(SKILL_ROOT, "SKILL.md"),
|
|
48
|
+
cli: join(SKILL_ROOT, "scripts", "cli.mjs"),
|
|
49
|
+
guide_dir: join(SKILL_ROOT, "guide"),
|
|
50
|
+
templates_dir: pathIfExists(join(SKILL_ROOT, "templates")),
|
|
51
|
+
testkit_dir: pathIfExists(join(SKILL_ROOT, "scripts", "testkit")),
|
|
52
|
+
package_version: readPackageVersion(),
|
|
53
|
+
format_version: FORMAT_VERSION,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Human-readable summary. Absolute paths, one per line, aligned so
|
|
58
|
+
// operators can eyeball things without parsing JSON.
|
|
59
|
+
export function renderWhereText(info) {
|
|
60
|
+
const lines = [
|
|
61
|
+
`skill_root: ${info.skill_root}`,
|
|
62
|
+
`skill_md: ${info.skill_md}`,
|
|
63
|
+
`cli: ${info.cli}`,
|
|
64
|
+
`guide_dir: ${info.guide_dir}`,
|
|
65
|
+
`templates_dir: ${info.templates_dir ?? "<not shipped>"}`,
|
|
66
|
+
`testkit_dir: ${info.testkit_dir ?? "<not shipped>"}`,
|
|
67
|
+
`package_version: ${info.package_version}`,
|
|
68
|
+
`format_version: ${info.format_version}`,
|
|
69
|
+
];
|
|
70
|
+
return lines.join("\n") + "\n";
|
|
71
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// assert-frontmatter.mjs — testkit helper: read a leaf, parse its
|
|
2
|
+
// frontmatter block, assert expected fields match.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately lightweight: does not import gray-matter. The
|
|
5
|
+
// opening `---` fence, body, and closing `---` fence pattern the
|
|
6
|
+
// skill emits is stable enough that a ~10-line parser is the right
|
|
7
|
+
// shape for a testkit. Tolerates both LF and CRLF line endings so
|
|
8
|
+
// consumer tests running on Windows runners don't see spurious
|
|
9
|
+
// parse failures.
|
|
10
|
+
//
|
|
11
|
+
// Zero runtime deps; pure Node built-ins.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
// Both fences match CRLF and LF: git on Windows checks repos out
|
|
16
|
+
// with native line endings by default. Capture group on FM_START is
|
|
17
|
+
// the length of the consumed fence so the caller can slice past it.
|
|
18
|
+
const FM_START = /^---(\r?\n)/;
|
|
19
|
+
const FM_END = /\r?\n---(\r?\n|$)/;
|
|
20
|
+
|
|
21
|
+
// Parse a leaf's frontmatter block into a flat key: string-ish
|
|
22
|
+
// object. Only the shallow YAML shape the skill emits is
|
|
23
|
+
// supported. For full YAML-as-data consumers should use gray-matter
|
|
24
|
+
// in their own test code; this helper is for sanity checks.
|
|
25
|
+
export function readLeafFrontmatter(absLeafPath) {
|
|
26
|
+
const raw = readFileSync(absLeafPath, "utf8");
|
|
27
|
+
const startMatch = FM_START.exec(raw);
|
|
28
|
+
if (!startMatch) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`readLeafFrontmatter: ${absLeafPath} has no frontmatter block`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const afterFirst = raw.slice(startMatch[0].length);
|
|
34
|
+
const endMatch = FM_END.exec(afterFirst);
|
|
35
|
+
if (!endMatch) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`readLeafFrontmatter: ${absLeafPath} has an unterminated frontmatter block`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const block = afterFirst.slice(0, endMatch.index);
|
|
41
|
+
const data = {};
|
|
42
|
+
// A "pending key" is a top-level key with an empty RHS whose
|
|
43
|
+
// type isn't yet decided. The first indented continuation line
|
|
44
|
+
// picks: `- x` → list, `subkey: v` → map. Once decided, further
|
|
45
|
+
// continuations at the same indent extend the same container.
|
|
46
|
+
let pendingKey = null;
|
|
47
|
+
let pendingIndent = -1;
|
|
48
|
+
let pendingKind = null; // null | "list" | "map"
|
|
49
|
+
for (const line of block.split(/\r?\n/)) {
|
|
50
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
51
|
+
const indent = line.length - line.trimStart().length;
|
|
52
|
+
const trimmed = line.trimStart();
|
|
53
|
+
|
|
54
|
+
// Continuation: line is indented past the open key.
|
|
55
|
+
if (pendingKey !== null && indent > pendingIndent) {
|
|
56
|
+
const listMatch = /^-\s*(.*)$/.exec(trimmed);
|
|
57
|
+
if (listMatch && (pendingKind === null || pendingKind === "list")) {
|
|
58
|
+
if (pendingKind === null) {
|
|
59
|
+
data[pendingKey] = [];
|
|
60
|
+
pendingKind = "list";
|
|
61
|
+
}
|
|
62
|
+
data[pendingKey].push(unquote(listMatch[1].trim()));
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const nestedKv = /^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i.exec(trimmed);
|
|
66
|
+
if (nestedKv && (pendingKind === null || pendingKind === "map")) {
|
|
67
|
+
if (pendingKind === null) {
|
|
68
|
+
data[pendingKey] = {};
|
|
69
|
+
pendingKind = "map";
|
|
70
|
+
}
|
|
71
|
+
data[pendingKey][nestedKv[1]] = unquote(nestedKv[2].trim());
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Fall through: unknown continuation shape, ignore.
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// New top-level key ends any open container.
|
|
79
|
+
const kv = /^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i.exec(line);
|
|
80
|
+
if (!kv) continue;
|
|
81
|
+
pendingKey = null;
|
|
82
|
+
pendingIndent = -1;
|
|
83
|
+
pendingKind = null;
|
|
84
|
+
const key = kv[1];
|
|
85
|
+
const val = kv[2].trim();
|
|
86
|
+
if (val === "") {
|
|
87
|
+
// Empty RHS: open a pending key; the first continuation
|
|
88
|
+
// picks list vs map. Default to an empty object until
|
|
89
|
+
// decided — consumers that assert a key exists without
|
|
90
|
+
// inspecting its type still pass.
|
|
91
|
+
data[key] = {};
|
|
92
|
+
pendingKey = key;
|
|
93
|
+
pendingIndent = indent;
|
|
94
|
+
pendingKind = null;
|
|
95
|
+
} else if (val === "[]") {
|
|
96
|
+
data[key] = [];
|
|
97
|
+
} else if (val.startsWith("[") && val.endsWith("]")) {
|
|
98
|
+
data[key] = val
|
|
99
|
+
.slice(1, -1)
|
|
100
|
+
.split(",")
|
|
101
|
+
.map((s) => unquote(s.trim()))
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
} else {
|
|
104
|
+
data[key] = unquote(val);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unquote(s) {
|
|
111
|
+
if (
|
|
112
|
+
(s.startsWith('"') && s.endsWith('"')) ||
|
|
113
|
+
(s.startsWith("'") && s.endsWith("'"))
|
|
114
|
+
) {
|
|
115
|
+
return s.slice(1, -1);
|
|
116
|
+
}
|
|
117
|
+
return s;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Compare actual frontmatter to an expected subset. Only fields
|
|
121
|
+
// named in `expected` are checked; extra fields in the leaf are
|
|
122
|
+
// allowed. Arrays compare element-wise as strings; objects compare
|
|
123
|
+
// each expected key shallowly (string-equality) so consumers can
|
|
124
|
+
// write `{ source: { origin: "file", path: "foo.md" } }` against
|
|
125
|
+
// the skill's canonical frontmatter shape.
|
|
126
|
+
//
|
|
127
|
+
// Throws an Error when any mismatch is found. Returns the parsed
|
|
128
|
+
// frontmatter object on success.
|
|
129
|
+
export function assertFrontmatterShape(absLeafPath, expected) {
|
|
130
|
+
const data = readLeafFrontmatter(absLeafPath);
|
|
131
|
+
const mismatches = [];
|
|
132
|
+
for (const [key, want] of Object.entries(expected ?? {})) {
|
|
133
|
+
const got = data[key];
|
|
134
|
+
if (Array.isArray(want)) {
|
|
135
|
+
if (!Array.isArray(got)) {
|
|
136
|
+
mismatches.push(`${key}: expected array, got ${JSON.stringify(got)}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (got.length !== want.length || got.some((v, i) => String(v) !== String(want[i]))) {
|
|
140
|
+
mismatches.push(
|
|
141
|
+
`${key}: expected [${want.join(", ")}], got [${got.join(", ")}]`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (want !== null && typeof want === "object") {
|
|
147
|
+
if (got === null || typeof got !== "object" || Array.isArray(got)) {
|
|
148
|
+
mismatches.push(`${key}: expected object, got ${JSON.stringify(got)}`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
for (const [subKey, subWant] of Object.entries(want)) {
|
|
152
|
+
if (String(got[subKey]) !== String(subWant)) {
|
|
153
|
+
mismatches.push(
|
|
154
|
+
`${key}.${subKey}: expected ${JSON.stringify(subWant)}, got ${JSON.stringify(got[subKey])}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (String(got) !== String(want)) {
|
|
161
|
+
mismatches.push(`${key}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (mismatches.length > 0) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`assertFrontmatterShape failed for ${absLeafPath}:\n - ` +
|
|
167
|
+
mismatches.join("\n - "),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// cli-run.mjs — testkit helper: spawn the skill CLI as a child
|
|
2
|
+
// process, capture stdout/stderr/exitCode, optionally parse the
|
|
3
|
+
// --json envelope. Consumers use this in their test suites to drive
|
|
4
|
+
// the skill without re-implementing the spawn ceremony.
|
|
5
|
+
//
|
|
6
|
+
// Zero runtime deps; pure Node built-ins.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { SKILL_ROOT } from "../lib/where.mjs";
|
|
11
|
+
import { hasJsonFlag } from "../lib/json-envelope.mjs";
|
|
12
|
+
|
|
13
|
+
export const CLI_PATH = join(SKILL_ROOT, "scripts", "cli.mjs");
|
|
14
|
+
|
|
15
|
+
// Run the skill CLI with `args`. Returns an object of:
|
|
16
|
+
// { status, stdout, stderr, envelope, error }
|
|
17
|
+
//
|
|
18
|
+
// `envelope` is only populated when `args` includes `--json` or
|
|
19
|
+
// `--json-errors` AND the stdout parses as JSON. On parse failure it
|
|
20
|
+
// is `null` and the caller can inspect `stdout` directly.
|
|
21
|
+
//
|
|
22
|
+
// `error` is populated when the child process could not be spawned
|
|
23
|
+
// at all (ENOENT, EACCES, EPERM). When it is set, `status` will be
|
|
24
|
+
// `null` (spawnSync's convention) and the `error` field carries the
|
|
25
|
+
// Node errno. Consumers writing cross-platform tests need to see
|
|
26
|
+
// this to distinguish "CLI ran and exited with status X" from "CLI
|
|
27
|
+
// never ran".
|
|
28
|
+
//
|
|
29
|
+
// Note on environment: when `env` is supplied, the testkit forwards
|
|
30
|
+
// the parent process environment too (object-spread with the
|
|
31
|
+
// override). This is intentional for local test convenience; CI
|
|
32
|
+
// harnesses writing untrusted fixtures should scrub `process.env`
|
|
33
|
+
// before calling.
|
|
34
|
+
export function runCli(args, { cwd, env } = {}) {
|
|
35
|
+
const resolvedArgs = Array.isArray(args) ? args : [];
|
|
36
|
+
const r = spawnSync(process.execPath, [CLI_PATH, ...resolvedArgs], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
cwd: cwd ?? process.cwd(),
|
|
39
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
40
|
+
});
|
|
41
|
+
const wantJson = hasJsonFlag(resolvedArgs);
|
|
42
|
+
let envelope = null;
|
|
43
|
+
if (wantJson && r.stdout) {
|
|
44
|
+
// Two output shapes exist. Envelope subcommands (validate, init,
|
|
45
|
+
// heal) emit a single-line JSON object. contract/where emit
|
|
46
|
+
// pretty-printed JSON spanning multiple lines. Try parsing the
|
|
47
|
+
// full stdout first; if that fails, fall back to the last
|
|
48
|
+
// JSON-like line (handles envelope output that may be preceded
|
|
49
|
+
// by progress lines).
|
|
50
|
+
const full = r.stdout.trim();
|
|
51
|
+
try {
|
|
52
|
+
envelope = JSON.parse(full);
|
|
53
|
+
} catch {
|
|
54
|
+
const lines = full.split("\n");
|
|
55
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
56
|
+
const line = lines[i].trim();
|
|
57
|
+
if (!line.startsWith("{")) continue;
|
|
58
|
+
try {
|
|
59
|
+
envelope = JSON.parse(line);
|
|
60
|
+
break;
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
status: r.status,
|
|
69
|
+
stdout: r.stdout,
|
|
70
|
+
stderr: r.stderr,
|
|
71
|
+
envelope,
|
|
72
|
+
error: r.error ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Convenience: assert a clean run, throw on non-zero exit with the
|
|
77
|
+
// stderr attached so the consumer's test output is useful. When the
|
|
78
|
+
// child failed to spawn at all (ENOENT/EACCES), surface that
|
|
79
|
+
// explicitly rather than saying "exited null".
|
|
80
|
+
export function runCliOk(args, opts) {
|
|
81
|
+
const r = runCli(args, opts);
|
|
82
|
+
const argString = Array.isArray(args) ? args.join(" ") : "";
|
|
83
|
+
if (r.error) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`runCliOk: failed to spawn skill-llm-wiki ${argString}: ` +
|
|
86
|
+
`${r.error.code ?? "unknown"} — ${r.error.message}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (r.status !== 0) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`runCliOk: skill-llm-wiki ${argString} exited ${r.status}:\n${r.stderr}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return r;
|
|
95
|
+
}
|