@codyswann/lisa 2.155.7 → 2.156.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 (73) hide show
  1. package/package.json +1 -1
  2. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  3. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  4. package/plugins/lisa/rules/eager/wiki-knowledge-source.md +5 -3
  5. package/plugins/lisa/rules/reference/config-resolution.md +27 -0
  6. package/plugins/lisa/rules/reference/wiki-knowledge-source.md +10 -3
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-copilot/rules/eager/wiki-knowledge-source.md +5 -3
  15. package/plugins/lisa-copilot/rules/reference/config-resolution.md +27 -0
  16. package/plugins/lisa-copilot/rules/reference/wiki-knowledge-source.md +10 -3
  17. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +27 -0
  19. package/plugins/lisa-cursor/rules/wiki-knowledge-source-reference.mdc +10 -3
  20. package/plugins/lisa-cursor/rules/wiki-knowledge-source.mdc +5 -3
  21. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-agy/plugin.json +1 -1
  24. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails-agy/plugin.json +1 -1
  44. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  49. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/scripts/ensure-wiki.mjs +247 -0
  54. package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +14 -1
  55. package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +7 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/scripts/ensure-wiki.mjs +247 -0
  58. package/plugins/lisa-wiki-agy/skills/lisa-wiki-ingest/SKILL.md +14 -1
  59. package/plugins/lisa-wiki-agy/skills/lisa-wiki-query/SKILL.md +7 -1
  60. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-copilot/scripts/ensure-wiki.mjs +247 -0
  62. package/plugins/lisa-wiki-copilot/skills/lisa-wiki-ingest/SKILL.md +14 -1
  63. package/plugins/lisa-wiki-copilot/skills/lisa-wiki-query/SKILL.md +7 -1
  64. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/scripts/ensure-wiki.mjs +247 -0
  66. package/plugins/lisa-wiki-cursor/skills/lisa-wiki-ingest/SKILL.md +14 -1
  67. package/plugins/lisa-wiki-cursor/skills/lisa-wiki-query/SKILL.md +7 -1
  68. package/plugins/src/base/rules/eager/wiki-knowledge-source.md +5 -3
  69. package/plugins/src/base/rules/reference/config-resolution.md +27 -0
  70. package/plugins/src/base/rules/reference/wiki-knowledge-source.md +10 -3
  71. package/plugins/src/wiki/scripts/ensure-wiki.mjs +247 -0
  72. package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +14 -1
  73. package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +7 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-wiki.mjs — resolve the project's wiki root, mirroring/refreshing a
4
+ * remote wiki when one is configured. Dependency-free (Node built-ins only),
5
+ * so it stays portable to any downstream repo that installs the plugin.
6
+ *
7
+ * This is the single resolver that `lisa-wiki-query` and `lisa-wiki-ingest`
8
+ * call as step 0 so they never hardcode `wiki/` and never have to know whether
9
+ * the wiki is local or remote. The mode decision lives HERE, not in the skills:
10
+ *
11
+ * - LOCAL — the wiki lives in this repo (the common case). Resolve the wiki
12
+ * root from `wiki/lisa-wiki.config.json` (`wikiRoot`, default
13
+ * `wiki`) and return it. No network, effectively a no-op.
14
+ * - REMOTE — `.lisa.config.json` declares `wiki.source.url`. Maintain a
15
+ * gitignored mirror of that repo and return the wiki root inside
16
+ * it. Clone-if-missing, fetch+fast-forward when stale (TTL), and
17
+ * tolerate being offline (proceed with the existing mirror + warn).
18
+ *
19
+ * Config (consumer repo `.lisa.config.json`, with `.lisa.config.local.json`
20
+ * overriding per the config-resolution rule):
21
+ *
22
+ * "wiki": {
23
+ * "source": {
24
+ * "url": "git@github.com:org/wiki.git", // present => REMOTE mode
25
+ * "ref": "main", // default: remote HEAD / "main"
26
+ * "mirrorPath": ".lisa/wiki", // default; always gitignored
27
+ * "subdir": "wiki" // optional: wiki root within the repo
28
+ * },
29
+ * "ttlSeconds": 300 // skip the fetch if synced more recently
30
+ * }
31
+ *
32
+ * Usage: node ensure-wiki.mjs [--cwd <dir>] [--json] [--ttl <seconds>] [--offline]
33
+ * --cwd project dir to resolve config/mirror against (default cwd)
34
+ * --json emit {mode, wikiRoot, mirrored, fetched, stale, offline} on stdout
35
+ * --ttl override ttlSeconds (0 = always fetch)
36
+ * --offline never touch the network; use whatever is already on disk
37
+ *
38
+ * Output: the resolved absolute wiki root is the LAST line on stdout (so a
39
+ * caller can `WIKI_ROOT=$(node ensure-wiki.mjs | tail -1)`). All human-facing
40
+ * progress goes to stderr. Exit 0 = a usable wiki root was resolved; 1 = not.
41
+ */
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { execFileSync } from "node:child_process";
45
+
46
+ const GITIGNORE_BEGIN = "# BEGIN: AI GUARDRAILS WIKI MIRROR";
47
+ const GITIGNORE_END = "# END: AI GUARDRAILS WIKI MIRROR";
48
+ const DEFAULT_MIRROR = ".lisa/wiki";
49
+ const DEFAULT_TTL_SECONDS = 300;
50
+
51
+ function log(msg) {
52
+ process.stderr.write(`${msg}\n`);
53
+ }
54
+ function fail(msg) {
55
+ log(`✗ ${msg}`);
56
+ process.exit(1);
57
+ }
58
+ function readJsonSafe(file) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(file, "utf8"));
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ function git(args, cwd) {
66
+ return execFileSync("git", args, {
67
+ cwd,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ encoding: "utf8",
70
+ }).trim();
71
+ }
72
+
73
+ // ── args ────────────────────────────────────────────────────────────────────
74
+ const argv = process.argv.slice(2);
75
+ function flagValue(name) {
76
+ const i = argv.indexOf(name);
77
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : undefined;
78
+ }
79
+ const projectDir = path.resolve(flagValue("--cwd") ?? process.cwd());
80
+ const asJson = argv.includes("--json");
81
+ const offline = argv.includes("--offline");
82
+ const ttlOverride = flagValue("--ttl");
83
+
84
+ // ── resolve the wiki source from .lisa.config.json (+ .local override) ────────
85
+ const committed =
86
+ readJsonSafe(path.join(projectDir, ".lisa.config.json")) ?? {};
87
+ const local =
88
+ readJsonSafe(path.join(projectDir, ".lisa.config.local.json")) ?? {};
89
+ const wikiCfg = { ...(committed.wiki ?? {}), ...(local.wiki ?? {}) };
90
+ const source = {
91
+ ...(committed.wiki?.source ?? {}),
92
+ ...(local.wiki?.source ?? {}),
93
+ };
94
+ const ttlSeconds = Number(
95
+ ttlOverride ?? wikiCfg.ttlSeconds ?? DEFAULT_TTL_SECONDS
96
+ );
97
+
98
+ if (source.url !== undefined && typeof source.url !== "string") {
99
+ fail("`wiki.source.url` in .lisa.config.json must be a string");
100
+ }
101
+
102
+ function emit(result) {
103
+ if (asJson) process.stdout.write(`${JSON.stringify(result)}\n`);
104
+ else process.stdout.write(`${result.wikiRoot}\n`);
105
+ process.exit(0);
106
+ }
107
+
108
+ // ── LOCAL mode ────────────────────────────────────────────────────────────────
109
+ if (!source.url) {
110
+ const localCfg = readJsonSafe(
111
+ path.join(projectDir, "wiki", "lisa-wiki.config.json")
112
+ );
113
+ const wikiRoot = path.resolve(projectDir, localCfg?.wikiRoot ?? "wiki");
114
+ if (!fs.existsSync(path.join(wikiRoot, "index.md"))) {
115
+ log(
116
+ `⚠ no index.md under ${wikiRoot} — local wiki may not be scaffolded yet (run /lisa-wiki:setup)`
117
+ );
118
+ }
119
+ log(`✓ local wiki: ${wikiRoot}`);
120
+ emit({
121
+ mode: "local",
122
+ wikiRoot,
123
+ mirrored: false,
124
+ fetched: false,
125
+ stale: false,
126
+ offline,
127
+ });
128
+ }
129
+
130
+ // ── REMOTE mode ───────────────────────────────────────────────────────────────
131
+ const mirrorPath = path.resolve(
132
+ projectDir,
133
+ source.mirrorPath ?? DEFAULT_MIRROR
134
+ );
135
+ const ref = source.ref || "";
136
+ ensureGitignored(projectDir, source.mirrorPath ?? DEFAULT_MIRROR);
137
+
138
+ const isClone = fs.existsSync(path.join(mirrorPath, ".git"));
139
+ let fetched = false;
140
+ let stale = false;
141
+
142
+ if (!isClone) {
143
+ if (offline) fail(`offline and no mirror present at ${mirrorPath}`);
144
+ log(`↧ cloning wiki ${source.url} → ${mirrorPath}`);
145
+ try {
146
+ fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
147
+ const cloneArgs = ["clone", "--depth", "1"];
148
+ if (ref) cloneArgs.push("--branch", ref);
149
+ cloneArgs.push(source.url, mirrorPath);
150
+ git(cloneArgs, projectDir);
151
+ fetched = true;
152
+ stampSync(mirrorPath);
153
+ } catch (e) {
154
+ fail(`clone failed: ${String(e.message ?? e).split("\n")[0]}`);
155
+ }
156
+ } else if (offline) {
157
+ log("• offline — using existing mirror without fetching");
158
+ stale = true;
159
+ } else if (isFresh(mirrorPath, ttlSeconds)) {
160
+ log(`• mirror synced < ${ttlSeconds}s ago — skipping fetch`);
161
+ } else {
162
+ // Stale: fetch and hard-reset. The mirror is a read-only working copy of the
163
+ // canonical wiki — never edited in place — so resetting to the remote ref is
164
+ // safe and keeps it byte-identical to upstream.
165
+ try {
166
+ const branch =
167
+ ref || git(["rev-parse", "--abbrev-ref", "HEAD"], mirrorPath);
168
+ log(`↻ refreshing wiki mirror (${branch})`);
169
+ git(["fetch", "--depth", "1", "origin", branch], mirrorPath);
170
+ git(["reset", "--hard", `origin/${branch}`], mirrorPath);
171
+ fetched = true;
172
+ stampSync(mirrorPath);
173
+ } catch (e) {
174
+ // Offline-tolerant: a fetch failure must not block work when we already
175
+ // have a usable (if stale) copy on disk.
176
+ log(
177
+ `⚠ refresh failed (${String(e.message ?? e).split("\n")[0]}) — using stale mirror`
178
+ );
179
+ stale = true;
180
+ }
181
+ }
182
+
183
+ const wikiRoot = resolveWikiRootInMirror(mirrorPath, source.subdir);
184
+ if (!fs.existsSync(path.join(wikiRoot, "index.md"))) {
185
+ log(
186
+ `⚠ no index.md under ${wikiRoot} — check wiki.source.subdir / the wiki repo layout`
187
+ );
188
+ }
189
+ log(`✓ remote wiki mirror: ${wikiRoot}`);
190
+ emit({ mode: "remote", wikiRoot, mirrored: true, fetched, stale, offline });
191
+
192
+ // ── helpers ───────────────────────────────────────────────────────────────────
193
+
194
+ /** Locate the wiki content root inside a cloned wiki repo. */
195
+ function resolveWikiRootInMirror(mirror, subdir) {
196
+ if (subdir) return path.resolve(mirror, subdir);
197
+ if (fs.existsSync(path.join(mirror, "wiki", "index.md")))
198
+ return path.join(mirror, "wiki");
199
+ return mirror; // dedicated wiki repo with content at its root
200
+ }
201
+
202
+ /** TTL stamp lives under .git so it is never part of the wiki content. */
203
+ function stampPath(mirror) {
204
+ return path.join(mirror, ".git", "lisa-wiki-sync");
205
+ }
206
+ function stampSync(mirror) {
207
+ try {
208
+ fs.writeFileSync(stampPath(mirror), String(Date.now()));
209
+ } catch {
210
+ /* best-effort */
211
+ }
212
+ }
213
+ function isFresh(mirror, ttl) {
214
+ if (ttl <= 0) return false;
215
+ let last = NaN;
216
+ try {
217
+ last = Number(fs.readFileSync(stampPath(mirror), "utf8"));
218
+ } catch {
219
+ return false;
220
+ }
221
+ if (!Number.isFinite(last)) return false;
222
+ return Date.now() - last < ttl * 1000;
223
+ }
224
+
225
+ /** Idempotently keep the mirror path out of version control. */
226
+ function ensureGitignored(dir, relPath) {
227
+ const gitignorePath = path.join(dir, ".gitignore");
228
+ const line = `/${relPath.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
229
+ const block = `${GITIGNORE_BEGIN}\n# Remote wiki mirror — gitignored working copy, managed by ensure-wiki.\n${line}\n${GITIGNORE_END}`;
230
+ const existing = fs.existsSync(gitignorePath)
231
+ ? fs.readFileSync(gitignorePath, "utf8")
232
+ : null;
233
+ if (existing && existing.includes(line)) return; // already ignored (any block)
234
+ const start = existing?.indexOf(GITIGNORE_BEGIN) ?? -1;
235
+ let merged;
236
+ if (existing === null) {
237
+ merged = `${block}\n`;
238
+ } else if (start !== -1) {
239
+ const end = existing.indexOf(GITIGNORE_END, start) + GITIGNORE_END.length;
240
+ merged = `${existing.slice(0, start)}${block}${existing.slice(end)}`;
241
+ } else {
242
+ const base = existing.endsWith("\n") ? existing : `${existing}\n`;
243
+ merged = `${base}\n${block}\n`;
244
+ }
245
+ fs.writeFileSync(gitignorePath, merged);
246
+ log(`✓ ensured ${line} is gitignored`);
247
+ }
@@ -19,10 +19,23 @@ performs the shared, ordered pipeline.
19
19
  run includes explicit external-write intent**.
20
20
  - **Dry run:** `/ingest --dry-run` — list the sources a full ingest would run; perform no writes.
21
21
 
22
+ ## Step 0 — resolve the wiki root (once per run)
23
+
24
+ Before anything else, run `node scripts/ensure-wiki.mjs --json` and use the returned `wikiRoot` as the
25
+ base for the whole pipeline — never hardcode `wiki/`. This is mode-agnostic by design:
26
+
27
+ - **Local wiki** (no `wiki.source` in `.lisa.config.json`) — resolves the in-repo wiki root instantly;
28
+ the branch sync below proceeds against **this** repo's remote, exactly as before.
29
+ - **Remote wiki** (`wiki.source.url` set) — `ensure-wiki` mirrors/refreshes the gitignored working copy
30
+ of the canonical wiki repo and returns its path. In this mode the "sync the branch" step below, and
31
+ the Commit/PR step (7), operate against the **wiki repo's own remote** (the mirror), not the host
32
+ project's `origin`. The host project repo is never written to.
33
+
22
34
  ## Before ingesting — sync the branch (once per run)
23
35
 
24
36
  Run this **once per ingest invocation, before any source is processed** (skip for `--dry-run`, which
25
- writes nothing). The point is to ingest on top of fresh state, never stale state.
37
+ writes nothing). The point is to ingest on top of fresh state, never stale state. Operate in the wiki
38
+ root resolved in Step 0 — the host repo for a local wiki, the mirror for a remote one.
26
39
 
27
40
  1. **Resolve the default remote branch** — `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`,
28
41
  or `git remote show origin | sed -n 's/.*HEAD branch: //p'`, or the `origin/HEAD` symbolic ref. If
@@ -8,7 +8,13 @@ description: Answer a question from the LLM Wiki with citations. Reads the index
8
8
  Answer from the wiki, with citations, without changing it (by default).
9
9
 
10
10
  ## Workflow
11
- 1. Read `wiki/index.md` to locate candidate pages; consult `wiki/start-here.md` for orientation.
11
+ 0. **Resolve the wiki root.** Run `node scripts/ensure-wiki.mjs --json` and use the returned
12
+ `wikiRoot` as the base for every read below — never assume `wiki/`. A local wiki resolves
13
+ instantly (no-op); a wiki whose `.lisa.config.json` declares `wiki.source.url` is mirrored and
14
+ refreshed transparently first. The script is offline-tolerant (it proceeds with the existing
15
+ mirror and warns rather than blocking), so freshness is guaranteed here and the caller never has
16
+ to think about it.
17
+ 1. Read `<wikiRoot>/index.md` to locate candidate pages; consult `<wikiRoot>/start-here.md` for orientation.
12
18
  2. Drill into the relevant synthesis pages and their cited source notes.
13
19
  3. Synthesize an answer. **Every claim cites its wiki page and/or source note.** If the wiki does not
14
20
  support an answer, say so plainly rather than inventing one; suggest an `/ingest` that would fill
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-wiki.mjs — resolve the project's wiki root, mirroring/refreshing a
4
+ * remote wiki when one is configured. Dependency-free (Node built-ins only),
5
+ * so it stays portable to any downstream repo that installs the plugin.
6
+ *
7
+ * This is the single resolver that `lisa-wiki-query` and `lisa-wiki-ingest`
8
+ * call as step 0 so they never hardcode `wiki/` and never have to know whether
9
+ * the wiki is local or remote. The mode decision lives HERE, not in the skills:
10
+ *
11
+ * - LOCAL — the wiki lives in this repo (the common case). Resolve the wiki
12
+ * root from `wiki/lisa-wiki.config.json` (`wikiRoot`, default
13
+ * `wiki`) and return it. No network, effectively a no-op.
14
+ * - REMOTE — `.lisa.config.json` declares `wiki.source.url`. Maintain a
15
+ * gitignored mirror of that repo and return the wiki root inside
16
+ * it. Clone-if-missing, fetch+fast-forward when stale (TTL), and
17
+ * tolerate being offline (proceed with the existing mirror + warn).
18
+ *
19
+ * Config (consumer repo `.lisa.config.json`, with `.lisa.config.local.json`
20
+ * overriding per the config-resolution rule):
21
+ *
22
+ * "wiki": {
23
+ * "source": {
24
+ * "url": "git@github.com:org/wiki.git", // present => REMOTE mode
25
+ * "ref": "main", // default: remote HEAD / "main"
26
+ * "mirrorPath": ".lisa/wiki", // default; always gitignored
27
+ * "subdir": "wiki" // optional: wiki root within the repo
28
+ * },
29
+ * "ttlSeconds": 300 // skip the fetch if synced more recently
30
+ * }
31
+ *
32
+ * Usage: node ensure-wiki.mjs [--cwd <dir>] [--json] [--ttl <seconds>] [--offline]
33
+ * --cwd project dir to resolve config/mirror against (default cwd)
34
+ * --json emit {mode, wikiRoot, mirrored, fetched, stale, offline} on stdout
35
+ * --ttl override ttlSeconds (0 = always fetch)
36
+ * --offline never touch the network; use whatever is already on disk
37
+ *
38
+ * Output: the resolved absolute wiki root is the LAST line on stdout (so a
39
+ * caller can `WIKI_ROOT=$(node ensure-wiki.mjs | tail -1)`). All human-facing
40
+ * progress goes to stderr. Exit 0 = a usable wiki root was resolved; 1 = not.
41
+ */
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { execFileSync } from "node:child_process";
45
+
46
+ const GITIGNORE_BEGIN = "# BEGIN: AI GUARDRAILS WIKI MIRROR";
47
+ const GITIGNORE_END = "# END: AI GUARDRAILS WIKI MIRROR";
48
+ const DEFAULT_MIRROR = ".lisa/wiki";
49
+ const DEFAULT_TTL_SECONDS = 300;
50
+
51
+ function log(msg) {
52
+ process.stderr.write(`${msg}\n`);
53
+ }
54
+ function fail(msg) {
55
+ log(`✗ ${msg}`);
56
+ process.exit(1);
57
+ }
58
+ function readJsonSafe(file) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(file, "utf8"));
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ function git(args, cwd) {
66
+ return execFileSync("git", args, {
67
+ cwd,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ encoding: "utf8",
70
+ }).trim();
71
+ }
72
+
73
+ // ── args ────────────────────────────────────────────────────────────────────
74
+ const argv = process.argv.slice(2);
75
+ function flagValue(name) {
76
+ const i = argv.indexOf(name);
77
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : undefined;
78
+ }
79
+ const projectDir = path.resolve(flagValue("--cwd") ?? process.cwd());
80
+ const asJson = argv.includes("--json");
81
+ const offline = argv.includes("--offline");
82
+ const ttlOverride = flagValue("--ttl");
83
+
84
+ // ── resolve the wiki source from .lisa.config.json (+ .local override) ────────
85
+ const committed =
86
+ readJsonSafe(path.join(projectDir, ".lisa.config.json")) ?? {};
87
+ const local =
88
+ readJsonSafe(path.join(projectDir, ".lisa.config.local.json")) ?? {};
89
+ const wikiCfg = { ...(committed.wiki ?? {}), ...(local.wiki ?? {}) };
90
+ const source = {
91
+ ...(committed.wiki?.source ?? {}),
92
+ ...(local.wiki?.source ?? {}),
93
+ };
94
+ const ttlSeconds = Number(
95
+ ttlOverride ?? wikiCfg.ttlSeconds ?? DEFAULT_TTL_SECONDS
96
+ );
97
+
98
+ if (source.url !== undefined && typeof source.url !== "string") {
99
+ fail("`wiki.source.url` in .lisa.config.json must be a string");
100
+ }
101
+
102
+ function emit(result) {
103
+ if (asJson) process.stdout.write(`${JSON.stringify(result)}\n`);
104
+ else process.stdout.write(`${result.wikiRoot}\n`);
105
+ process.exit(0);
106
+ }
107
+
108
+ // ── LOCAL mode ────────────────────────────────────────────────────────────────
109
+ if (!source.url) {
110
+ const localCfg = readJsonSafe(
111
+ path.join(projectDir, "wiki", "lisa-wiki.config.json")
112
+ );
113
+ const wikiRoot = path.resolve(projectDir, localCfg?.wikiRoot ?? "wiki");
114
+ if (!fs.existsSync(path.join(wikiRoot, "index.md"))) {
115
+ log(
116
+ `⚠ no index.md under ${wikiRoot} — local wiki may not be scaffolded yet (run /lisa-wiki:setup)`
117
+ );
118
+ }
119
+ log(`✓ local wiki: ${wikiRoot}`);
120
+ emit({
121
+ mode: "local",
122
+ wikiRoot,
123
+ mirrored: false,
124
+ fetched: false,
125
+ stale: false,
126
+ offline,
127
+ });
128
+ }
129
+
130
+ // ── REMOTE mode ───────────────────────────────────────────────────────────────
131
+ const mirrorPath = path.resolve(
132
+ projectDir,
133
+ source.mirrorPath ?? DEFAULT_MIRROR
134
+ );
135
+ const ref = source.ref || "";
136
+ ensureGitignored(projectDir, source.mirrorPath ?? DEFAULT_MIRROR);
137
+
138
+ const isClone = fs.existsSync(path.join(mirrorPath, ".git"));
139
+ let fetched = false;
140
+ let stale = false;
141
+
142
+ if (!isClone) {
143
+ if (offline) fail(`offline and no mirror present at ${mirrorPath}`);
144
+ log(`↧ cloning wiki ${source.url} → ${mirrorPath}`);
145
+ try {
146
+ fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
147
+ const cloneArgs = ["clone", "--depth", "1"];
148
+ if (ref) cloneArgs.push("--branch", ref);
149
+ cloneArgs.push(source.url, mirrorPath);
150
+ git(cloneArgs, projectDir);
151
+ fetched = true;
152
+ stampSync(mirrorPath);
153
+ } catch (e) {
154
+ fail(`clone failed: ${String(e.message ?? e).split("\n")[0]}`);
155
+ }
156
+ } else if (offline) {
157
+ log("• offline — using existing mirror without fetching");
158
+ stale = true;
159
+ } else if (isFresh(mirrorPath, ttlSeconds)) {
160
+ log(`• mirror synced < ${ttlSeconds}s ago — skipping fetch`);
161
+ } else {
162
+ // Stale: fetch and hard-reset. The mirror is a read-only working copy of the
163
+ // canonical wiki — never edited in place — so resetting to the remote ref is
164
+ // safe and keeps it byte-identical to upstream.
165
+ try {
166
+ const branch =
167
+ ref || git(["rev-parse", "--abbrev-ref", "HEAD"], mirrorPath);
168
+ log(`↻ refreshing wiki mirror (${branch})`);
169
+ git(["fetch", "--depth", "1", "origin", branch], mirrorPath);
170
+ git(["reset", "--hard", `origin/${branch}`], mirrorPath);
171
+ fetched = true;
172
+ stampSync(mirrorPath);
173
+ } catch (e) {
174
+ // Offline-tolerant: a fetch failure must not block work when we already
175
+ // have a usable (if stale) copy on disk.
176
+ log(
177
+ `⚠ refresh failed (${String(e.message ?? e).split("\n")[0]}) — using stale mirror`
178
+ );
179
+ stale = true;
180
+ }
181
+ }
182
+
183
+ const wikiRoot = resolveWikiRootInMirror(mirrorPath, source.subdir);
184
+ if (!fs.existsSync(path.join(wikiRoot, "index.md"))) {
185
+ log(
186
+ `⚠ no index.md under ${wikiRoot} — check wiki.source.subdir / the wiki repo layout`
187
+ );
188
+ }
189
+ log(`✓ remote wiki mirror: ${wikiRoot}`);
190
+ emit({ mode: "remote", wikiRoot, mirrored: true, fetched, stale, offline });
191
+
192
+ // ── helpers ───────────────────────────────────────────────────────────────────
193
+
194
+ /** Locate the wiki content root inside a cloned wiki repo. */
195
+ function resolveWikiRootInMirror(mirror, subdir) {
196
+ if (subdir) return path.resolve(mirror, subdir);
197
+ if (fs.existsSync(path.join(mirror, "wiki", "index.md")))
198
+ return path.join(mirror, "wiki");
199
+ return mirror; // dedicated wiki repo with content at its root
200
+ }
201
+
202
+ /** TTL stamp lives under .git so it is never part of the wiki content. */
203
+ function stampPath(mirror) {
204
+ return path.join(mirror, ".git", "lisa-wiki-sync");
205
+ }
206
+ function stampSync(mirror) {
207
+ try {
208
+ fs.writeFileSync(stampPath(mirror), String(Date.now()));
209
+ } catch {
210
+ /* best-effort */
211
+ }
212
+ }
213
+ function isFresh(mirror, ttl) {
214
+ if (ttl <= 0) return false;
215
+ let last = NaN;
216
+ try {
217
+ last = Number(fs.readFileSync(stampPath(mirror), "utf8"));
218
+ } catch {
219
+ return false;
220
+ }
221
+ if (!Number.isFinite(last)) return false;
222
+ return Date.now() - last < ttl * 1000;
223
+ }
224
+
225
+ /** Idempotently keep the mirror path out of version control. */
226
+ function ensureGitignored(dir, relPath) {
227
+ const gitignorePath = path.join(dir, ".gitignore");
228
+ const line = `/${relPath.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
229
+ const block = `${GITIGNORE_BEGIN}\n# Remote wiki mirror — gitignored working copy, managed by ensure-wiki.\n${line}\n${GITIGNORE_END}`;
230
+ const existing = fs.existsSync(gitignorePath)
231
+ ? fs.readFileSync(gitignorePath, "utf8")
232
+ : null;
233
+ if (existing && existing.includes(line)) return; // already ignored (any block)
234
+ const start = existing?.indexOf(GITIGNORE_BEGIN) ?? -1;
235
+ let merged;
236
+ if (existing === null) {
237
+ merged = `${block}\n`;
238
+ } else if (start !== -1) {
239
+ const end = existing.indexOf(GITIGNORE_END, start) + GITIGNORE_END.length;
240
+ merged = `${existing.slice(0, start)}${block}${existing.slice(end)}`;
241
+ } else {
242
+ const base = existing.endsWith("\n") ? existing : `${existing}\n`;
243
+ merged = `${base}\n${block}\n`;
244
+ }
245
+ fs.writeFileSync(gitignorePath, merged);
246
+ log(`✓ ensured ${line} is gitignored`);
247
+ }
@@ -19,10 +19,23 @@ performs the shared, ordered pipeline.
19
19
  run includes explicit external-write intent**.
20
20
  - **Dry run:** `/ingest --dry-run` — list the sources a full ingest would run; perform no writes.
21
21
 
22
+ ## Step 0 — resolve the wiki root (once per run)
23
+
24
+ Before anything else, run `node scripts/ensure-wiki.mjs --json` and use the returned `wikiRoot` as the
25
+ base for the whole pipeline — never hardcode `wiki/`. This is mode-agnostic by design:
26
+
27
+ - **Local wiki** (no `wiki.source` in `.lisa.config.json`) — resolves the in-repo wiki root instantly;
28
+ the branch sync below proceeds against **this** repo's remote, exactly as before.
29
+ - **Remote wiki** (`wiki.source.url` set) — `ensure-wiki` mirrors/refreshes the gitignored working copy
30
+ of the canonical wiki repo and returns its path. In this mode the "sync the branch" step below, and
31
+ the Commit/PR step (7), operate against the **wiki repo's own remote** (the mirror), not the host
32
+ project's `origin`. The host project repo is never written to.
33
+
22
34
  ## Before ingesting — sync the branch (once per run)
23
35
 
24
36
  Run this **once per ingest invocation, before any source is processed** (skip for `--dry-run`, which
25
- writes nothing). The point is to ingest on top of fresh state, never stale state.
37
+ writes nothing). The point is to ingest on top of fresh state, never stale state. Operate in the wiki
38
+ root resolved in Step 0 — the host repo for a local wiki, the mirror for a remote one.
26
39
 
27
40
  1. **Resolve the default remote branch** — `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`,
28
41
  or `git remote show origin | sed -n 's/.*HEAD branch: //p'`, or the `origin/HEAD` symbolic ref. If
@@ -8,7 +8,13 @@ description: Answer a question from the LLM Wiki with citations. Reads the index
8
8
  Answer from the wiki, with citations, without changing it (by default).
9
9
 
10
10
  ## Workflow
11
- 1. Read `wiki/index.md` to locate candidate pages; consult `wiki/start-here.md` for orientation.
11
+ 0. **Resolve the wiki root.** Run `node scripts/ensure-wiki.mjs --json` and use the returned
12
+ `wikiRoot` as the base for every read below — never assume `wiki/`. A local wiki resolves
13
+ instantly (no-op); a wiki whose `.lisa.config.json` declares `wiki.source.url` is mirrored and
14
+ refreshed transparently first. The script is offline-tolerant (it proceeds with the existing
15
+ mirror and warns rather than blocking), so freshness is guaranteed here and the caller never has
16
+ to think about it.
17
+ 1. Read `<wikiRoot>/index.md` to locate candidate pages; consult `<wikiRoot>/start-here.md` for orientation.
12
18
  2. Drill into the relevant synthesis pages and their cited source notes.
13
19
  3. Synthesize an answer. **Every claim cites its wiki page and/or source note.** If the wiki does not
14
20
  support an answer, say so plainly rather than inventing one; suggest an `/ingest` that would fill
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.155.7",
3
+ "version": "2.156.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"