@ctxr/skill-llm-wiki 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- package/scripts/lib/validate.mjs +362 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// interactive.mjs — TTY-gated prompt helpers.
|
|
2
|
+
//
|
|
3
|
+
// The "ask, don't guess" rule from methodology section 9.4.3 has two
|
|
4
|
+
// enforcement layers: intent.mjs refuses ambiguous CLI invocations; this
|
|
5
|
+
// module handles interactive prompts for things that genuinely need a
|
|
6
|
+
// runtime yes/no (migration confirmation, Tier 1 install, --review
|
|
7
|
+
// checkpoints in Phase 7).
|
|
8
|
+
//
|
|
9
|
+
// Non-interactive mode is the default for CI, hooks, and any pipeline
|
|
10
|
+
// where stdin is not a TTY. Detection prefers explicit flags over
|
|
11
|
+
// heuristics, in this order:
|
|
12
|
+
//
|
|
13
|
+
// 1. `LLM_WIKI_NO_PROMPT=1` (env var) → never prompt
|
|
14
|
+
// 2. `--no-prompt` (CLI flag, surfaced via opts) → never prompt
|
|
15
|
+
// 3. `process.stdin.isTTY === false` → never prompt
|
|
16
|
+
// 4. otherwise → prompt
|
|
17
|
+
//
|
|
18
|
+
// When prompting is disabled, every prompt helper throws a
|
|
19
|
+
// `NonInteractiveError` that the caller is expected to surface as a
|
|
20
|
+
// structured CLI error. The caller chooses whether to exit, fall through,
|
|
21
|
+
// or translate into a different behaviour (Tier 1 install's silent
|
|
22
|
+
// fallthrough is the canonical example).
|
|
23
|
+
|
|
24
|
+
import { createInterface } from "node:readline";
|
|
25
|
+
|
|
26
|
+
export class NonInteractiveError extends Error {
|
|
27
|
+
constructor(question) {
|
|
28
|
+
super(
|
|
29
|
+
`skill-llm-wiki: cannot prompt "${question}" in non-interactive mode`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "NonInteractiveError";
|
|
32
|
+
this.question = question;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isInteractive(opts = {}) {
|
|
37
|
+
if (process.env.LLM_WIKI_NO_PROMPT === "1") return false;
|
|
38
|
+
if (opts.noPrompt) return false;
|
|
39
|
+
if (opts.forceInteractive) return true; // tests
|
|
40
|
+
// `process.stdin.isTTY` is `true` on a TTY, `undefined` on a pipe.
|
|
41
|
+
// Boolean() handles both: pipes become false, TTYs become true.
|
|
42
|
+
return Boolean(process.stdin && process.stdin.isTTY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Basic y/n prompt with a configurable default. Returns a boolean.
|
|
46
|
+
// Throws NonInteractiveError when prompts are disabled.
|
|
47
|
+
export async function confirm(question, opts = {}) {
|
|
48
|
+
if (!isInteractive(opts)) {
|
|
49
|
+
throw new NonInteractiveError(question);
|
|
50
|
+
}
|
|
51
|
+
const def = opts.default === undefined ? true : Boolean(opts.default);
|
|
52
|
+
const suffix = def ? " [Y/n] " : " [y/N] ";
|
|
53
|
+
const answer = await readLine(question + suffix);
|
|
54
|
+
if (answer === "") return def;
|
|
55
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Free-form text prompt with an optional default value.
|
|
59
|
+
export async function ask(question, opts = {}) {
|
|
60
|
+
if (!isInteractive(opts)) {
|
|
61
|
+
throw new NonInteractiveError(question);
|
|
62
|
+
}
|
|
63
|
+
const suffix = opts.default ? ` [${opts.default}] ` : " ";
|
|
64
|
+
const answer = await readLine(question + suffix);
|
|
65
|
+
if (answer === "" && opts.default !== undefined) return opts.default;
|
|
66
|
+
return answer.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Multiple-choice prompt. Returns the chosen option's `value`.
|
|
70
|
+
export async function choose(question, options, opts = {}) {
|
|
71
|
+
if (!isInteractive(opts)) {
|
|
72
|
+
throw new NonInteractiveError(question);
|
|
73
|
+
}
|
|
74
|
+
const lines = [question];
|
|
75
|
+
for (let i = 0; i < options.length; i++) {
|
|
76
|
+
lines.push(` ${i + 1}. ${options[i].label}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(`Choose 1-${options.length}: `);
|
|
79
|
+
while (true) {
|
|
80
|
+
const answer = await readLine(lines.join("\n"));
|
|
81
|
+
const idx = Number.parseInt(answer.trim(), 10);
|
|
82
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= options.length) {
|
|
83
|
+
return options[idx - 1].value;
|
|
84
|
+
}
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`skill-llm-wiki: please enter a number between 1 and ${options.length}\n`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readLine(prompt) {
|
|
92
|
+
return new Promise((resolvePromise) => {
|
|
93
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
94
|
+
rl.question(prompt, (answer) => {
|
|
95
|
+
rl.close();
|
|
96
|
+
resolvePromise(answer);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// migrate.mjs — move a legacy `<source>.llmwiki.v<N>/` wiki into the new
|
|
2
|
+
// `<source>.wiki/` sibling layout with a private git repo and an op-log
|
|
3
|
+
// entry recording the migration lineage.
|
|
4
|
+
//
|
|
5
|
+
// Atomicity guarantees:
|
|
6
|
+
// 1. The legacy folder is read-only to this module — never mutated,
|
|
7
|
+
// byte-identical before and after (success OR failure).
|
|
8
|
+
// 2. The destination is cleaned up on ANY exception between mkdirSync
|
|
9
|
+
// and the final commit. The user never has to manually rm a
|
|
10
|
+
// half-built sibling before retrying.
|
|
11
|
+
// 3. If the destination existed before this call (the caller should
|
|
12
|
+
// have refused via intent.mjs's INT-01 check, but defence in depth),
|
|
13
|
+
// we never touch it on failure — we only nuke directories this
|
|
14
|
+
// call created.
|
|
15
|
+
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
import { appendOpLog } from "./history.mjs";
|
|
19
|
+
import { ensureWikiGitignore } from "./gitignore.mjs";
|
|
20
|
+
import {
|
|
21
|
+
gitCommit,
|
|
22
|
+
gitHeadSha,
|
|
23
|
+
gitInit,
|
|
24
|
+
gitRunChecked,
|
|
25
|
+
gitTag,
|
|
26
|
+
} from "./git.mjs";
|
|
27
|
+
|
|
28
|
+
// Match legacy folder names of the form `<anything>.llmwiki.v<digits>`
|
|
29
|
+
// and extract the version integer. Returns null for non-matching inputs.
|
|
30
|
+
function parseLegacyVersion(legacyPath) {
|
|
31
|
+
const m = /\.llmwiki\.v(\d+)$/.exec(basename(legacyPath));
|
|
32
|
+
return m ? Number(m[1]) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Build the default migration destination for a legacy wiki.
|
|
36
|
+
// `<parent>/<base>.llmwiki.v3` → `<parent>/<base>.wiki`.
|
|
37
|
+
export function defaultMigrationTarget(legacyPath) {
|
|
38
|
+
const parent = dirname(legacyPath);
|
|
39
|
+
const name = basename(legacyPath);
|
|
40
|
+
const cleanName = name.replace(/\.llmwiki\.v\d+$/, "");
|
|
41
|
+
return join(parent, `${cleanName}.wiki`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Migrate a legacy wiki. Parameters:
|
|
45
|
+
// legacyPath absolute path to `<source>.llmwiki.v<N>/`
|
|
46
|
+
// newWikiPath absolute path to the new sibling destination
|
|
47
|
+
// opts.opId op-id string for the migration (caller-supplied so the
|
|
48
|
+
// surrounding CLI can correlate with its op-log record)
|
|
49
|
+
//
|
|
50
|
+
// Returns { opId, version, sha } on success.
|
|
51
|
+
// Throws if legacyPath is not a legacy wiki, or if newWikiPath already
|
|
52
|
+
// exists (collision resolution is the caller's job).
|
|
53
|
+
export function migrateLegacyWiki(legacyPath, newWikiPath, opts = {}) {
|
|
54
|
+
const version = parseLegacyVersion(legacyPath);
|
|
55
|
+
if (version === null) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`migrate: ${legacyPath} does not match the legacy .llmwiki.v<N> naming convention`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!existsSync(legacyPath)) {
|
|
61
|
+
throw new Error(`migrate: legacy wiki ${legacyPath} does not exist`);
|
|
62
|
+
}
|
|
63
|
+
if (existsSync(newWikiPath)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`migrate: destination ${newWikiPath} already exists; pick a new target or remove it first`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!opts.opId || typeof opts.opId !== "string") {
|
|
69
|
+
throw new Error("migrate: opts.opId is required");
|
|
70
|
+
}
|
|
71
|
+
// The destination must not pre-exist. We check and throw here — AFTER
|
|
72
|
+
// this point, any directory at `newWikiPath` was created by this
|
|
73
|
+
// specific call, so failure-cleanup can rmSync it unconditionally
|
|
74
|
+
// without fear of destroying an unrelated pre-existing directory.
|
|
75
|
+
if (existsSync(newWikiPath)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`migrate: destination ${newWikiPath} already exists; pick a new target or remove it first`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(newWikiPath, { recursive: true });
|
|
82
|
+
// Copy everything except the legacy sibling's own cruft. The legacy
|
|
83
|
+
// layout never had a `.llmwiki/` subdir of its own, so a full recursive
|
|
84
|
+
// copy is safe. `errorOnExist: false` is the default; explicit for clarity.
|
|
85
|
+
for (const entry of readdirSync(legacyPath, { withFileTypes: true })) {
|
|
86
|
+
cpSync(join(legacyPath, entry.name), join(newWikiPath, entry.name), {
|
|
87
|
+
recursive: true,
|
|
88
|
+
errorOnExist: false,
|
|
89
|
+
force: false,
|
|
90
|
+
preserveTimestamps: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Initialise the private git repo, stage everything, commit.
|
|
94
|
+
gitInit(newWikiPath);
|
|
95
|
+
ensureWikiGitignore(newWikiPath);
|
|
96
|
+
gitRunChecked(newWikiPath, ["add", "-A"]);
|
|
97
|
+
gitCommit(newWikiPath, `migrate from legacy .llmwiki.v${version}`);
|
|
98
|
+
const tagName = `op/${opts.opId}`;
|
|
99
|
+
gitTag(newWikiPath, tagName, "HEAD");
|
|
100
|
+
const sha = gitHeadSha(newWikiPath);
|
|
101
|
+
appendOpLog(newWikiPath, {
|
|
102
|
+
op_id: opts.opId,
|
|
103
|
+
operation: "migrate",
|
|
104
|
+
layout_mode: "sibling",
|
|
105
|
+
started: new Date().toISOString(),
|
|
106
|
+
finished: new Date().toISOString(),
|
|
107
|
+
base_commit: "legacy",
|
|
108
|
+
final_commit: sha || "",
|
|
109
|
+
summary: `migrated from ${legacyPath} (v${version})`,
|
|
110
|
+
});
|
|
111
|
+
return { opId: opts.opId, version, sha };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Atomic-rollback: remove the half-built destination so the user
|
|
114
|
+
// can retry cleanly. Safe to do unconditionally because the
|
|
115
|
+
// pre-existence check above guarantees this call is the one that
|
|
116
|
+
// created `newWikiPath`.
|
|
117
|
+
try {
|
|
118
|
+
rmSync(newWikiPath, { recursive: true, force: true });
|
|
119
|
+
} catch {
|
|
120
|
+
/* best effort — surface the original error anyway */
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { parseLegacyVersion };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// nest-applier.mjs — the applying NEST operator.
|
|
2
|
+
//
|
|
3
|
+
// Given a cluster proposal (set of sibling leaves all under the
|
|
4
|
+
// same parent directory) and a resolved slug (from Tier 2), move
|
|
5
|
+
// the leaves into a new subdirectory `<parent>/<slug>/`, rewrite
|
|
6
|
+
// each moved leaf's `parents[]` to point at the new parent, and
|
|
7
|
+
// bootstrap a minimal `index.md` stub in the new subdirectory.
|
|
8
|
+
// The parent index.md is NOT touched here — the caller re-runs
|
|
9
|
+
// `rebuildAllIndices` in its phase so the parent's entries[] is
|
|
10
|
+
// regenerated with the new subcategory replacing the moved leaves.
|
|
11
|
+
//
|
|
12
|
+
// Preconditions enforced here:
|
|
13
|
+
//
|
|
14
|
+
// 1. The resolved slug is a valid kebab-case directory name.
|
|
15
|
+
// 2. All leaves share the same parent directory.
|
|
16
|
+
// 3. The new subdirectory does not already exist.
|
|
17
|
+
// 4. None of the leaf target paths collide with existing files.
|
|
18
|
+
//
|
|
19
|
+
// On any precondition failure the function throws BEFORE touching
|
|
20
|
+
// the filesystem, so the phase's rollback guarantees (pre-op
|
|
21
|
+
// snapshot → reset on exception) remain byte-exact.
|
|
22
|
+
//
|
|
23
|
+
// We do NOT touch the private git here. The caller's phase
|
|
24
|
+
// pipeline `git add -A && git commit` after the applier runs is
|
|
25
|
+
// what records the change.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { basename, dirname, join } from "node:path";
|
|
36
|
+
import { parseFrontmatter, renderFrontmatter } from "./frontmatter.mjs";
|
|
37
|
+
|
|
38
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
39
|
+
|
|
40
|
+
export function validateSlug(slug) {
|
|
41
|
+
if (typeof slug !== "string") return false;
|
|
42
|
+
return SLUG_RE.test(slug);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve a slug that won't collide with a member leaf's id or with a
|
|
46
|
+
// non-member sibling in the same parent directory. The observed
|
|
47
|
+
// collision case (v0.4.1 novel-corpus run): Tier 2's propose_structure
|
|
48
|
+
// response picked slug="security" for a cluster whose members included
|
|
49
|
+
// a leaf with id="security", so after apply both the new subcategory's
|
|
50
|
+
// stub index.md AND the moved leaf carried id="security" — DUP-ID at
|
|
51
|
+
// validate time, forcing a full pipeline rollback. Pre-resolving here
|
|
52
|
+
// auto-suffixes the slug (deterministically: `-group`, then `-group-N`)
|
|
53
|
+
// until it's non-colliding, letting the NEST land on the first try.
|
|
54
|
+
// Non-collision slugs are returned unchanged; invalid slugs are left
|
|
55
|
+
// alone so applyNest's own validation can reject them with its usual
|
|
56
|
+
// error message.
|
|
57
|
+
export function resolveNestSlug(slug, proposal) {
|
|
58
|
+
if (!validateSlug(slug)) return slug;
|
|
59
|
+
if (
|
|
60
|
+
!proposal ||
|
|
61
|
+
!Array.isArray(proposal.leaves) ||
|
|
62
|
+
proposal.leaves.length === 0
|
|
63
|
+
) {
|
|
64
|
+
return slug;
|
|
65
|
+
}
|
|
66
|
+
const forbidden = collectForbiddenIds(proposal);
|
|
67
|
+
if (!forbidden.has(slug)) return slug;
|
|
68
|
+
// Try "-group" first (the natural human reading: "the group of X
|
|
69
|
+
// leaves"); fall back to numeric suffixes starting at -group-2
|
|
70
|
+
// because "-group" itself already occupies the slot that would
|
|
71
|
+
// otherwise be "-group-1". If the base slug is so long that
|
|
72
|
+
// "${slug}-group" overflows the 64-char SLUG_RE cap, short-circuit:
|
|
73
|
+
// all numeric candidates share the same prefix and will fail
|
|
74
|
+
// validation identically, so there's no point spinning the loop.
|
|
75
|
+
// Returning the original (colliding) slug propagates the failure
|
|
76
|
+
// to applyNest, which throws a clear "target subcategory already
|
|
77
|
+
// exists" error — strictly better than a silent spin.
|
|
78
|
+
const primary = `${slug}-group`;
|
|
79
|
+
if (!validateSlug(primary)) return slug;
|
|
80
|
+
if (!forbidden.has(primary)) return primary;
|
|
81
|
+
for (let i = 2; i < 100; i++) {
|
|
82
|
+
const candidate = `${slug}-group-${i}`;
|
|
83
|
+
if (!forbidden.has(candidate)) return candidate;
|
|
84
|
+
}
|
|
85
|
+
return slug;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectForbiddenIds(proposal) {
|
|
89
|
+
const forbidden = new Set();
|
|
90
|
+
for (const leaf of proposal.leaves) {
|
|
91
|
+
if (leaf?.data?.id) forbidden.add(leaf.data.id);
|
|
92
|
+
}
|
|
93
|
+
const parentDir = dirname(proposal.leaves[0].path);
|
|
94
|
+
const memberPaths = new Set(proposal.leaves.map((l) => l.path));
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(parentDir, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return forbidden;
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
// Skip the parent's own index.md: its id is the parent's basename
|
|
103
|
+
// (i.e., the parent directory name), not something the new
|
|
104
|
+
// subcategory could collide with. Parent-name collisions — where
|
|
105
|
+
// the slug equals the parent dir's name — are a separate case that
|
|
106
|
+
// applyNest itself rejects via its existsSync(targetDir) check.
|
|
107
|
+
if (entry.name === "index.md") continue;
|
|
108
|
+
const entryPath = join(parentDir, entry.name);
|
|
109
|
+
if (memberPaths.has(entryPath)) continue;
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
forbidden.add(entry.name);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
115
|
+
try {
|
|
116
|
+
const raw = readFileSync(entryPath, "utf8");
|
|
117
|
+
const { data } = parseFrontmatter(raw, entryPath);
|
|
118
|
+
if (data?.id) forbidden.add(data.id);
|
|
119
|
+
} catch {
|
|
120
|
+
/* skip unreadable siblings */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return forbidden;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function applyNest(wikiRoot, proposal, slug, opts = {}) {
|
|
127
|
+
void wikiRoot;
|
|
128
|
+
void opts;
|
|
129
|
+
if (!proposal || !Array.isArray(proposal.leaves) || proposal.leaves.length < 2) {
|
|
130
|
+
throw new Error("nest-applier: proposal must carry at least 2 leaves");
|
|
131
|
+
}
|
|
132
|
+
if (!validateSlug(slug)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`nest-applier: invalid slug "${slug}" (must match /^[a-z][a-z0-9-]{0,63}$/)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const parentDirs = new Set(proposal.leaves.map((l) => dirname(l.path)));
|
|
138
|
+
if (parentDirs.size !== 1) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`nest-applier: leaves belong to ${parentDirs.size} different parent dirs — cannot NEST across parents in one step`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const parentDir = parentDirs.values().next().value;
|
|
144
|
+
const targetDir = join(parentDir, slug);
|
|
145
|
+
if (existsSync(targetDir)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`nest-applier: target subcategory ${targetDir} already exists`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Precompute target paths and check for collisions BEFORE any
|
|
152
|
+
// filesystem mutation.
|
|
153
|
+
const moves = [];
|
|
154
|
+
for (const leaf of proposal.leaves) {
|
|
155
|
+
const targetPath = join(targetDir, basename(leaf.path));
|
|
156
|
+
if (existsSync(targetPath)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`nest-applier: target leaf path ${targetPath} already exists`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
moves.push({ from: leaf.path, to: targetPath, leaf });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Create the target directory and move each leaf into it,
|
|
165
|
+
// rewriting the parents[] field as we go. We use `renameSync`
|
|
166
|
+
// rather than a raw move so the private git sees the rename
|
|
167
|
+
// as a proper content-preserving move on the next `git add`.
|
|
168
|
+
mkdirSync(targetDir, { recursive: true });
|
|
169
|
+
for (const move of moves) {
|
|
170
|
+
const raw = readFileSync(move.from, "utf8");
|
|
171
|
+
const { data, body } = parseFrontmatter(raw, move.from);
|
|
172
|
+
// Rewrite parents[] to point at the new subcategory's
|
|
173
|
+
// index.md. The methodology says parents[] uses POSIX-relative
|
|
174
|
+
// paths; for a leaf at `<parent>/<slug>/foo.md`, the direct
|
|
175
|
+
// parent index.md lives at `<parent>/<slug>/index.md`, which
|
|
176
|
+
// is `index.md` relative to the leaf itself.
|
|
177
|
+
data.parents = ["index.md"];
|
|
178
|
+
writeFileSync(move.to, renderFrontmatter(data, body), "utf8");
|
|
179
|
+
// Unlink the old location. The new file has already been
|
|
180
|
+
// written, so this is a destructive move but at the same
|
|
181
|
+
// level as the rollback guarantee (which resets the working
|
|
182
|
+
// tree to the pre-op snapshot on any exception upstream).
|
|
183
|
+
rmSync(move.from, { force: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Bootstrap an index.md stub in the new subcategory. The stub is
|
|
187
|
+
// minimal: `id`, `type`, `depth_role`, `focus` (from the Tier 2
|
|
188
|
+
// cluster purpose, with a placeholder fallback), any shared tags
|
|
189
|
+
// and shared_covers that the cluster members have in common, and
|
|
190
|
+
// the `parents[]` / `generator` marker that the subsequent
|
|
191
|
+
// `rebuildAllIndices` pass fills in. No `activation_defaults`
|
|
192
|
+
// aggregation: routing is semantic now — Claude decides descent
|
|
193
|
+
// from `focus` and `shared_covers` rather than from a literal
|
|
194
|
+
// keyword/tag union. Per-leaf `activation` blocks stay on the
|
|
195
|
+
// leaves as optional semantic hints (see SKILL.md "Routing into
|
|
196
|
+
// guide.wiki/").
|
|
197
|
+
const sharedTags = intersectTags(proposal.leaves.map((l) => l.data.tags || []));
|
|
198
|
+
const sharedCovers = intersectCovers(proposal.leaves.map((l) => l.data.covers || []));
|
|
199
|
+
const purpose = typeof proposal.purpose === "string" ? proposal.purpose.trim() : "";
|
|
200
|
+
const stubData = {
|
|
201
|
+
id: slug,
|
|
202
|
+
type: "index",
|
|
203
|
+
depth_role: "subcategory",
|
|
204
|
+
focus: purpose || `subtree under ${slug}`,
|
|
205
|
+
};
|
|
206
|
+
if (sharedTags.length > 0) stubData.tags = sharedTags;
|
|
207
|
+
if (sharedCovers.length > 0) {
|
|
208
|
+
stubData.shared_covers = sharedCovers;
|
|
209
|
+
}
|
|
210
|
+
const stubBody =
|
|
211
|
+
"\n<!-- BEGIN AUTO-GENERATED NAVIGATION -->\n\n" +
|
|
212
|
+
"<!-- END AUTO-GENERATED NAVIGATION -->\n\n" +
|
|
213
|
+
"<!-- BEGIN AUTHORED ORIENTATION -->\n\n" +
|
|
214
|
+
"<!-- END AUTHORED ORIENTATION -->\n";
|
|
215
|
+
const stubPath = join(targetDir, "index.md");
|
|
216
|
+
writeFileSync(stubPath, renderFrontmatter(stubData, stubBody), "utf8");
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
target_dir: targetDir,
|
|
220
|
+
moved: moves.map((m) => ({ from: m.from, to: m.to })),
|
|
221
|
+
stub: stubPath,
|
|
222
|
+
shared_tags: sharedTags,
|
|
223
|
+
shared_covers: sharedCovers,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function intersectTags(lists) {
|
|
228
|
+
if (lists.length === 0) return [];
|
|
229
|
+
const first = new Set(lists[0]);
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const t of first) {
|
|
232
|
+
if (lists.every((l) => l.includes(t))) out.push(t);
|
|
233
|
+
}
|
|
234
|
+
return out.sort();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Deterministic intersection of cover strings across cluster
|
|
238
|
+
// members. Case-sensitive string equality. Result is sorted so
|
|
239
|
+
// stub bodies are byte-deterministic across rebuilds.
|
|
240
|
+
function intersectCovers(lists) {
|
|
241
|
+
if (lists.length === 0) return [];
|
|
242
|
+
if (lists.length === 1) return [];
|
|
243
|
+
const first = new Set(lists[0]);
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const item of first) {
|
|
246
|
+
if (lists.every((l) => l.includes(item))) out.push(item);
|
|
247
|
+
}
|
|
248
|
+
return out.sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Historical note: an `aggregateActivation(leaves)` helper used to
|
|
252
|
+
// live here. It unioned `activation.keyword_matches`,
|
|
253
|
+
// `activation.tag_matches`, `tags[]`, and `activation.escalation_from`
|
|
254
|
+
// across cluster members into a single `activation_defaults` block
|
|
255
|
+
// for the new subcategory stub. That block was the old literal-
|
|
256
|
+
// routing substrate — the router's deterministic descent rule was an
|
|
257
|
+
// AND-filter on `activation_defaults.tag_matches ∩ profile.tags`.
|
|
258
|
+
// The rule has been removed in favour of semantic routing (Claude
|
|
259
|
+
// matches on the stub's `focus` + `shared_covers`), so the helper
|
|
260
|
+
// has no callers and was deleted.
|