@codyswann/lisa 2.155.6 → 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.
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/rules/eager/wiki-knowledge-source.md +5 -3
- package/plugins/lisa/rules/reference/config-resolution.md +27 -0
- package/plugins/lisa/rules/reference/wiki-knowledge-source.md +10 -3
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/wiki-knowledge-source.md +5 -3
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +27 -0
- package/plugins/lisa-copilot/rules/reference/wiki-knowledge-source.md +10 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +27 -0
- package/plugins/lisa-cursor/rules/wiki-knowledge-source-reference.mdc +10 -3
- package/plugins/lisa-cursor/rules/wiki-knowledge-source.mdc +5 -3
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/scripts/ensure-wiki.mjs +247 -0
- package/plugins/lisa-wiki/skills/lisa-wiki-ingest/SKILL.md +14 -1
- package/plugins/lisa-wiki/skills/lisa-wiki-query/SKILL.md +7 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/scripts/ensure-wiki.mjs +247 -0
- package/plugins/lisa-wiki-agy/skills/lisa-wiki-ingest/SKILL.md +14 -1
- package/plugins/lisa-wiki-agy/skills/lisa-wiki-query/SKILL.md +7 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/scripts/ensure-wiki.mjs +247 -0
- package/plugins/lisa-wiki-copilot/skills/lisa-wiki-ingest/SKILL.md +14 -1
- package/plugins/lisa-wiki-copilot/skills/lisa-wiki-query/SKILL.md +7 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/scripts/ensure-wiki.mjs +247 -0
- package/plugins/lisa-wiki-cursor/skills/lisa-wiki-ingest/SKILL.md +14 -1
- package/plugins/lisa-wiki-cursor/skills/lisa-wiki-query/SKILL.md +7 -1
- package/plugins/src/base/rules/eager/wiki-knowledge-source.md +5 -3
- package/plugins/src/base/rules/reference/config-resolution.md +27 -0
- package/plugins/src/base/rules/reference/wiki-knowledge-source.md +10 -3
- package/plugins/src/wiki/scripts/ensure-wiki.mjs +247 -0
- package/plugins/src/wiki/skills/lisa-wiki-ingest/SKILL.md +14 -1
- package/plugins/src/wiki/skills/lisa-wiki-query/SKILL.md +7 -1
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|