@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/LICENSE +21 -0
  3. package/README.md +484 -0
  4. package/SKILL.md +252 -0
  5. package/guide/basics/concepts.md +74 -0
  6. package/guide/basics/index.md +45 -0
  7. package/guide/basics/schema.md +140 -0
  8. package/guide/cli.md +256 -0
  9. package/guide/correctness/index.md +45 -0
  10. package/guide/correctness/invariants.md +89 -0
  11. package/guide/correctness/safety.md +96 -0
  12. package/guide/history/diff.md +110 -0
  13. package/guide/history/hidden-git.md +130 -0
  14. package/guide/history/index.md +52 -0
  15. package/guide/history/remote-sync.md +113 -0
  16. package/guide/index.md +134 -0
  17. package/guide/isolation/coexistence.md +134 -0
  18. package/guide/isolation/index.md +44 -0
  19. package/guide/isolation/scale.md +251 -0
  20. package/guide/layout/in-place-mode.md +97 -0
  21. package/guide/layout/index.md +53 -0
  22. package/guide/layout/layout-contract.md +131 -0
  23. package/guide/layout/layout-modes.md +115 -0
  24. package/guide/operations/index.md +76 -0
  25. package/guide/operations/ingest/build.md +75 -0
  26. package/guide/operations/ingest/extend.md +61 -0
  27. package/guide/operations/ingest/index.md +54 -0
  28. package/guide/operations/ingest/join.md +65 -0
  29. package/guide/operations/maintain/fix.md +66 -0
  30. package/guide/operations/maintain/index.md +47 -0
  31. package/guide/operations/maintain/rebuild.md +86 -0
  32. package/guide/operations/validate.md +48 -0
  33. package/guide/substrate/index.md +47 -0
  34. package/guide/substrate/operators.md +96 -0
  35. package/guide/substrate/tiered-ai.md +363 -0
  36. package/guide/ux/index.md +44 -0
  37. package/guide/ux/preflight.md +150 -0
  38. package/guide/ux/user-intent.md +135 -0
  39. package/package.json +55 -0
  40. package/scripts/cli.mjs +893 -0
  41. package/scripts/commands/remote.mjs +93 -0
  42. package/scripts/commands/review.mjs +253 -0
  43. package/scripts/commands/sync.mjs +84 -0
  44. package/scripts/lib/chunk.mjs +421 -0
  45. package/scripts/lib/cluster-detect.mjs +516 -0
  46. package/scripts/lib/decision-log.mjs +343 -0
  47. package/scripts/lib/draft.mjs +158 -0
  48. package/scripts/lib/embeddings.mjs +366 -0
  49. package/scripts/lib/frontmatter.mjs +497 -0
  50. package/scripts/lib/git-commands.mjs +155 -0
  51. package/scripts/lib/git.mjs +486 -0
  52. package/scripts/lib/gitignore.mjs +62 -0
  53. package/scripts/lib/history.mjs +331 -0
  54. package/scripts/lib/indices.mjs +510 -0
  55. package/scripts/lib/ingest.mjs +258 -0
  56. package/scripts/lib/intent.mjs +713 -0
  57. package/scripts/lib/interactive.mjs +99 -0
  58. package/scripts/lib/migrate.mjs +126 -0
  59. package/scripts/lib/nest-applier.mjs +260 -0
  60. package/scripts/lib/operators.mjs +1365 -0
  61. package/scripts/lib/orchestrator.mjs +718 -0
  62. package/scripts/lib/paths.mjs +197 -0
  63. package/scripts/lib/preflight.mjs +213 -0
  64. package/scripts/lib/provenance.mjs +672 -0
  65. package/scripts/lib/quality-metric.mjs +269 -0
  66. package/scripts/lib/query-fixture.mjs +71 -0
  67. package/scripts/lib/rollback.mjs +95 -0
  68. package/scripts/lib/shape-check.mjs +172 -0
  69. package/scripts/lib/similarity-cache.mjs +126 -0
  70. package/scripts/lib/similarity.mjs +230 -0
  71. package/scripts/lib/snapshot.mjs +54 -0
  72. package/scripts/lib/source-frontmatter.mjs +85 -0
  73. package/scripts/lib/tier2-protocol.mjs +470 -0
  74. package/scripts/lib/tiered.mjs +453 -0
  75. package/scripts/lib/validate.mjs +362 -0
@@ -0,0 +1,486 @@
1
+ // git.mjs — the ONE place in the skill that spawns `git` subprocesses.
2
+ //
3
+ // Everything runs through a strictly isolated environment so our private
4
+ // per-wiki repository at <wiki>/.llmwiki/git/ never reads from, writes to,
5
+ // or otherwise interferes with a user's own git repository — even when the
6
+ // wiki lives inside their working tree.
7
+ //
8
+ // Naming note: GIT_DIR, GIT_WORK_TREE, GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL,
9
+ // HOME, GIT_TERMINAL_PROMPT, GIT_OPTIONAL_LOCKS, GIT_AUTHOR_* and
10
+ // GIT_COMMITTER_* are git's own environment variables — we cannot rename
11
+ // them. They are set only in the per-subprocess `env` option passed to
12
+ // spawnSync; they never mutate process.env and never touch the user's
13
+ // shell. Skill-owned env vars use the namespaced LLM_WIKI_* prefix.
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ export const IS_WINDOWS = process.platform === "win32";
21
+ const NULL_DEVICE = IS_WINDOWS ? "NUL" : "/dev/null";
22
+
23
+ // Per-invocation `-c` flags applied to every git call. These override whatever
24
+ // the user (or system) set in their own gitconfig. Duplicated in the env block
25
+ // via GIT_CONFIG_* where possible; keeping them as CLI args is defence in depth.
26
+ const FORCED_CONFIG_FLAGS = [
27
+ "-c",
28
+ "commit.gpgsign=false",
29
+ "-c",
30
+ "tag.gpgsign=false",
31
+ "-c",
32
+ `core.hooksPath=${NULL_DEVICE}`,
33
+ "-c",
34
+ "core.autocrlf=false",
35
+ "-c",
36
+ "core.fileMode=false",
37
+ "-c",
38
+ "core.longpaths=true",
39
+ ];
40
+
41
+ export function gitDir(wikiRoot) {
42
+ return join(wikiRoot, ".llmwiki", "git");
43
+ }
44
+
45
+ // Keys common to every isolated git invocation — used both by the full
46
+ // per-wiki env builder and by the "git --version" probe in preflight.mjs
47
+ // so both paths share one source of truth.
48
+ export const BASE_ISOLATION_ENV = Object.freeze({
49
+ GIT_CONFIG_NOSYSTEM: "1",
50
+ GIT_CONFIG_GLOBAL: NULL_DEVICE,
51
+ GIT_TERMINAL_PROMPT: "0",
52
+ GIT_OPTIONAL_LOCKS: "0",
53
+ });
54
+
55
+ // The fixed-timestamp override pins commit author/committer date so two
56
+ // runs against the same inputs produce byte-identical commit SHAs.
57
+ // Returns ISO 8601 or null when the env var is unset. Throws on malformed
58
+ // values so operator mistakes fail loud instead of silently dropping.
59
+ // Internal to this module — `gitCommit` is the sole caller.
60
+ function resolveFixedTimestamp() {
61
+ const raw = process.env.LLM_WIKI_FIXED_TIMESTAMP;
62
+ if (raw === undefined || raw === "") return null;
63
+ if (!/^\d+$/.test(raw)) {
64
+ throw new Error(
65
+ `LLM_WIKI_FIXED_TIMESTAMP must be a positive integer (epoch seconds); got "${raw}"`,
66
+ );
67
+ }
68
+ return new Date(Number(raw) * 1000).toISOString();
69
+ }
70
+
71
+ // The parent `process.env` may contain `GIT_*` variables set by the
72
+ // user's shell (e.g. `GIT_DIR`, `GIT_INDEX_FILE`, `GIT_NAMESPACE`,
73
+ // `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_SSH_COMMAND`,
74
+ // `GIT_AUTHOR_DATE`) that would bypass our isolation contract if we
75
+ // spread them verbatim into the subprocess env. Strip every `GIT_*`
76
+ // and `SSH_ASKPASS` key at the boundary, then re-populate ONLY the
77
+ // keys we explicitly control. This is the D3 fix from the Phase 8
78
+ // security sweep.
79
+ function sanitisedParentEnv() {
80
+ const out = {};
81
+ for (const [k, v] of Object.entries(process.env)) {
82
+ if (k.startsWith("GIT_")) continue;
83
+ if (k === "SSH_ASKPASS") continue;
84
+ out[k] = v;
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Build the isolation env block for a git subprocess. NEVER mutates
90
+ // process.env. Exported for test inspection. When `extraEnv` is provided,
91
+ // its keys are merged AFTER the base isolation keys, so callers can
92
+ // inject per-invocation overrides (e.g. GIT_AUTHOR_DATE for deterministic
93
+ // commits) without going around the builder.
94
+ export function buildGitEnv(wikiRoot, extraEnv = null) {
95
+ const env = {
96
+ ...sanitisedParentEnv(),
97
+ ...BASE_ISOLATION_ENV,
98
+ GIT_DIR: gitDir(wikiRoot),
99
+ GIT_WORK_TREE: wikiRoot,
100
+ HOME: tmpdir(),
101
+ GIT_AUTHOR_NAME: "skill-llm-wiki",
102
+ GIT_AUTHOR_EMAIL: "noreply@skill-llm-wiki.invalid",
103
+ GIT_COMMITTER_NAME: "skill-llm-wiki",
104
+ GIT_COMMITTER_EMAIL: "noreply@skill-llm-wiki.invalid",
105
+ };
106
+ if (extraEnv) Object.assign(env, extraEnv);
107
+ return env;
108
+ }
109
+
110
+ // Generic git invocation. Returns { status, stdout, stderr } exactly as
111
+ // spawnSync, with { encoding: "utf8" } applied and the isolation env.
112
+ // Throws with an actionable message if the subprocess failed to spawn
113
+ // OR was killed by a signal (so callers never silently continue on
114
+ // SIGINT/SIGTERM). `opts.extraEnv` is merged into the env block AFTER
115
+ // the standard isolation keys, for per-call overrides like fixed dates.
116
+ export function gitRun(wikiRoot, args, opts = {}) {
117
+ const { extraEnv, ...rest } = opts;
118
+ const env = buildGitEnv(wikiRoot, extraEnv);
119
+ const fullArgs = [...FORCED_CONFIG_FLAGS, ...args];
120
+ const result = spawnSync("git", fullArgs, {
121
+ env,
122
+ encoding: "utf8",
123
+ ...rest,
124
+ });
125
+ if (result.error) {
126
+ const err = new Error(
127
+ `git invocation failed to start: ${result.error.message}`,
128
+ );
129
+ err.cause = result.error;
130
+ throw err;
131
+ }
132
+ if (result.signal) {
133
+ const err = new Error(
134
+ `git ${args.join(" ")} killed by signal ${result.signal}`,
135
+ );
136
+ err.signal = result.signal;
137
+ throw err;
138
+ }
139
+ return {
140
+ status: result.status,
141
+ stdout: result.stdout ?? "",
142
+ stderr: result.stderr ?? "",
143
+ };
144
+ }
145
+
146
+ // Run git and throw on non-zero exit with the stderr attached.
147
+ // Every argv element and every stderr line that escapes this
148
+ // function runs through `redactUrl` first so embedded credentials
149
+ // never surface in shell transcripts, CI logs, or bug reports.
150
+ export function gitRunChecked(wikiRoot, args, opts = {}) {
151
+ const r = gitRun(wikiRoot, args, opts);
152
+ if (r.status !== 0) {
153
+ const redactedArgs = redactArgs(args);
154
+ const redactedStderr = redactUrl(r.stderr.trim());
155
+ const err = new Error(
156
+ `git ${redactedArgs.join(" ")} exited ${r.status}\n${redactedStderr}`,
157
+ );
158
+ err.stdout = redactUrl(r.stdout);
159
+ err.stderr = redactUrl(r.stderr);
160
+ err.status = r.status;
161
+ throw err;
162
+ }
163
+ return r;
164
+ }
165
+
166
+ // Lazy init: create the bare repo under .llmwiki/git/ if missing, commit
167
+ // an empty genesis tree tagged op/genesis, and install an internal
168
+ // info/exclude so `git add -A` never sweeps scratch directories.
169
+ //
170
+ // Crash-safe: a successful init is marked by the presence of `op/genesis`
171
+ // (not merely `.git/HEAD`). If an earlier run crashed part-way through
172
+ // setup, the next invocation detects the missing tag and completes the
173
+ // setup idempotently. On catastrophic failure during fresh init, the
174
+ // partially-created repo is removed so retries start clean.
175
+ export function gitInit(wikiRoot) {
176
+ const dir = gitDir(wikiRoot);
177
+ const alreadyInitialized =
178
+ existsSync(join(dir, "HEAD")) && gitRefExists(wikiRoot, "op/genesis");
179
+ if (alreadyInitialized) {
180
+ return { initialized: false, dir };
181
+ }
182
+ // Cleanup: if HEAD exists but op/genesis does not, the prior init was
183
+ // interrupted. Nuke the half-built metadata directory and start over.
184
+ if (existsSync(join(dir, "HEAD"))) {
185
+ rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ try {
188
+ mkdirSync(dir, { recursive: true });
189
+ // `git init` discovers $GIT_DIR from the env; --quiet keeps logs clean.
190
+ gitRunChecked(wikiRoot, ["init", "--quiet", "--initial-branch=main"]);
191
+ // Internal exclude — our private repo ignores these even if the user
192
+ // has no wiki-local .gitignore yet.
193
+ const excludePath = join(dir, "info", "exclude");
194
+ mkdirSync(join(dir, "info"), { recursive: true });
195
+ writeFileSync(
196
+ excludePath,
197
+ [
198
+ "# skill-llm-wiki internal exclude — do not edit",
199
+ ".work/",
200
+ ".shape/history/*/work/",
201
+ "",
202
+ ].join("\n"),
203
+ "utf8",
204
+ );
205
+ // Empty genesis commit so we always have a root to rollback to, even
206
+ // against an empty wiki directory. --allow-empty is the only way to
207
+ // commit with no files staged.
208
+ gitCommit(wikiRoot, "genesis", { allowEmpty: true });
209
+ gitTag(wikiRoot, "op/genesis", "HEAD");
210
+ } catch (err) {
211
+ // Clean up the half-built repo so subsequent retries don't see a
212
+ // corrupt state. Rethrow so the caller knows init failed.
213
+ try {
214
+ rmSync(dir, { recursive: true, force: true });
215
+ } catch {
216
+ /* best-effort cleanup; swallow to preserve the original error */
217
+ }
218
+ throw err;
219
+ }
220
+ return { initialized: true, dir };
221
+ }
222
+
223
+ // Create a commit. Timestamp pinning honours LLM_WIKI_FIXED_TIMESTAMP
224
+ // (skill-owned env var) for deterministic-SHA rebuilds, else uses the
225
+ // ambient wall clock. Malformed LLM_WIKI_FIXED_TIMESTAMP is a hard error
226
+ // surfaced from resolveFixedTimestamp — we never silently drop operator
227
+ // mistakes.
228
+ //
229
+ // Runs through gitRunChecked so the full diagnostic detail (stderr,
230
+ // cause-chained spawn errors, signal detection) is carried uniformly.
231
+ // We do NOT pass --no-verify because core.hooksPath=/dev/null already
232
+ // disables hooks at the config layer.
233
+ export function gitCommit(wikiRoot, message, opts = {}) {
234
+ const args = ["commit", "--quiet", "-m", message];
235
+ if (opts.allowEmpty) args.push("--allow-empty");
236
+ const extraEnv = {};
237
+ const iso = resolveFixedTimestamp();
238
+ if (iso) {
239
+ extraEnv.GIT_AUTHOR_DATE = iso;
240
+ extraEnv.GIT_COMMITTER_DATE = iso;
241
+ }
242
+ return gitRunChecked(wikiRoot, args, { extraEnv });
243
+ }
244
+
245
+ // Create an annotated-or-lightweight tag. Fails loud on collision by
246
+ // default: if `tagName` already exists pointing at a different commit,
247
+ // this throws with a clear message so operators never silently overwrite
248
+ // a prior op-id's rollback anchor. Pass `{ force: true }` only when the
249
+ // caller has verified the rewrite is intentional (e.g., migration).
250
+ // Reject refs/specs that would be parseable as a git flag. Every
251
+ // helper that accepts a user-controllable ref wraps its argv with an
252
+ // end-of-options separator (`--`) AND runs the ref through this guard
253
+ // at the boundary so a caller cannot smuggle `--git-dir=elsewhere` or
254
+ // similar via a crafted rollback / diff ref. The Phase 8 security
255
+ // audit flagged this as D2.
256
+ function assertSafeRef(ref, kind) {
257
+ if (typeof ref !== "string" || ref.length === 0) {
258
+ throw new Error(`${kind}: ref must be a non-empty string`);
259
+ }
260
+ if (ref.startsWith("-")) {
261
+ throw new Error(`${kind}: refs starting with '-' are refused: ${ref}`);
262
+ }
263
+ if (ref.includes("\0") || ref.includes("\n")) {
264
+ throw new Error(`${kind}: control characters in ref: ${JSON.stringify(ref)}`);
265
+ }
266
+ }
267
+
268
+ export function gitTag(wikiRoot, tagName, commitRef = "HEAD", opts = {}) {
269
+ assertSafeRef(tagName, "gitTag tagName");
270
+ assertSafeRef(commitRef, "gitTag commitRef");
271
+ const existing = gitRevParse(wikiRoot, `refs/tags/${tagName}`);
272
+ if (existing !== null) {
273
+ const target = gitRevParse(wikiRoot, commitRef);
274
+ if (existing === target) {
275
+ // Tag already points at the requested commit — idempotent no-op.
276
+ return { status: 0, stdout: "", stderr: "" };
277
+ }
278
+ if (!opts.force) {
279
+ throw new Error(
280
+ `gitTag: refusing to overwrite existing tag "${tagName}" ` +
281
+ `(points at ${existing.slice(0, 12)}, would move to ${String(target).slice(0, 12)}). ` +
282
+ `Pass { force: true } if the rewrite is intentional.`,
283
+ );
284
+ }
285
+ }
286
+ const args = ["tag"];
287
+ if (opts.force) args.push("-f");
288
+ args.push(tagName, commitRef);
289
+ return gitRunChecked(wikiRoot, args);
290
+ }
291
+
292
+ export function gitFsck(wikiRoot) {
293
+ const r = gitRun(wikiRoot, [
294
+ "fsck",
295
+ "--no-dangling",
296
+ "--no-reflogs",
297
+ "--no-progress",
298
+ ]);
299
+ return { ok: r.status === 0, stderr: r.stderr, stdout: r.stdout };
300
+ }
301
+
302
+ export function gitResetHard(wikiRoot, ref) {
303
+ assertSafeRef(ref, "gitResetHard");
304
+ return gitRunChecked(wikiRoot, ["reset", "--hard", "--quiet", ref]);
305
+ }
306
+
307
+ // Remove untracked files introduced since the last commit.
308
+ //
309
+ // We pass `-fd` (force, include directories) but deliberately OMIT `-x`
310
+ // so files matching `.gitignore` and the internal `.git/info/exclude` are
311
+ // preserved through a rollback. Specifically, this protects:
312
+ //
313
+ // - `.work/` — in-flight phase scratch
314
+ // - `.shape/history/*/work/` — archived per-op scratch
315
+ // - `.llmwiki/` — the private git repo itself + caches
316
+ //
317
+ // These paths are the user's "in-flight operation state". Wiping them on
318
+ // rollback would defeat resumable pipelines and destroy the op-log. If a
319
+ // future phase genuinely needs nuclear clean, add a dedicated helper that
320
+ // passes `-x` explicitly rather than changing this one.
321
+ export function gitClean(wikiRoot) {
322
+ return gitRunChecked(wikiRoot, ["clean", "-fd", "--quiet"]);
323
+ }
324
+
325
+ // Returns the resolved SHA, or null if the ref cannot be parsed.
326
+ export function gitRevParse(wikiRoot, ref) {
327
+ assertSafeRef(ref, "gitRevParse");
328
+ // `rev-parse` does not support `--` as an end-of-options separator
329
+ // before its refs argument; the leading-dash guard above is our only
330
+ // protection, which is why assertSafeRef is mandatory here.
331
+ const r = gitRun(wikiRoot, ["rev-parse", "--verify", "--quiet", ref]);
332
+ if (r.status !== 0) return null;
333
+ return r.stdout.trim() || null;
334
+ }
335
+
336
+ export function gitRefExists(wikiRoot, ref) {
337
+ return gitRevParse(wikiRoot, ref) !== null;
338
+ }
339
+
340
+ // Returns the HEAD SHA or null when the repo has no commits yet.
341
+ export function gitHeadSha(wikiRoot) {
342
+ return gitRevParse(wikiRoot, "HEAD");
343
+ }
344
+
345
+ // Run `git cat-file -s <spec>` and return the integer byte size, or null
346
+ // on failure. Used by the provenance verifier in later phases to read
347
+ // source sizes authoritatively from a pinned commit.
348
+ export function gitCatFileSize(wikiRoot, spec) {
349
+ assertSafeRef(spec, "gitCatFileSize");
350
+ const r = gitRun(wikiRoot, ["cat-file", "-s", spec]);
351
+ if (r.status !== 0) return null;
352
+ const n = Number(r.stdout.trim());
353
+ return Number.isFinite(n) ? n : null;
354
+ }
355
+
356
+ // ── URL credential redaction ─────────────────────────────────────────
357
+ //
358
+ // Remote URLs routinely carry embedded credentials for https auth:
359
+ // https://ghp_xxxxx@github.com/owner/repo.git
360
+ // https://user:token@host/repo.git
361
+ //
362
+ // Any code path that echoes a URL — error messages composed from
363
+ // argv, success lines that report "remote X added (Y)", log output
364
+ // — MUST run the URL through `redactUrl` first. A leaked token in
365
+ // a shell transcript or CI log is a credential-disclosure incident
366
+ // the skill is obligated to prevent.
367
+ //
368
+ // `redactArgs` applies the same redaction to every element of an
369
+ // argv array, preserving non-URL elements untouched. Used by
370
+ // `gitRunChecked`'s error-message builder.
371
+ export function redactUrl(value) {
372
+ if (typeof value !== "string") return value;
373
+ // Match URLs with userinfo: `scheme://user[:pass]@host...`. The
374
+ // replacement preserves scheme + host + path, stripping only the
375
+ // `user[:pass]@` component.
376
+ return value.replace(
377
+ /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/gi,
378
+ "$1<redacted>@",
379
+ );
380
+ }
381
+
382
+ export function redactArgs(args) {
383
+ return args.map((a) => redactUrl(a));
384
+ }
385
+
386
+ // ── Remote mirroring helpers (Phase 7) ───────────────────────────────
387
+ //
388
+ // Every remote operation flows through the isolation env like every
389
+ // other git call — the remote can be anywhere (local bare repo, ssh,
390
+ // https) and the subprocess still inherits `GIT_DIR`, `GIT_CONFIG_*`,
391
+ // `HOME=tmpdir()`, etc. The user's own `~/.gitconfig` credentials,
392
+ // signing keys, and push templates are NOT consulted; auth must flow
393
+ // via the URL or an out-of-band credential helper the user sets up
394
+ // explicitly. `GIT_TERMINAL_PROMPT=0` (already in the base env)
395
+ // prevents any password prompt from blocking a pipeline.
396
+ //
397
+ // By design:
398
+ // - We never auto-push. `gitPush` is only reachable via the
399
+ // `skill-llm-wiki sync` subcommand, which the user invokes
400
+ // explicitly.
401
+ // - We push tag refs by default, not branch heads, so a shared
402
+ // remote becomes a read-only history mirror rather than a
403
+ // competing HEAD.
404
+ // - We never fetch with --depth (shallow clones lose op history).
405
+
406
+ export function gitRemoteAdd(wikiRoot, name, url) {
407
+ if (!name || typeof name !== "string") {
408
+ throw new Error("gitRemoteAdd: remote name must be a non-empty string");
409
+ }
410
+ if (!url || typeof url !== "string") {
411
+ throw new Error("gitRemoteAdd: remote url must be a non-empty string");
412
+ }
413
+ return gitRunChecked(wikiRoot, ["remote", "add", name, url]);
414
+ }
415
+
416
+ export function gitRemoteRemove(wikiRoot, name) {
417
+ if (!name || typeof name !== "string") {
418
+ throw new Error("gitRemoteRemove: remote name must be a non-empty string");
419
+ }
420
+ return gitRunChecked(wikiRoot, ["remote", "remove", name]);
421
+ }
422
+
423
+ // List configured remotes as `{ name, url, fetch, push }` records.
424
+ // Parses `git remote -v` output which is the authoritative form.
425
+ // Returns an empty array when no remotes are configured.
426
+ export function gitRemoteList(wikiRoot) {
427
+ const r = gitRun(wikiRoot, ["remote", "-v"]);
428
+ // Defence-in-depth: redact any URL-looking content in stderr on
429
+ // failure, in case a corrupt config surfaces a remote URL in the
430
+ // error stream.
431
+ if (r.status !== 0) {
432
+ throw new Error(
433
+ `gitRemoteList: git remote -v exited ${r.status}: ${redactUrl(
434
+ r.stderr.trim(),
435
+ )}`,
436
+ );
437
+ }
438
+ const out = new Map();
439
+ for (const line of (r.stdout || "").split(/\r?\n/)) {
440
+ if (line === "") continue;
441
+ // `origin\thttps://.../repo.git (fetch)` or `(push)`
442
+ const m = /^(\S+)\t(\S+) \((fetch|push)\)$/.exec(line);
443
+ if (!m) continue;
444
+ const [, name, url, kind] = m;
445
+ if (!out.has(name)) out.set(name, { name, fetch: null, push: null });
446
+ out.get(name)[kind] = url;
447
+ }
448
+ return Array.from(out.values());
449
+ }
450
+
451
+ export function gitFetch(wikiRoot, remoteName = "origin") {
452
+ return gitRunChecked(wikiRoot, ["fetch", "--tags", "--no-recurse-submodules", remoteName]);
453
+ }
454
+
455
+ // Push tag refs to the remote. Defaults to pushing every `op/*` and
456
+ // `pre-op/*` tag in the private repo — the op history we want
457
+ // mirrored. Branch heads are explicitly NOT pushed by default; the
458
+ // caller must pass `refspecs` to push a branch.
459
+ export function gitPush(wikiRoot, remoteName = "origin", opts = {}) {
460
+ const { refspecs = ["refs/tags/op/*", "refs/tags/pre-op/*"], force = false } = opts;
461
+ const args = ["push"];
462
+ if (force) args.push("--force");
463
+ args.push(remoteName, ...refspecs);
464
+ return gitRunChecked(wikiRoot, args);
465
+ }
466
+
467
+ // Check whether the working tree has any tracked changes (staged or not).
468
+ // Returns true when `git diff --cached --quiet && git diff --quiet` would
469
+ // report a clean tree. Used by snapshot.mjs to avoid empty commits.
470
+ export function gitWorkingTreeClean(wikiRoot) {
471
+ const cached = gitRun(wikiRoot, ["diff", "--cached", "--quiet"]);
472
+ if (cached.status === 1) return false;
473
+ if (cached.status !== 0) {
474
+ throw new Error(
475
+ `git diff --cached exited ${cached.status}: ${cached.stderr.trim()}`,
476
+ );
477
+ }
478
+ const unstaged = gitRun(wikiRoot, ["diff", "--quiet"]);
479
+ if (unstaged.status === 1) return false;
480
+ if (unstaged.status !== 0) {
481
+ throw new Error(
482
+ `git diff exited ${unstaged.status}: ${unstaged.stderr.trim()}`,
483
+ );
484
+ }
485
+ return true;
486
+ }
@@ -0,0 +1,62 @@
1
+ // gitignore.mjs — the wiki-local `.gitignore` writer.
2
+ //
3
+ // Every skill-managed wiki ships a root `.gitignore` that hides our
4
+ // private metadata (`.llmwiki/`, `.work/`, `.shape/history/*/work/`) from
5
+ // any ancestor git repository the user might have wrapped around the
6
+ // wiki. The user's own project git thus never accidentally tracks our
7
+ // binary objects, but can still commit the wiki's plain-text content
8
+ // files as part of the project's history.
9
+ //
10
+ // Idempotent: a second run on an already-compliant file is a no-op. When
11
+ // the file pre-exists with user content, missing skill entries are
12
+ // appended in a clearly-marked block so the merge is reviewable.
13
+
14
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ export const REQUIRED_GITIGNORE_ENTRIES = Object.freeze([
18
+ ".llmwiki/",
19
+ ".work/",
20
+ ".shape/history/*/work/",
21
+ ]);
22
+
23
+ const HEADER_COMMENT =
24
+ "# skill-llm-wiki internal metadata — safe to gitignore in your own project";
25
+ const MARKER_COMMENT = "# skill-llm-wiki additions";
26
+
27
+ // Atomic write: write to a tmp file then rename over the target. A
28
+ // crash mid-write leaves either the old file or the new file, never
29
+ // a truncated `.gitignore` that would cause the user's own git to
30
+ // start tracking `.llmwiki/` on the next commit. D8 defence from the
31
+ // Phase 8 security sweep.
32
+ function atomicWrite(target, body) {
33
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
34
+ writeFileSync(tmp, body, "utf8");
35
+ renameSync(tmp, target);
36
+ }
37
+
38
+ // Write or merge <wiki>/.gitignore so it contains the required lines.
39
+ // Returns { created, updated, added } describing what happened.
40
+ export function ensureWikiGitignore(wikiRoot) {
41
+ const target = join(wikiRoot, ".gitignore");
42
+ if (!existsSync(target)) {
43
+ const body = [HEADER_COMMENT, ...REQUIRED_GITIGNORE_ENTRIES, ""].join("\n");
44
+ atomicWrite(target, body);
45
+ return { created: true, updated: false, added: [...REQUIRED_GITIGNORE_ENTRIES] };
46
+ }
47
+ const current = readFileSync(target, "utf8");
48
+ const trimmedLines = new Set(
49
+ current.split(/\r?\n/).map((l) => l.trim()),
50
+ );
51
+ const missing = REQUIRED_GITIGNORE_ENTRIES.filter(
52
+ (l) => !trimmedLines.has(l),
53
+ );
54
+ if (missing.length === 0) {
55
+ return { created: false, updated: false, added: [] };
56
+ }
57
+ const prefix = current.endsWith("\n") ? current : current + "\n";
58
+ const appended =
59
+ prefix + "\n" + MARKER_COMMENT + "\n" + missing.join("\n") + "\n";
60
+ atomicWrite(target, appended);
61
+ return { created: false, updated: true, added: missing };
62
+ }