@ctxr/skill-llm-wiki 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/LICENSE +21 -0
- package/README.md +484 -0
- package/SKILL.md +252 -0
- package/guide/basics/concepts.md +74 -0
- package/guide/basics/index.md +45 -0
- package/guide/basics/schema.md +140 -0
- package/guide/cli.md +256 -0
- package/guide/correctness/index.md +45 -0
- package/guide/correctness/invariants.md +89 -0
- package/guide/correctness/safety.md +96 -0
- package/guide/history/diff.md +110 -0
- package/guide/history/hidden-git.md +130 -0
- package/guide/history/index.md +52 -0
- package/guide/history/remote-sync.md +113 -0
- package/guide/index.md +134 -0
- package/guide/isolation/coexistence.md +134 -0
- package/guide/isolation/index.md +44 -0
- package/guide/isolation/scale.md +251 -0
- package/guide/layout/in-place-mode.md +97 -0
- package/guide/layout/index.md +53 -0
- package/guide/layout/layout-contract.md +131 -0
- package/guide/layout/layout-modes.md +115 -0
- package/guide/operations/index.md +76 -0
- package/guide/operations/ingest/build.md +75 -0
- package/guide/operations/ingest/extend.md +61 -0
- package/guide/operations/ingest/index.md +54 -0
- package/guide/operations/ingest/join.md +65 -0
- package/guide/operations/maintain/fix.md +66 -0
- package/guide/operations/maintain/index.md +47 -0
- package/guide/operations/maintain/rebuild.md +86 -0
- package/guide/operations/validate.md +48 -0
- package/guide/substrate/index.md +47 -0
- package/guide/substrate/operators.md +96 -0
- package/guide/substrate/tiered-ai.md +363 -0
- package/guide/ux/index.md +44 -0
- package/guide/ux/preflight.md +150 -0
- package/guide/ux/user-intent.md +135 -0
- package/package.json +55 -0
- package/scripts/cli.mjs +893 -0
- package/scripts/commands/remote.mjs +93 -0
- package/scripts/commands/review.mjs +253 -0
- package/scripts/commands/sync.mjs +84 -0
- package/scripts/lib/chunk.mjs +421 -0
- package/scripts/lib/cluster-detect.mjs +516 -0
- package/scripts/lib/decision-log.mjs +343 -0
- package/scripts/lib/draft.mjs +158 -0
- package/scripts/lib/embeddings.mjs +366 -0
- package/scripts/lib/frontmatter.mjs +497 -0
- package/scripts/lib/git-commands.mjs +155 -0
- package/scripts/lib/git.mjs +486 -0
- package/scripts/lib/gitignore.mjs +62 -0
- package/scripts/lib/history.mjs +331 -0
- package/scripts/lib/indices.mjs +510 -0
- package/scripts/lib/ingest.mjs +258 -0
- package/scripts/lib/intent.mjs +713 -0
- package/scripts/lib/interactive.mjs +99 -0
- package/scripts/lib/migrate.mjs +126 -0
- package/scripts/lib/nest-applier.mjs +260 -0
- package/scripts/lib/operators.mjs +1365 -0
- package/scripts/lib/orchestrator.mjs +718 -0
- package/scripts/lib/paths.mjs +197 -0
- package/scripts/lib/preflight.mjs +213 -0
- package/scripts/lib/provenance.mjs +672 -0
- package/scripts/lib/quality-metric.mjs +269 -0
- package/scripts/lib/query-fixture.mjs +71 -0
- package/scripts/lib/rollback.mjs +95 -0
- package/scripts/lib/shape-check.mjs +172 -0
- package/scripts/lib/similarity-cache.mjs +126 -0
- package/scripts/lib/similarity.mjs +230 -0
- package/scripts/lib/snapshot.mjs +54 -0
- package/scripts/lib/source-frontmatter.mjs +85 -0
- package/scripts/lib/tier2-protocol.mjs +470 -0
- package/scripts/lib/tiered.mjs +453 -0
- package/scripts/lib/validate.mjs +362 -0
|
@@ -0,0 +1,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
|
+
}
|