@alphacoder-v0/pi-loops 0.17.5 → 0.18.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/src/recipe.ts ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * Recipes: a packaged way of running a project on loops (`CONTEXT.md` has the vocabulary,
3
+ * `docs/design.md` §15–18 the decisions).
4
+ *
5
+ * Everything `/recipe` decides is here, as functions that take state and return the change; the
6
+ * command handler in `pi-loops.ts` reads arguments, asks the person, writes the store and prints.
7
+ * Nothing in this module touches `jobs.json` or the UI. The one rule that matters most: a
8
+ * manifest's `[[job]]` is checked by building the `/cron add` line it stands for and handing that
9
+ * to `parseAddArgs` — the same parser, the same refusals — so a recipe can never create a job a
10
+ * person could not have typed.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { parseAddArgs, type AddArgs } from "./args.ts";
18
+ import { parseToml, type TomlTable, type TomlValue } from "./toml.ts";
19
+ import { computeNext, formatSchedule, stamp } from "./schedule.ts";
20
+ import { PI_LOOPS_VERSION as VERSION } from "./version.ts";
21
+
22
+ export const AUTONOMY_LEVELS = ["report", "propose", "act"] as const;
23
+ export type AutonomyLevel = (typeof AUTONOMY_LEVELS)[number];
24
+
25
+ export interface RecipeJob {
26
+ name: string;
27
+ /** The schedule text exactly as `/cron add` would take it. */
28
+ schedule: string;
29
+ /** The playbook this job's runs read, relative to the recipe directory. */
30
+ playbook: string;
31
+ /** Optional `/cron add` flags. Absent = the flag is not passed. */
32
+ verify?: boolean;
33
+ timeout?: string;
34
+ thinking?: string;
35
+ tools?: string[];
36
+ /** Prompt override; the default is the pointer sentence built by `promptFor`. */
37
+ prompt?: string;
38
+ }
39
+
40
+ export interface RecipeManifest {
41
+ name: string;
42
+ summary: string;
43
+ needsTracker: boolean;
44
+ levels: AutonomyLevel[];
45
+ /** A script run once per project, with confirmation, before the jobs exist. */
46
+ setup?: string;
47
+ /** Files copied beside the playbooks (templates a playbook tells the run to create from). */
48
+ files: string[];
49
+ budgetHintUsd?: number;
50
+ jobs: RecipeJob[];
51
+ }
52
+
53
+ export interface Recipe {
54
+ manifest: RecipeManifest;
55
+ /** Absolute path of the directory `recipe.toml` was read from. */
56
+ dir: string;
57
+ }
58
+
59
+ /** What `/recipe add` leaves beside the playbooks so `list`, `remove` and `update` know what happened. */
60
+ export interface InstallRecord {
61
+ recipe: string;
62
+ /** The pi-loops version whose packaged files were installed. */
63
+ version: string;
64
+ level: AutonomyLevel;
65
+ installedAt: string;
66
+ /** Relative names of every file written (playbooks and extra files), for `update`. */
67
+ files: string[];
68
+ /** Where the files came from: a packaged recipe by name, or a path. */
69
+ source: string;
70
+ }
71
+
72
+ export const MANIFEST_FILE = "recipe.toml";
73
+ export const RECORD_FILE = ".recipe.json";
74
+ /** Where a recipe's files land in a project, relative to the project root. */
75
+ export const INSTALL_ROOT = path.join(".agents", "skills");
76
+ /** A setup script longer than this cannot be shown whole in a confirmation, so it is not run at all. */
77
+ export const MAX_SETUP_SHOWN = 6000;
78
+
79
+ const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
80
+
81
+ /** A packaged recipe name, and the only thing `/recipe update|remove <name>` accept as a name. */
82
+ export function isRecipeName(s: string): boolean {
83
+ return NAME_RE.test(s);
84
+ }
85
+
86
+ /* ---------------------------------------------------------------------------- the manifest */
87
+
88
+ function str(t: TomlTable, key: string, where: string): string | undefined {
89
+ const v = t[key];
90
+ if (v === undefined) return undefined;
91
+ if (typeof v !== "string") throw new Error(`${where}: ${key} must be a string`);
92
+ return v;
93
+ }
94
+ function requireStr(t: TomlTable, key: string, where: string): string {
95
+ const v = str(t, key, where);
96
+ if (v === undefined || !v.trim()) throw new Error(`${where}: ${key} is required`);
97
+ return v;
98
+ }
99
+ function bool(t: TomlTable, key: string, where: string): boolean | undefined {
100
+ const v = t[key];
101
+ if (v === undefined) return undefined;
102
+ if (typeof v !== "boolean") throw new Error(`${where}: ${key} must be true or false`);
103
+ return v;
104
+ }
105
+ function strList(t: TomlTable, key: string, where: string): string[] | undefined {
106
+ const v = t[key];
107
+ if (v === undefined) return undefined;
108
+ if (!Array.isArray(v) || !v.every((x: TomlValue) => typeof x === "string")) throw new Error(`${where}: ${key} must be a list of strings`);
109
+ return v as string[];
110
+ }
111
+
112
+ /** The sentence a recipe job's prompt is, unless the manifest says otherwise. */
113
+ export function promptFor(job: RecipeJob, installDir: string): string {
114
+ return job.prompt ?? `Read ${path.join(installDir, job.playbook)} and do what it says for this repository.`;
115
+ }
116
+
117
+ /**
118
+ * The `/cron add` line a recipe job stands for. Building the text and parsing it back is not
119
+ * indirection for its own sake: it is what makes "a recipe can only do what a person could type"
120
+ * true, and it is the line `/recipe add` shows before asking for a yes.
121
+ */
122
+ export function addLineFor(job: RecipeJob, installDir: string): string {
123
+ const q = (s: string) => (/[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
124
+ const parts = ["--stateful", "--name", q(job.name)];
125
+ if (job.verify) parts.push("--verify");
126
+ if (job.timeout) parts.push("--timeout", q(job.timeout));
127
+ if (job.thinking) parts.push("--thinking", q(job.thinking));
128
+ if (job.tools?.length) parts.push("--tools", q(job.tools.join(",")));
129
+ parts.push(q(job.schedule), promptFor(job, installDir));
130
+ return parts.join(" ");
131
+ }
132
+
133
+ /** The arguments `/cron add` would have parsed from that line; throws exactly what it would. */
134
+ export function addArgsFor(job: RecipeJob, installDir: string, now: number = Date.now()): AddArgs {
135
+ return parseAddArgs(addLineFor(job, installDir), now);
136
+ }
137
+
138
+ export function parseManifest(text: string, opts: { now?: number } = {}): RecipeManifest {
139
+ const doc = parseToml(text);
140
+ const where = MANIFEST_FILE;
141
+ const name = requireStr(doc, "name", where);
142
+ if (!NAME_RE.test(name)) throw new Error(`${where}: name must be lowercase letters, digits and dashes (got "${name}")`);
143
+ const summary = requireStr(doc, "summary", where);
144
+ const levelsRaw = strList(doc, "levels", where);
145
+ if (!levelsRaw?.length) throw new Error(`${where}: levels must list at least one of ${AUTONOMY_LEVELS.join(", ")}`);
146
+ const levels: AutonomyLevel[] = [];
147
+ for (const l of levelsRaw) {
148
+ if (!(AUTONOMY_LEVELS as readonly string[]).includes(l)) throw new Error(`${where}: unknown level "${l}" (levels are ${AUTONOMY_LEVELS.join(", ")})`);
149
+ levels.push(l as AutonomyLevel);
150
+ }
151
+ // Declared order is not trusted: the wizard defaults to the lowest, and "lowest" is a fact
152
+ // about the vocabulary, not about how someone typed the list.
153
+ levels.sort((a, b) => AUTONOMY_LEVELS.indexOf(a) - AUTONOMY_LEVELS.indexOf(b));
154
+ const setup = str(doc, "setup", where);
155
+ if (setup !== undefined && !safeRelative(setup)) throw new Error(`${where}: setup must be a file inside the recipe directory`);
156
+ const files = strList(doc, "files", where) ?? [];
157
+ for (const f of files) if (!safeRelative(f)) throw new Error(`${where}: files entry "${f}" must be inside the recipe directory`);
158
+ const budget = doc.budget_hint_usd;
159
+ if (budget !== undefined && (typeof budget !== "number" || budget < 0)) throw new Error(`${where}: budget_hint_usd must be a non-negative number`);
160
+ const jobsRaw = doc.job;
161
+ if (!Array.isArray(jobsRaw) || jobsRaw.length === 0) throw new Error(`${where}: at least one [[job]] is required`);
162
+ const jobs: RecipeJob[] = [];
163
+ const seen = new Set<string>();
164
+ for (const [i, raw] of jobsRaw.entries()) {
165
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`${where}: [[job]] #${i + 1} is not a table`);
166
+ const t = raw as TomlTable;
167
+ const jw = `${where} [[job]] #${i + 1}`;
168
+ const job: RecipeJob = {
169
+ name: requireStr(t, "name", jw),
170
+ schedule: requireStr(t, "schedule", jw),
171
+ playbook: requireStr(t, "playbook", jw),
172
+ verify: bool(t, "verify", jw),
173
+ timeout: str(t, "timeout", jw),
174
+ thinking: str(t, "thinking", jw),
175
+ tools: strList(t, "tools", jw),
176
+ prompt: str(t, "prompt", jw),
177
+ };
178
+ if (seen.has(job.name)) throw new Error(`${jw}: job name "${job.name}" is used twice`);
179
+ seen.add(job.name);
180
+ if (!safeRelative(job.playbook) || !job.playbook.endsWith(".md")) throw new Error(`${jw}: playbook must be a .md file inside the recipe directory`);
181
+ // The one validation that matters: what /cron add would say.
182
+ try {
183
+ const parsed = addArgsFor(job, path.join(INSTALL_ROOT, name), opts.now);
184
+ if (parsed.schedule.kind === "once") throw new Error("a recipe job must recur (`in`/`at` schedules fire once and are then removed)");
185
+ // What createLoopJob refuses at creation, refused here, so a manifest is bad before it is installed.
186
+ const t = opts.now ?? Date.now();
187
+ if (parsed.schedule.kind === "cron" && computeNext({ schedule: parsed.schedule, createdAt: t }, t) === undefined) throw new Error(`${formatSchedule(parsed.schedule)} has no next run`);
188
+ } catch (e) {
189
+ throw new Error(`${jw} (${job.name}): ${(e as Error).message}`);
190
+ }
191
+ jobs.push(job);
192
+ }
193
+ return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, setup, files, budgetHintUsd: budget as number | undefined, jobs };
194
+ }
195
+
196
+ /** Relative, inside the directory, and a single file: no `..`, no absolute path, no trailing slash. */
197
+ function safeRelative(p: string): boolean {
198
+ if (!p || path.isAbsolute(p) || p.endsWith("/")) return false;
199
+ const norm = path.posix.normalize(p);
200
+ return !norm.startsWith("../") && norm !== ".." && !norm.startsWith("/");
201
+ }
202
+
203
+ /** `/recipe add <ref> [--level <l>]` and `pi-loops recipe add`: which word is the recipe, which the level. */
204
+ export function parseAddWords(words: string[]): { ref?: string; level?: string } {
205
+ let ref: string | undefined;
206
+ let level: string | undefined;
207
+ for (let i = 0; i < words.length; i++) {
208
+ const w = words[i];
209
+ if (w === "--level") {
210
+ level = words[++i];
211
+ continue;
212
+ }
213
+ if (w.startsWith("--level=")) {
214
+ level = w.slice("--level=".length);
215
+ continue;
216
+ }
217
+ if (!w.startsWith("--") && ref === undefined) ref = w;
218
+ }
219
+ return { ref, level };
220
+ }
221
+
222
+ /* ------------------------------------------------------------------------ finding recipes */
223
+
224
+ /** The recipes shipped with this package. */
225
+ export function packagedRecipesDir(): string {
226
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "recipes");
227
+ }
228
+
229
+ export function loadRecipe(dir: string, now?: number): Recipe {
230
+ const file = path.join(dir, MANIFEST_FILE);
231
+ let text: string;
232
+ try {
233
+ text = fs.readFileSync(file, "utf8");
234
+ } catch {
235
+ throw new Error(`no ${MANIFEST_FILE} in ${dir}`);
236
+ }
237
+ const manifest = parseManifest(text, { now });
238
+ for (const f of playbookFiles(manifest)) {
239
+ if (!fs.existsSync(path.join(dir, f))) throw new Error(`${manifest.name}: ${f} is named in ${MANIFEST_FILE} but is not in ${dir}`);
240
+ }
241
+ if (manifest.setup && !fs.existsSync(path.join(dir, manifest.setup))) throw new Error(`${manifest.name}: setup script ${manifest.setup} is not in ${dir}`);
242
+ return { manifest, dir: path.resolve(dir) };
243
+ }
244
+
245
+ /** Every file `install` writes: each job's playbook once, then the extra files. */
246
+ export function playbookFiles(manifest: RecipeManifest): string[] {
247
+ const out: string[] = [];
248
+ for (const j of manifest.jobs) if (!out.includes(j.playbook)) out.push(j.playbook);
249
+ for (const f of manifest.files) if (!out.includes(f)) out.push(f);
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * `/recipe add <ref>`: a name is looked up among the packaged recipes; anything with a slash or
255
+ * a dot is a directory. A directory is taken as typed (relative to `cwd`), never searched for.
256
+ */
257
+ export function resolveRecipeRef(ref: string, opts: { packaged?: string; cwd: string }): { dir: string; source: string } {
258
+ const packaged = opts.packaged ?? packagedRecipesDir();
259
+ if (NAME_RE.test(ref)) {
260
+ const dir = path.join(packaged, ref);
261
+ if (fs.existsSync(path.join(dir, MANIFEST_FILE))) return { dir, source: ref };
262
+ const known = listRecipes(packaged).map((r) => r.manifest.name);
263
+ throw new Error(`no packaged recipe named "${ref}"${known.length ? ` (have: ${known.join(", ")})` : ""}; a directory works too: /recipe add ./path/to/recipe`);
264
+ }
265
+ const dir = path.resolve(opts.cwd, ref);
266
+ if (!fs.existsSync(path.join(dir, MANIFEST_FILE))) throw new Error(`no ${MANIFEST_FILE} in ${dir}`);
267
+ return { dir, source: dir };
268
+ }
269
+
270
+ export function listRecipes(packaged: string = packagedRecipesDir()): Recipe[] {
271
+ let names: string[];
272
+ try {
273
+ names = fs.readdirSync(packaged).filter((n) => !n.startsWith("_") && !n.startsWith("."));
274
+ } catch {
275
+ return [];
276
+ }
277
+ const out: Recipe[] = [];
278
+ for (const n of names.sort()) {
279
+ const dir = path.join(packaged, n);
280
+ if (!fs.existsSync(path.join(dir, MANIFEST_FILE))) continue;
281
+ out.push(loadRecipe(dir));
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /* --------------------------------------------------------------------- the autonomy line */
287
+
288
+ // Spaces and tabs only: `\s` would swallow the newlines after the line and drag the next line up.
289
+ const LEVEL_LINE = /^Autonomy:[ \t]*(\S+)[ \t]*$/m;
290
+
291
+ /**
292
+ * The installed level is the first line of the playbook body: `Autonomy: propose`. Text the
293
+ * model reads and a person edits; the wizard writes it, and `update` keeps it.
294
+ */
295
+ export function setLevelLine(text: string, level: AutonomyLevel): string {
296
+ if (LEVEL_LINE.test(text)) return text.replace(LEVEL_LINE, `Autonomy: ${level}`);
297
+ // No line yet: after the frontmatter if there is one, else at the top.
298
+ const fm = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
299
+ if (fm) return `${fm[0]}\nAutonomy: ${level}\n${text.slice(fm[0].length).replace(/^\r?\n/, "")}`;
300
+ return `Autonomy: ${level}\n\n${text}`;
301
+ }
302
+
303
+ export function readLevelLine(text: string): AutonomyLevel | undefined {
304
+ const m = text.match(LEVEL_LINE);
305
+ const l = m?.[1];
306
+ return l && (AUTONOMY_LEVELS as readonly string[]).includes(l) ? (l as AutonomyLevel) : undefined;
307
+ }
308
+
309
+ /* ------------------------------------------------------------------------------ installing */
310
+
311
+ export interface InstallPlan {
312
+ /** Absolute path the files go to. */
313
+ targetDir: string;
314
+ /** Files that would be written, relative to targetDir. */
315
+ files: string[];
316
+ /** Files at targetDir that already exist and differ from what would be written. */
317
+ changed: string[];
318
+ /** The `/cron add` line per job, with the project-relative playbook path. */
319
+ addLines: string[];
320
+ record?: InstallRecord;
321
+ }
322
+
323
+ export function installDirFor(projectDir: string, recipeName: string): string {
324
+ return path.join(projectDir, INSTALL_ROOT, recipeName);
325
+ }
326
+
327
+ /** What would be written and created, computed without writing anything. */
328
+ export function planInstall(recipe: Recipe, projectDir: string, level: AutonomyLevel): InstallPlan {
329
+ const targetDir = installDirFor(projectDir, recipe.manifest.name);
330
+ const files = playbookFiles(recipe.manifest);
331
+ const changed: string[] = [];
332
+ for (const f of files) {
333
+ const dest = path.join(targetDir, f);
334
+ if (fs.existsSync(dest) && editedBeyondLevel(fs.readFileSync(dest, "utf8"), renderFile(recipe, f, level), level, isPlaybook(recipe, f))) changed.push(f);
335
+ }
336
+ const rel = path.join(INSTALL_ROOT, recipe.manifest.name);
337
+ return { targetDir, files, changed, addLines: recipe.manifest.jobs.map((j) => addLineFor(j, rel)), record: readRecord(targetDir) };
338
+ }
339
+
340
+ /** The packaged file as it is installed: playbooks get the level line, other files are copied as they are. */
341
+ function renderFile(recipe: Recipe, f: string, level: AutonomyLevel): string {
342
+ const text = fs.readFileSync(path.join(recipe.dir, f), "utf8");
343
+ return isPlaybook(recipe, f) ? setLevelLine(text, level) : text;
344
+ }
345
+
346
+ /**
347
+ * Does the installed copy differ from what would be written, other than in the level line? The
348
+ * level line is the wizard's, not the person's: reinstalling at another level is not an edit.
349
+ */
350
+ function editedBeyondLevel(existing: string, rendered: string, level: AutonomyLevel, isPlaybook: boolean): boolean {
351
+ return (isPlaybook ? setLevelLine(existing, level) : existing) !== rendered;
352
+ }
353
+
354
+ function isPlaybook(recipe: Recipe, f: string): boolean {
355
+ return f.endsWith(".md") && recipe.manifest.jobs.some((j) => j.playbook === f);
356
+ }
357
+
358
+ /** The untouched copy `update` merges against: under `.orig/`, so `cp -r` of a subdirectory carries no debris. */
359
+ function origPath(targetDir: string, f: string): string {
360
+ const p = path.join(targetDir, ".orig", f);
361
+ fs.mkdirSync(path.dirname(p), { recursive: true });
362
+ return p;
363
+ }
364
+
365
+ /**
366
+ * Refuse to write through a symlink. `.agents/skills/` is a committed directory in many
367
+ * repositories, so a clone can ship `.agents/skills/<name>` — or one of the files inside — as a
368
+ * link to anywhere; every write here must land inside the project's own tree.
369
+ */
370
+ function refuseSymlinks(projectDir: string, targetDir: string, files: string[]): void {
371
+ const root = fs.realpathSync(projectDir);
372
+ const check = (p: string) => {
373
+ try {
374
+ if (fs.lstatSync(p).isSymbolicLink()) throw new Error(`${p} is a symbolic link; refusing to write through it`);
375
+ } catch (e) {
376
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
377
+ throw e;
378
+ }
379
+ };
380
+ // Every existing ancestor from the project down to the install directory, then every destination.
381
+ const rel = path.relative(projectDir, targetDir).split(path.sep);
382
+ for (let i = 1; i <= rel.length; i++) check(path.join(projectDir, ...rel.slice(0, i)));
383
+ const dests = files.flatMap((f) => [path.join(targetDir, f), path.join(targetDir, ".orig", f), ...f.split("/").slice(0, -1).map((_, i, parts) => path.join(targetDir, ...parts.slice(0, i + 1)))]);
384
+ for (const d of [path.join(targetDir, ".orig"), path.join(targetDir, RECORD_FILE), ...dests]) check(d);
385
+ if (fs.existsSync(targetDir)) {
386
+ const real = fs.realpathSync(targetDir);
387
+ if (real !== root && !real.startsWith(root + path.sep)) throw new Error(`${targetDir} resolves outside the project (${real}); refusing to write there`);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Write the files and the untouched copies `update` merges against. Existing files are
393
+ * overwritten only when `overwrite` says so — `planInstall().changed` is what the wizard shows
394
+ * before asking.
395
+ */
396
+ export function installFiles(recipe: Recipe, projectDir: string, level: AutonomyLevel, opts: { overwrite: boolean; source: string }): InstallRecord {
397
+ const targetDir = installDirFor(projectDir, recipe.manifest.name);
398
+ const files = playbookFiles(recipe.manifest);
399
+ refuseSymlinks(projectDir, targetDir, [...files, ...(recipe.manifest.setup ? [recipe.manifest.setup] : [])]);
400
+ fs.mkdirSync(targetDir, { recursive: true });
401
+ for (const f of files) {
402
+ const dest = path.join(targetDir, f);
403
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
404
+ const content = renderFile(recipe, f, level);
405
+ if (fs.existsSync(dest) && !opts.overwrite) {
406
+ const existing = fs.readFileSync(dest, "utf8");
407
+ if (editedBeyondLevel(existing, content, level, isPlaybook(recipe, f))) {
408
+ // Kept as edited — but the level line is the wizard's, so it still moves, in the copy and
409
+ // in the base the next update merges against.
410
+ if (isPlaybook(recipe, f)) {
411
+ const relevelled = setLevelLine(existing, level);
412
+ if (relevelled !== existing) fs.writeFileSync(dest, relevelled);
413
+ const orig = origPath(targetDir, f);
414
+ if (fs.existsSync(orig)) fs.writeFileSync(orig, setLevelLine(fs.readFileSync(orig, "utf8"), level));
415
+ }
416
+ continue;
417
+ }
418
+ }
419
+ fs.writeFileSync(dest, content);
420
+ fs.writeFileSync(origPath(targetDir, f), content);
421
+ }
422
+ if (recipe.manifest.setup) {
423
+ // The setup script is copied too, so what ran is what is there to read afterwards.
424
+ const dest = path.join(targetDir, recipe.manifest.setup);
425
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
426
+ fs.copyFileSync(path.join(recipe.dir, recipe.manifest.setup), dest);
427
+ }
428
+ const record: InstallRecord = { recipe: recipe.manifest.name, version: VERSION, level, installedAt: stamp(), files, source: opts.source };
429
+ fs.writeFileSync(path.join(targetDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
430
+ return record;
431
+ }
432
+
433
+ /**
434
+ * The record is a file in a directory a clone can ship, so nothing in it is trusted: the name must
435
+ * be a recipe name, the level one of ours, the source a packaged name or an absolute path.
436
+ */
437
+ export function readRecord(targetDir: string): InstallRecord | undefined {
438
+ try {
439
+ const r = JSON.parse(fs.readFileSync(path.join(targetDir, RECORD_FILE), "utf8")) as Partial<InstallRecord>;
440
+ if (typeof r?.recipe !== "string" || !NAME_RE.test(r.recipe)) return undefined;
441
+ if (typeof r.level !== "string" || !(AUTONOMY_LEVELS as readonly string[]).includes(r.level)) return undefined;
442
+ if (typeof r.source !== "string" || !(NAME_RE.test(r.source) || path.isAbsolute(r.source))) return undefined;
443
+ if (!Array.isArray(r.files) || !r.files.every((f) => typeof f === "string" && safeRelative(f))) return undefined;
444
+ return { recipe: r.recipe, level: r.level as AutonomyLevel, source: r.source, files: r.files, version: typeof r.version === "string" ? r.version : "?", installedAt: typeof r.installedAt === "string" ? r.installedAt : "?" };
445
+ } catch {
446
+ return undefined;
447
+ }
448
+ }
449
+
450
+ /** `update` and `remove` name a recipe; anything else is a path, and paths do not go under `.agents/skills`. */
451
+ export function requireRecipeName(name: string): string {
452
+ if (!NAME_RE.test(name)) throw new Error(`"${name}" is not a recipe name (lowercase letters, digits, dashes)`);
453
+ return name;
454
+ }
455
+
456
+ /**
457
+ * `--purge`: only what the install wrote — the recorded files, their untouched copies, the setup
458
+ * script and the record — then the directory if that emptied it. `.agents/skills/<name>/` may also
459
+ * hold a project's own files, and those are not ours to delete.
460
+ */
461
+ export function purgeInstall(targetDir: string, record: InstallRecord, setup?: string): void {
462
+ for (const f of [...record.files, ...(setup ? [setup] : [])]) {
463
+ fs.rmSync(path.join(targetDir, f), { force: true });
464
+ }
465
+ fs.rmSync(path.join(targetDir, ".orig"), { recursive: true, force: true });
466
+ fs.rmSync(path.join(targetDir, RECORD_FILE), { force: true });
467
+ // Empty subdirectories the files lived in, deepest first, then the directory itself.
468
+ const dirs = [...new Set([...record.files, ...(setup ? [setup] : [])].map((f) => path.dirname(f)).filter((d) => d !== "."))].sort((a, b) => b.length - a.length);
469
+ for (const d of dirs) {
470
+ try {
471
+ fs.rmdirSync(path.join(targetDir, d));
472
+ } catch {
473
+ // not empty, or already gone: either way, not ours
474
+ }
475
+ }
476
+ try {
477
+ fs.rmdirSync(targetDir);
478
+ } catch {
479
+ // something else lives here; leave it
480
+ }
481
+ }
482
+
483
+ /** Every recipe installed in a project: the directories under `.agents/skills/` that carry a record. */
484
+ export function installedRecipes(projectDir: string): InstallRecord[] {
485
+ const root = path.join(projectDir, INSTALL_ROOT);
486
+ let names: string[];
487
+ try {
488
+ names = fs.readdirSync(root);
489
+ } catch {
490
+ return [];
491
+ }
492
+ const out: InstallRecord[] = [];
493
+ for (const n of names.sort()) {
494
+ const r = readRecord(path.join(root, n));
495
+ if (r) out.push(r);
496
+ }
497
+ return out;
498
+ }
499
+
500
+ /** The project's tracker description: written with the person by `/recipe add`, and kept out of the repository too. */
501
+ export const TRACKER_FILE = path.join("docs", "agents", "issue-tracker.md");
502
+
503
+ /**
504
+ * Keep the install out of the project's history without touching its `.gitignore`:
505
+ * `.git/info/exclude` is git's own place for a rule that belongs to this clone. Returns what it
506
+ * did, so the wizard can say it. Not a repository → nothing to exclude from.
507
+ */
508
+ export function ensureExcluded(projectDir: string, rel: string, kind: "dir" | "file" = "dir"): "added" | "present" | "no-git" {
509
+ const gitDir = resolveGitDir(projectDir);
510
+ if (!gitDir) return "no-git";
511
+ const file = path.join(gitDir, "info", "exclude");
512
+ const line = `/${rel.split(path.sep).join("/").replace(/\/+$/, "")}${kind === "dir" ? "/" : ""}`;
513
+ let text = "";
514
+ try {
515
+ text = fs.readFileSync(file, "utf8");
516
+ } catch {
517
+ // absent: git does not create it until something writes it
518
+ }
519
+ if (text.split(/\r?\n/).some((l) => l.trim() === line)) return "present";
520
+ fs.mkdirSync(path.dirname(file), { recursive: true });
521
+ fs.appendFileSync(file, `${text.length && !text.endsWith("\n") ? "\n" : ""}# pi-loops recipes (installed copies; not part of the repository)\n${line}\n`);
522
+ return "added";
523
+ }
524
+
525
+ /** `.git` may be a directory or, in a worktree, a file pointing at one. */
526
+ function resolveGitDir(projectDir: string): string | undefined {
527
+ const dot = path.join(projectDir, ".git");
528
+ try {
529
+ const st = fs.statSync(dot);
530
+ if (st.isDirectory()) return dot;
531
+ const m = fs.readFileSync(dot, "utf8").match(/^gitdir:\s*(.+)$/m);
532
+ if (!m) return undefined;
533
+ const target = path.resolve(projectDir, m[1].trim());
534
+ if (!fs.existsSync(target)) return undefined;
535
+ // A linked worktree's gitdir is <main>/.git/worktrees/<name>, and git reads info/exclude
536
+ // through the *common* dir, never from there: a rule written beside the worktree is ignored.
537
+ try {
538
+ const common = fs.readFileSync(path.join(target, "commondir"), "utf8").trim();
539
+ if (common) return path.resolve(target, common);
540
+ } catch {
541
+ // no commondir: an ordinary gitdir file (a submodule), which is its own common dir
542
+ }
543
+ return target;
544
+ } catch {
545
+ return undefined;
546
+ }
547
+ }
548
+
549
+ /* --------------------------------------------------------------------------------- update */
550
+
551
+ export interface UpdateResult {
552
+ /** Files identical to what is installed: nothing to do. */
553
+ unchanged: string[];
554
+ /** Files the person had not edited, or whose edits merged cleanly: written. */
555
+ updated: string[];
556
+ /** Files with overlapping edits: the installed copy is not written; the merge with markers is at `mergePath`. */
557
+ conflicts: Array<{ file: string; mergePath: string; basePath: string; packagedPath: string }>;
558
+ /** Files without an `.orig` (installed by hand, or by an older build): left alone. */
559
+ noBase: string[];
560
+ }
561
+
562
+ /**
563
+ * Three-way merge of the installed copy against the packaged one, with the untouched copy from
564
+ * install time as the base. Silent when the person changed nothing; a file with conflicts is not
565
+ * written — the caller hands it to the session, because markers in a playbook are text the next
566
+ * run would follow.
567
+ */
568
+ export function updateFiles(recipe: Recipe, projectDir: string, level: AutonomyLevel, opts: { git?: string } = {}): UpdateResult {
569
+ const targetDir = installDirFor(projectDir, recipe.manifest.name);
570
+ const out: UpdateResult = { unchanged: [], updated: [], conflicts: [], noBase: [] };
571
+ refuseSymlinks(projectDir, targetDir, playbookFiles(recipe.manifest));
572
+ for (const f of playbookFiles(recipe.manifest)) {
573
+ const theirs = renderFile(recipe, f, level);
574
+ const dest = path.join(targetDir, f);
575
+ if (!fs.existsSync(dest)) {
576
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
577
+ fs.writeFileSync(dest, theirs);
578
+ fs.writeFileSync(origPath(targetDir, f), theirs);
579
+ out.updated.push(f);
580
+ continue;
581
+ }
582
+ const ours = fs.readFileSync(dest, "utf8");
583
+ if (ours === theirs) {
584
+ fs.writeFileSync(origPath(targetDir, f), theirs);
585
+ out.unchanged.push(f);
586
+ continue;
587
+ }
588
+ let base: string;
589
+ try {
590
+ base = fs.readFileSync(origPath(targetDir, f), "utf8");
591
+ } catch {
592
+ out.noBase.push(f);
593
+ continue;
594
+ }
595
+ if (ours === base) {
596
+ // Not edited by hand: take the new version outright.
597
+ fs.writeFileSync(dest, theirs);
598
+ fs.writeFileSync(origPath(targetDir, f), theirs);
599
+ out.updated.push(f);
600
+ continue;
601
+ }
602
+ if (base === theirs) {
603
+ // Edited by hand, package unchanged: nothing to merge.
604
+ out.unchanged.push(f);
605
+ continue;
606
+ }
607
+ const merged = mergeThreeWay(base, ours, theirs, opts.git);
608
+ if (merged.conflicts) {
609
+ // Written beside the untouched copy, never over the playbook: markers in a playbook are
610
+ // text the next run would follow, and a prompt is not a place to keep a file.
611
+ const mergePath = `${origPath(targetDir, f)}.merge`;
612
+ fs.writeFileSync(mergePath, merged.text);
613
+ out.conflicts.push({ file: f, mergePath, basePath: origPath(targetDir, f), packagedPath: path.join(recipe.dir, f) });
614
+ continue;
615
+ }
616
+ fs.writeFileSync(dest, merged.text);
617
+ fs.writeFileSync(origPath(targetDir, f), theirs);
618
+ out.updated.push(f);
619
+ }
620
+ const record = readRecord(targetDir);
621
+ if (record) {
622
+ record.version = VERSION;
623
+ record.files = playbookFiles(recipe.manifest);
624
+ fs.writeFileSync(path.join(targetDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
625
+ }
626
+ return out;
627
+ }
628
+
629
+ /** `git merge-file -p`: exit status is the number of conflicts, 0 for a clean merge. */
630
+ export function mergeThreeWay(base: string, ours: string, theirs: string, git = "git"): { text: string; conflicts: boolean } {
631
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-loops-merge-"));
632
+ try {
633
+ const b = path.join(tmp, "base");
634
+ const o = path.join(tmp, "ours");
635
+ const t = path.join(tmp, "theirs");
636
+ fs.writeFileSync(b, base);
637
+ fs.writeFileSync(o, ours);
638
+ fs.writeFileSync(t, theirs);
639
+ try {
640
+ const text = execFileSync(git, ["merge-file", "-p", "-L", "yours", "-L", "installed", "-L", "packaged", o, b, t], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
641
+ return { text, conflicts: false };
642
+ } catch (e) {
643
+ const err = e as { status?: number | null; stdout?: string; message?: string };
644
+ // A positive status is a conflict count and stdout still holds the merged text with markers.
645
+ if (typeof err.status === "number" && err.status > 0 && typeof err.stdout === "string") return { text: err.stdout, conflicts: true };
646
+ throw new Error(`git merge-file failed: ${err.message ?? String(e)}`);
647
+ }
648
+ } finally {
649
+ fs.rmSync(tmp, { recursive: true, force: true });
650
+ }
651
+ }
package/src/store.ts CHANGED
@@ -49,6 +49,8 @@ export interface LoopJob {
49
49
  /** Fire once on startup if a tick was missed while no pi was running (default true). */
50
50
  catchUp: boolean;
51
51
  timeoutMs?: number;
52
+ /** The recipe that created this job (`/recipe add`), so `/recipe list|remove` can find it. */
53
+ recipe?: string;
52
54
  createdAt: string;
53
55
  createdBy?: { sessionId?: string; cwd: string };
54
56
  /** Host the job belongs to (shared $HOME across machines): other hosts ignore it. Missing = any host (pre-0.1.3). */
package/src/tools.ts CHANGED
@@ -54,6 +54,8 @@ export interface CreateJobInput {
54
54
  catchUp?: boolean;
55
55
  verify?: boolean;
56
56
  checkerModel?: string;
57
+ /** Set by `/recipe add`: which recipe this job belongs to. */
58
+ recipe?: string;
57
59
  }
58
60
 
59
61
  export interface JobScope {
@@ -147,6 +149,7 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
147
149
  checkerModel: input.stateful && input.verify ? input.checkerModel : undefined,
148
150
  catchUp: input.catchUp ?? input.stateful,
149
151
  timeoutMs: input.timeoutMs,
152
+ recipe: input.recipe,
150
153
  createdAt: stamp(),
151
154
  // A sub-agent schedules on behalf of the session that runs it.
152
155
  createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },