@jagreehal/shepherd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +445 -2
  4. package/THIRD_PARTY_NOTICES.md +41 -0
  5. package/commands/shepherd.md +18 -0
  6. package/examples/local-demo/baseline/src/config.ts +2 -0
  7. package/examples/local-demo/baseline/src/db.ts +5 -0
  8. package/examples/local-demo/baseline/src/notes.ts +15 -0
  9. package/examples/local-demo/baseline/src/users.ts +8 -0
  10. package/examples/local-demo/expected.json +12 -0
  11. package/examples/local-demo/planted/src/config.ts +2 -0
  12. package/examples/local-demo/planted/src/notes.ts +15 -0
  13. package/examples/local-demo/planted/src/search.ts +8 -0
  14. package/examples/local-demo/planted/src/users.ts +8 -0
  15. package/lenses/observability/SKILL.md +26 -0
  16. package/package.json +44 -3
  17. package/skills/ci-repair/SKILL.md +178 -0
  18. package/skills/code-review/SKILL.md +89 -0
  19. package/skills/code-review/agents/openai.yaml +3 -0
  20. package/skills/diagnosing-bugs/SKILL.md +138 -0
  21. package/skills/diagnosing-bugs/agents/openai.yaml +3 -0
  22. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  23. package/skills/garden/SKILL.md +144 -0
  24. package/skills/incident/SKILL.md +261 -0
  25. package/skills/pair/SKILL.md +111 -0
  26. package/skills/pr/CREDITS.md +3 -0
  27. package/skills/pr/SKILL.md +170 -0
  28. package/skills/pr/agents/openai.yaml +3 -0
  29. package/skills/retro/SKILL.md +44 -0
  30. package/skills/retro/agents/openai.yaml +5 -0
  31. package/skills/review-correctness/SKILL.md +80 -0
  32. package/skills/review-maintainability/SKILL.md +86 -0
  33. package/skills/review-security/SKILL.md +75 -0
  34. package/skills/review-simplicity/SKILL.md +87 -0
  35. package/skills/review-slop/SKILL.md +94 -0
  36. package/skills/review-spec/SKILL.md +57 -0
  37. package/skills/shepherd/SKILL.md +404 -0
  38. package/skills/shepherd/references/degradation.md +21 -0
  39. package/skills/shepherd/references/dispatch.md +115 -0
  40. package/skills/shepherd/references/formats.md +46 -0
  41. package/skills/shepherd/references/models.md +88 -0
  42. package/skills/shepherd/references/policy.md +110 -0
  43. package/skills/swarm/SKILL.md +521 -0
  44. package/skills/swarm/references/custom-lenses.md +129 -0
  45. package/skills/swarm/references/formats.md +91 -0
  46. package/skills/triage/SKILL.md +388 -0
  47. package/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
  48. package/skills/writing-for-agents/SKILL.md +81 -0
  49. package/skills/writing-for-agents/agents/openai.yaml +3 -0
  50. package/src/acceptance.ts +111 -0
  51. package/src/cli.ts +379 -0
  52. package/src/config.ts +439 -0
  53. package/src/doctor.ts +153 -0
  54. package/src/garden.ts +198 -0
  55. package/src/install.ts +86 -0
  56. package/src/lens.ts +178 -0
  57. package/src/local.ts +393 -0
package/src/local.ts ADDED
@@ -0,0 +1,393 @@
1
+ // Local review: capture what the developer changed before any PR exists, check a swarm review of it
2
+ // against that change, and keep a receipt tied to the exact content reviewed.
3
+ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import { z } from "zod";
9
+
10
+ // Settings that would run user code or write inside `.git` during a read, off for every call. quotePath off
11
+ // keeps non-ASCII names readable; names with quotes, backslashes or control characters are still quoted.
12
+ const GIT_SAFE = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "gc.auto=0", "-c", "core.quotePath=false"];
13
+
14
+ const DIFF_FLAGS = ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--find-renames"];
15
+
16
+ const RUN: ExecFileSyncOptionsWithStringEncoding = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 };
17
+
18
+ /**
19
+ * A `.gitattributes` filter runs its configured clean, smudge or process command whenever git reads or writes
20
+ * a working-tree file, so capture would execute repository-chosen code. Every driver in config gets empty
21
+ * commands, which git skips, and loses `required`.
22
+ */
23
+ function filtersOff(cwd: string): string[] {
24
+ let names: string[] = [];
25
+
26
+ try {
27
+ names = execFileSync("git", ["config", "--null", "--name-only", "--get-regexp", "^filter\\..+\\.(clean|smudge|process|required)$"], { ...RUN, cwd }).split("\0");
28
+ } catch {
29
+ // git config exits 1 when nothing matches: no drivers to switch off.
30
+ }
31
+
32
+ const drivers = new Set(names.filter(Boolean).map((n) => n.slice("filter.".length, n.lastIndexOf("."))));
33
+
34
+ return [...drivers].flatMap((d) => ["clean", "smudge", "process"].flatMap((k) => ["-c", `filter.${d}.${k}=`]).concat("-c", `filter.${d}.required=false`));
35
+ }
36
+
37
+ /** Copy the repository's index to `dest`, if it has one, keeping its timestamps. */
38
+ export function copyIndex(repoRoot: string, dest: string): void {
39
+ const real = path.resolve(repoRoot, git(repoRoot, ["rev-parse", "--git-path", "index"]).trim());
40
+
41
+ if (!existsSync(real)) return;
42
+ copyFileSync(real, dest);
43
+ // Git treats a file whose mtime is not older than the index as possibly changed and compares its
44
+ // content. A copy with a fresh mtime would hide same-size edits made within that window.
45
+ const { atime, mtime } = statSync(real);
46
+
47
+ utimesSync(dest, atime, mtime);
48
+ }
49
+
50
+ /** git with hooks, fsmonitor, gc and every filter driver off: a read never runs repository-chosen commands. */
51
+ export const git = (cwd: string, args: string[], env?: NodeJS.ProcessEnv): string => execFileSync("git", [...GIT_SAFE, ...filtersOff(cwd), ...args], { ...RUN, cwd, env: { ...process.env, ...env } });
52
+
53
+ const gitOr = (cwd: string, args: string[]): string | null => {
54
+ try {
55
+ return git(cwd, args).trim() || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+
61
+ /**
62
+ * A run of new-side lines the change touched. A deletion inside a file counts as the lines on either side of
63
+ * it. A deleted file has no new side, so its range is its old lines, marked `deleted`.
64
+ */
65
+ export type ChangedRange = { file: string; start: number; end: number; deleted?: true };
66
+
67
+ export type LocalChange = {
68
+ base_ref: string;
69
+ base_sha: string;
70
+ head_sha: string | null;
71
+ fingerprint: string;
72
+ patch_path: string;
73
+ files: string[];
74
+ ranges: ChangedRange[];
75
+ };
76
+
77
+ /** Where shepherd keeps scratch and receipts: inside the git directory, never committed, the same path for every process. */
78
+ export const stateDir = (repoRoot: string): string => {
79
+ const common = git(repoRoot, ["rev-parse", "--git-common-dir"]).trim();
80
+
81
+ return path.join(path.resolve(repoRoot, common), "shepherd");
82
+ };
83
+
84
+ /**
85
+ * The commit the change is measured from: an explicit base, else where this branch left its upstream,
86
+ * else where it left the remote's default branch, else HEAD (uncommitted work only), else the empty tree.
87
+ */
88
+ type Base = { ref: string; sha: string; head: string | null };
89
+
90
+ function resolveBase(repoRoot: string, base?: string): Base {
91
+ const head = gitOr(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
92
+ const forkPoint = (ref: string) => (head ? gitOr(repoRoot, ["merge-base", "HEAD", ref]) : null);
93
+
94
+ if (base) {
95
+ const sha = gitOr(repoRoot, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${base}^{commit}`]);
96
+
97
+ if (!sha) throw new Error(`base not found: ${base}`);
98
+
99
+ return { ref: base, sha: forkPoint(sha) ?? sha, head };
100
+ }
101
+
102
+ if (!head) return { ref: "empty tree", sha: git(repoRoot, ["hash-object", "-t", "tree", "/dev/null"]).trim(), head };
103
+ const remoteDefault = gitOr(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
104
+
105
+ for (const ref of [gitOr(repoRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]), remoteDefault, "origin/main", "origin/master"]) {
106
+ const sha = ref ? forkPoint(ref) : null;
107
+
108
+ if (ref && sha) return { ref, sha, head };
109
+ }
110
+
111
+ return { ref: "HEAD", sha: head, head };
112
+ }
113
+
114
+ const ESCAPES = "abtnvfr";
115
+
116
+ /** Undo git's C-style quoting of a path: `"caf\\303\\251.ts"` is `café.ts`. An unquoted path comes back as is. */
117
+ export function unquotePath(raw: string): string {
118
+ if (!raw.startsWith('"') || !raw.endsWith('"')) return raw;
119
+ const bytes: number[] = [];
120
+
121
+ for (let i = 1; i < raw.length - 1; i++) {
122
+ const c = raw[i]!;
123
+
124
+ if (c !== "\\") {
125
+ // A whole code point: an emoji is two UTF-16 units that only encode together.
126
+ const point = String.fromCodePoint(raw.codePointAt(i)!);
127
+
128
+ bytes.push(...Buffer.from(point));
129
+ i += point.length - 1;
130
+ continue;
131
+ }
132
+
133
+ const next = raw[++i]!;
134
+
135
+ if (/[0-7]/.test(next)) {
136
+ bytes.push(Number.parseInt(raw.slice(i, i + 3), 8));
137
+ i += 2;
138
+ } else bytes.push(ESCAPES.includes(next) ? 7 + ESCAPES.indexOf(next) : next.charCodeAt(0));
139
+ }
140
+
141
+ return Buffer.from(bytes).toString("utf8");
142
+ }
143
+
144
+ /** The path in a `--- a/x` or `+++ b/x` header, or null for /dev/null. */
145
+ const headerPath = (line: string): string | null => {
146
+ const raw = unquotePath(line.slice(4).replace(/\t$/, ""));
147
+
148
+ return raw === "/dev/null" ? null : raw.slice(2);
149
+ };
150
+
151
+ /** Changed ranges from a zero-context patch: new-side lines, or old lines for a file the change deletes. */
152
+ export function changedRanges(patch: string): ChangedRange[] {
153
+ const out: ChangedRange[] = [];
154
+ let oldFile: string | null = null;
155
+ let newFile: string | null = null;
156
+
157
+ for (const line of patch.split("\n")) {
158
+ if (line.startsWith("diff --git ")) oldFile = newFile = null;
159
+ else if (line.startsWith("--- ")) oldFile = headerPath(line);
160
+ else if (line.startsWith("+++ ")) newFile = headerPath(line);
161
+ else if (line.startsWith("@@")) {
162
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
163
+
164
+ if (!m) continue;
165
+ const count = (n: string | undefined) => (n === undefined ? 1 : Number(n));
166
+
167
+ if (newFile === null && oldFile !== null) {
168
+ out.push({ file: oldFile, start: Number(m[1]), end: Number(m[1]) + count(m[2]) - 1, deleted: true });
169
+ continue;
170
+ }
171
+
172
+ if (newFile === null) continue;
173
+ const start = Number(m[3]);
174
+ const added = count(m[4]);
175
+
176
+ // A pure deletion reports the line before it; the lines on both sides of the gap are what it changed.
177
+ out.push(added === 0 ? { file: newFile, start: Math.max(start, 1), end: start + 1 } : { file: newFile, start, end: start + added - 1 });
178
+ }
179
+ }
180
+
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * Capture the change: commits not yet on the base, staged and unstaged edits, and untracked files that
186
+ * .gitignore does not exclude. Untracked files enter through a copy of the index in a temp folder, so the
187
+ * developer's own index is never touched.
188
+ */
189
+ export function captureChange(repoRoot: string, base?: string): LocalChange {
190
+ const { ref, sha, head } = resolveBase(repoRoot, base);
191
+ const tmp = mkdtempSync(path.join(tmpdir(), "shepherd-index-"));
192
+ const index = path.join(tmp, "index");
193
+
194
+ try {
195
+ copyIndex(repoRoot, index);
196
+
197
+ const env = { GIT_INDEX_FILE: index, GIT_OPTIONAL_LOCKS: "0" };
198
+
199
+ git(repoRoot, ["add", "--all", "--intent-to-add", "--", "."], env);
200
+ const exact = git(repoRoot, [...DIFF_FLAGS, "-U0", sha], env);
201
+ const patch = git(repoRoot, [...DIFF_FLAGS, sha], env);
202
+ const fingerprint = createHash("sha256").update(`${sha}\n${exact}`).digest("hex");
203
+ const dir = stateDir(repoRoot);
204
+
205
+ mkdirSync(dir, { recursive: true });
206
+ const patchPath = path.join(dir, `local-${fingerprint.slice(0, 12)}.patch`);
207
+
208
+ writeFileSync(patchPath, patch);
209
+ const ranges = changedRanges(exact);
210
+ const files = git(repoRoot, [...DIFF_FLAGS, "--name-only", "-z", sha], env).split("\0").filter(Boolean);
211
+
212
+ return { base_ref: ref, base_sha: sha, head_sha: head, fingerprint, patch_path: patchPath, files, ranges };
213
+ } finally {
214
+ rmSync(tmp, { recursive: true, force: true });
215
+ }
216
+ }
217
+
218
+ const Severity = z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW", "NIT"]);
219
+
220
+ const Status = z.enum(["ok", "failed", "could_not_run", "skipped"]);
221
+
222
+ /** What swarm writes at the end of a local review. Scopes are "full", "<path>", or "<path>:<start>-<end>". */
223
+ export const ReviewRecord = z.object({
224
+ fingerprint: z.string(),
225
+ stages: z.array(z.object({ stage: z.string(), status: Status, reason: z.string().optional() })),
226
+ lenses: z.array(z.object({ lens: z.string(), status: Status, scope: z.union([z.literal("full"), z.array(z.string())]) })),
227
+ candidates: z.array(z.object({ source: z.string(), file: z.string(), line: z.number().int(), status: z.enum(["raised", "dropped"]), reason: z.string().optional() })).default([]),
228
+ findings: z.array(z.object({ file: z.string(), line: z.union([z.number().int().positive(), z.literal("general")]), severity: Severity, lens: z.string(), body: z.string().min(1) })),
229
+ });
230
+
231
+ export type ReviewRecord = z.infer<typeof ReviewRecord>;
232
+
233
+ export const REQUIRED_STAGES = ["checks", "router", "verify"];
234
+
235
+ export type FinishResult = {
236
+ status: "complete" | "incomplete" | "invalid";
237
+ fingerprint: string;
238
+ errors: string[];
239
+ missing: string[];
240
+ findings: { open: number; by_severity: Record<string, number> };
241
+ receipt: string | null;
242
+ };
243
+
244
+ const covers = (scope: "full" | string[], r: ChangedRange): boolean =>
245
+ scope === "full" ||
246
+ scope.some((s) => {
247
+ const m = /^(.*):(\d+)-(\d+)$/.exec(s);
248
+
249
+ if (!m) return s === r.file;
250
+
251
+ return m[1] === r.file && Number(m[2]) <= r.start && Number(m[3]) >= r.end;
252
+ });
253
+
254
+ const lineCount = (repoRoot: string, file: string): number | null => {
255
+ const p = path.join(repoRoot, file);
256
+
257
+ return existsSync(p) ? readFileSync(p, "utf8").replace(/\n$/, "").split("\n").length : null;
258
+ };
259
+
260
+ /**
261
+ * Check a review record against the change on disk now. Invalid: a finding or candidate the change cannot
262
+ * hold. Incomplete: a stage or lens did not finish, a changed range never reached a reviewer, or the change
263
+ * moved during the review. Complete writes a receipt for this exact content. Delivery is checked, not
264
+ * understanding.
265
+ */
266
+ export function finish(repoRoot: string, recordJson: string, base?: string): FinishResult {
267
+ const parsed = ReviewRecord.safeParse(JSON.parse(recordJson));
268
+ const change = captureChange(repoRoot, base);
269
+ const empty = { open: 0, by_severity: {} };
270
+
271
+ if (!parsed.success) return { status: "invalid", fingerprint: change.fingerprint, errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`), missing: [], findings: empty, receipt: null };
272
+ const record = parsed.data;
273
+ const errors: string[] = [];
274
+ const missing: string[] = [];
275
+
276
+ if (record.fingerprint !== change.fingerprint) missing.push("the change moved during the review: capture it again and re-run");
277
+
278
+ for (const stage of REQUIRED_STAGES) {
279
+ const s = record.stages.find((x) => x.stage === stage);
280
+
281
+ if (!s) missing.push(`stage ${stage} has no record`);
282
+ else if (s.status === "skipped" && !s.reason) errors.push(`stage ${stage} was skipped without a reason`);
283
+ else if (s.status !== "ok" && s.status !== "skipped") missing.push(`stage ${stage} ended ${s.status}`);
284
+ }
285
+
286
+ for (const l of record.lenses) if (l.status !== "ok") missing.push(`lens ${l.lens} ended ${l.status}`);
287
+ const reviewed = record.lenses.filter((l) => l.status === "ok");
288
+
289
+ for (const r of change.ranges) if (!reviewed.some((l) => covers(l.scope, r))) missing.push(`${r.file}:${r.start}-${r.end} reached no reviewer`);
290
+
291
+ record.findings.forEach((f, i) => {
292
+ if (f.line === "general") return;
293
+ // A finding on a deleted file cites the removed content, so it is checked against the old lines.
294
+ const removed = change.ranges.find((r) => r.deleted && r.file === f.file);
295
+ const lines = removed ? removed.end : lineCount(repoRoot, f.file);
296
+
297
+ if (lines === null) errors.push(`finding ${i}: ${f.file} does not exist`);
298
+ else if (f.line > lines) errors.push(`finding ${i}: ${f.file} has ${lines} lines, not ${f.line}`);
299
+ });
300
+
301
+ record.candidates.forEach((c, i) => {
302
+ if (c.status === "dropped" && !c.reason) errors.push(`candidate ${i} (${c.source} ${c.file}:${c.line}) dropped without a reason`);
303
+
304
+ if (c.status === "raised" && !record.findings.some((f) => f.file === c.file && f.line !== "general" && Math.abs(f.line - c.line) <= 5)) {
305
+ errors.push(`candidate ${i} (${c.source} ${c.file}:${c.line}) marked raised, but no finding is within 5 lines of it`);
306
+ }
307
+ });
308
+ const bySeverity: Record<string, number> = {};
309
+
310
+ for (const f of record.findings) bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
311
+ const findings = { open: record.findings.length, by_severity: bySeverity };
312
+ const status = errors.length ? "invalid" : missing.length ? "incomplete" : "complete";
313
+ let receipt: string | null = null;
314
+
315
+ if (status === "complete") {
316
+ const dir = path.join(stateDir(repoRoot), "receipts");
317
+
318
+ mkdirSync(dir, { recursive: true });
319
+ receipt = path.join(dir, `${change.fingerprint}.json`);
320
+ const body = JSON.stringify({ fingerprint: change.fingerprint, base_ref: change.base_ref, base_sha: change.base_sha, reviewed_at: new Date().toISOString(), findings }, null, 2);
321
+
322
+ writeFileSync(receipt, body);
323
+ writeFileSync(path.join(dir, "last.json"), body);
324
+ }
325
+
326
+ return { status, fingerprint: change.fingerprint, errors, missing, findings, receipt };
327
+ }
328
+
329
+ export type ReceiptStatus = { status: "reviewed" | "changed" | "none"; fingerprint: string; reviewed_at?: string; findings?: FinishResult["findings"] };
330
+
331
+ /** Is the change on disk now the one a complete local review covered? Any edit since makes it "changed". */
332
+ export function receiptStatus(repoRoot: string, base?: string): ReceiptStatus {
333
+ const { fingerprint } = captureChange(repoRoot, base);
334
+ const dir = path.join(stateDir(repoRoot), "receipts");
335
+ const exact = path.join(dir, `${fingerprint}.json`);
336
+ const read = (p: string) => z.object({ reviewed_at: z.string(), findings: z.object({ open: z.number(), by_severity: z.record(z.string(), z.number()) }) }).parse(JSON.parse(readFileSync(p, "utf8")));
337
+
338
+ if (existsSync(exact)) return { status: "reviewed", fingerprint, ...read(exact) };
339
+
340
+ if (existsSync(path.join(dir, "last.json"))) return { status: "changed", fingerprint, reviewed_at: read(path.join(dir, "last.json")).reviewed_at };
341
+
342
+ return { status: "none", fingerprint };
343
+ }
344
+
345
+ const DEMO = path.resolve(import.meta.dir, "..", "examples", "local-demo");
346
+
347
+ export type DemoBug = { id: string; class: string; file: string; lines: [number, number]; enters_as: string };
348
+
349
+ export const demoBugs = (): DemoBug[] => JSON.parse(readFileSync(path.join(DEMO, "expected.json"), "utf8")).bugs;
350
+
351
+ const copyTree = (from: string, to: string) => {
352
+ for (const rel of readdirSync(from, { recursive: true, withFileTypes: true })) {
353
+ if (!rel.isFile()) continue;
354
+ const src = path.join(rel.parentPath, rel.name);
355
+ const dest = path.join(to, path.relative(from, src));
356
+
357
+ mkdirSync(path.dirname(dest), { recursive: true });
358
+ copyFileSync(src, dest);
359
+ }
360
+ };
361
+
362
+ /**
363
+ * Build a demo repository under `dir`: a remote holding the baseline, and an `app` clone on a feature branch
364
+ * whose change carries every planted bug, one per way a change reaches the review. Returns the clone's path.
365
+ */
366
+ export function buildDemo(dir: string): string {
367
+ const remote = path.join(dir, "remote.git");
368
+ const app = path.join(dir, "app");
369
+ const id = ["-c", "user.name=Shepherd demo", "-c", "user.email=demo@example.invalid", "-c", "commit.gpgsign=false"];
370
+
371
+ mkdirSync(dir, { recursive: true });
372
+ git(dir, ["init", "-q", "--bare", "-b", "main", remote]);
373
+ git(dir, ["clone", "-q", remote, app]);
374
+ git(app, ["checkout", "-q", "-b", "main"]);
375
+ copyTree(path.join(DEMO, "baseline"), app);
376
+ git(app, ["add", "-A"]);
377
+ git(app, [...id, "commit", "-q", "-m", "Notes service"]);
378
+ git(app, ["push", "-q", "-u", "origin", "main"]);
379
+ git(app, ["remote", "set-head", "origin", "main"]);
380
+ git(app, ["checkout", "-q", "-b", "feature/notes"]);
381
+ const planted = path.join(DEMO, "planted", "src");
382
+
383
+ copyFileSync(path.join(planted, "users.ts"), path.join(app, "src", "users.ts"));
384
+ git(app, [...id, "commit", "-q", "-am", "Page through users"]);
385
+ copyFileSync(path.join(planted, "notes.ts"), path.join(app, "src", "notes.ts"));
386
+ git(app, ["add", "src/notes.ts"]);
387
+ const key = `sk_live_${randomBytes(18).toString("base64url").replace(/[-_]/g, "a").slice(0, 24)}`;
388
+
389
+ writeFileSync(path.join(app, "src", "config.ts"), readFileSync(path.join(planted, "config.ts"), "utf8").replace("{{GENERATED_KEY}}", key));
390
+ copyFileSync(path.join(planted, "search.ts"), path.join(app, "src", "search.ts"));
391
+
392
+ return app;
393
+ }