@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.
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: issue-triage
3
+ description: One run of the triage loop. Moves the repository's open issues through the label state machine (needs-triage → needs-info | ready-for-agent | ready-for-human | wontfix), verifies claims, writes agent briefs, and reports recommendations to the inbox.
4
+ ---
5
+
6
+ # Triage: one run
7
+
8
+ Autonomy: propose
9
+
10
+ The line above is the installed autonomy level; `/recipe add` wrote it and a person may change it.
11
+ - `propose`: you evaluate, verify, comment, and label `needs-triage` / `needs-info`. Moving an
12
+ issue to `ready-for-agent`, `ready-for-human` or `wontfix` is the maintainer's click; you
13
+ recommend it in the inbox.
14
+ - `act`: additionally, you move a **reproduced bug** with a brief to `ready-for-agent` yourself,
15
+ and close an issue that is already implemented or a duplicate (with the comment). Enhancements
16
+ and rejections still wait for the maintainer.
17
+
18
+ You are the triage loop. Nobody is watching. Everything you post on the tracker is read by people
19
+ later, and your final reply is read by a program (the loop protocol at the end of your prompt).
20
+
21
+ ## The state machine
22
+
23
+ Every open issue carries one **category** label (`bug` | `enhancement`) and exactly one **state**
24
+ label:
25
+
26
+ | state | meaning | who moves an issue out of it |
27
+ |---|---|---|
28
+ | *(none)* | never looked at | you → `needs-triage`, first sighting |
29
+ | `needs-triage` | being evaluated | you → `needs-info`; maintainer → `ready-for-agent`, `ready-for-human`, `wontfix` |
30
+ | `needs-info` | waiting on the reporter | you → `needs-triage` once the reporter has replied |
31
+ | `ready-for-agent` | brief accepted, an agent may build it | the implement loop |
32
+ | `ready-for-human` | needs a person | the person |
33
+ | `agent-working`, `in-review`, `agent-blocked` | the implement loop's states | not yours to touch |
34
+ | `wontfix` | closed, will not be actioned | — |
35
+
36
+ **The tracker is the truth; your notes are a cache.** Read labels and comments fresh every run.
37
+ Your notes carry only what the tracker cannot tell you: which comment timestamp you last handled
38
+ per issue, so you do not re-triage or re-ask.
39
+
40
+ Every comment you post starts with this line, on its own:
41
+
42
+ ```
43
+ > *This was generated by AI during triage.*
44
+ ```
45
+
46
+ ## What to do
47
+
48
+ **First, read `docs/agents/issue-tracker.md`.** It says which tracker this repository uses and how
49
+ to list, read, comment, change state, claim and close. The `gh` commands in this playbook are the
50
+ GitHub spelling; if the file describes local Markdown files, do each step the way it describes
51
+ instead (the states and the checkpoints are the same).
52
+
53
+ 1. **List the work.** Open issues that are (a) unlabeled, (b) `needs-triage`, or (c) `needs-info`
54
+ with a comment by someone other than you that is newer than the timestamp in your notes for
55
+ that issue. Oldest first. Take at most **3** this run; the rest wait for the next one.
56
+
57
+ ```sh
58
+ gh issue list --state open --limit 200 --json number,title,labels,updatedAt,author
59
+ gh issue view <n> --comments --json number,title,body,labels,author,createdAt,comments
60
+ ```
61
+
62
+ 2. **For each issue, gather context.** Read the whole issue and every comment; parse any earlier
63
+ *Triage Notes* or *Agent Brief* of yours so nothing already settled is asked again. Then:
64
+ - **Redundancy:** search the codebase for an existing implementation of the request — by the
65
+ concept, not the reporter's words — and remember where you looked.
66
+ - **Prior rejection:** read every `.out-of-scope/*.md` in the repository (if the directory
67
+ exists) and note any that resembles this request.
68
+ - **Duplicates:** compare against the other open issues' titles.
69
+
70
+ 3. **Verify the claim.** For a bug, reproduce it from the reporter's steps: run the command, the
71
+ test, the script. For an enhancement, confirm it is not already there. Record what happened:
72
+ confirmed (with the code path), not reproduced, or not enough detail to try.
73
+
74
+ 4. **Apply the outcome.**
75
+ - **Fully specified and verified** → post an *Agent Brief* (template below), make sure the
76
+ labels are `needs-triage` plus the category, and report to the inbox:
77
+ `#<n> brief posted — recommend ready-for-agent (<category>, <verified how>)`.
78
+ - **Needs a person** (design decision, external access, manual testing, taste) → post the brief
79
+ anyway with a line saying why it cannot be delegated; recommend `ready-for-human`.
80
+ - **Not enough to act on** → post *Triage Notes* (template below) with specific questions,
81
+ move to `needs-info`, report: `#<n> → needs-info (asked: <one phrase per question>)`.
82
+ - **Already implemented** → say where it lives in a comment; recommend `wontfix (already
83
+ implemented)`. Do not write to `.out-of-scope/`.
84
+ - **Out of scope / duplicate** → a polite comment with the reason (and the earlier issue, or
85
+ the `.out-of-scope/` file it matches); recommend `wontfix` or `duplicate of #<m>`.
86
+ - **Reporter replied on a `needs-info` issue** → move it back to `needs-triage` and evaluate
87
+ it again from step 2 in this same run.
88
+
89
+ Labels: `gh issue edit <n> --add-label "<state>" --remove-label "<old state>"`. Never leave an
90
+ issue with two state labels. Under `propose`, never close an issue.
91
+
92
+ 5. **Do not** edit code, open branches, or touch issues in `ready-for-agent`, `agent-working`,
93
+ `in-review` or `agent-blocked` — those belong to the implement loop and the maintainer.
94
+
95
+ ## Agent Brief
96
+
97
+ The brief is the contract the implement loop works from; the issue body and discussion are only
98
+ context. Write it to survive weeks of unrelated change: name types, functions, commands and
99
+ behaviour, never file paths or line numbers. Behavioural, not procedural. Acceptance criteria that
100
+ can each be checked on their own. Say what is out of scope.
101
+
102
+ ```markdown
103
+ > *This was generated by AI during triage.*
104
+
105
+ ## Agent Brief
106
+
107
+ **Category:** bug / enhancement
108
+ **Summary:** one line
109
+
110
+ **Current behavior:** what happens now (for a bug: the broken behaviour, reproduced how)
111
+ **Desired behavior:** what should happen, including edge cases and errors
112
+
113
+ **Key interfaces:**
114
+ - `TypeName` / `functionName()` / config shape: what changes and why
115
+
116
+ **Acceptance criteria:**
117
+ - [ ] specific, independently checkable
118
+ - [ ] …
119
+
120
+ **Out of scope:**
121
+ - what must not change in this issue
122
+ ```
123
+
124
+ ## Triage Notes (needs-info)
125
+
126
+ ```markdown
127
+ > *This was generated by AI during triage.*
128
+
129
+ ## Triage Notes
130
+
131
+ **What we've established so far:**
132
+ - …
133
+
134
+ **What we still need from you (@reporter):**
135
+ - a specific, answerable question
136
+ ```
137
+
138
+ ## Notes for the next run
139
+
140
+ One line per open issue you have handled: `#<n> <state you left it in> last-comment=<ISO timestamp
141
+ with offset> brief=yes|no`. Drop closed issues. Stay under 2000 characters — if you must cut, cut
142
+ the oldest `needs-info` lines; the tracker still has everything.
143
+
144
+ ## Inbox
145
+
146
+ One line per issue you acted on, in the shapes above. No issues needing attention → no inbox
147
+ lines. A finding is a decision for the maintainer, so it carries the recommendation and the reason.
@@ -16,6 +16,7 @@ The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops
16
16
  | the findings must be double-checked before the user sees them | `cron_create` with `stateful: true, verify: true` (a second adversarial sub-agent reviews each finding) |
17
17
  | "when <condition> happens, do <action>" (a file appears, a PR merges, a build finishes) | `new_trigger` — condition + action, fires once unless the user asks for repeating |
18
18
  | a reminder in N minutes | `cron_create` with schedule `in 10m` |
19
+ | run the project's issue tracker as a standing process (triage, build, review), or an experiment loop against a research contract | tell the user to run `/recipe list` and `/recipe add <name>` — a packaged set of loops installed with one confirmation; do not recreate it with `cron_create` |
19
20
 
20
21
  Never use `new_trigger` for time-based schedules; never use `cron_create` for conditions.
21
22
 
package/src/cli.ts CHANGED
@@ -20,6 +20,7 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
20
20
  import { liveHost, stopHost } from "./host-control.ts";
21
21
  import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
22
22
  import { JobStore, defaultLoopsDir } from "./store.ts";
23
+ import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
23
24
  import { TriggerStore } from "./triggers.ts";
24
25
  import { PI_LOOPS_VERSION } from "./version.ts";
25
26
  import { stamp } from "./schedule.ts";
@@ -104,6 +105,10 @@ export const CLI_USAGE = [
104
105
  "",
105
106
  "pi-loops host status | abort <run-id|trace-id> | stop",
106
107
  " Look in on the background host that keeps the clock while no pi is open, or interrupt it.",
108
+ "",
109
+ "pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]",
110
+ " The packaged recipes; what one installs; copy its playbooks into a project (.agents/skills/<name>/).",
111
+ " The setup script and the jobs need a pi open in the project: /recipe add <name> finds the copies and finishes.",
107
112
  ].join("\n");
108
113
 
109
114
  /**
@@ -239,6 +244,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
239
244
  if (command === "web" || command === "tui") return launch(argv, out);
240
245
  if (command === "upgrade") return upgrade(flags.has("check"), out);
241
246
  if (command === "install-launcher") return installLauncher(str("dir"), out);
247
+ if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
242
248
 
243
249
  if (command === "sessions") {
244
250
  // `pickSession` refuses an unknown id, and there was no way to discover one.
@@ -375,7 +381,66 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
375
381
  return asked ? 2 : 0;
376
382
  }
377
383
 
378
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "web", "upgrade", "install-launcher", "help"]);
384
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "web", "upgrade", "install-launcher", "help"]);
385
+
386
+ /**
387
+ * `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
388
+ * the playbooks, lists the directory in `.git/info/exclude` and writes the record — and stops there:
389
+ * the setup script is shown to a person before it runs and the jobs are created after a
390
+ * confirmation, both of which need the pi that `/recipe add` runs in. It finds the copies in place.
391
+ */
392
+ async function recipeCommand(positional: string[], opts: { level?: string; overwrite: boolean }, cwd: string, loopsDir: string, out: (line: string) => void): Promise<number> {
393
+ const sub = positional[0] ?? "list";
394
+ if (sub === "list") {
395
+ const packaged = listRecipes();
396
+ const installed = new Map(installedRecipes(cwd).map((r) => [r.recipe, r]));
397
+ const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && j.cwd === cwd);
398
+ for (const r of packaged) {
399
+ const rec = installed.get(r.manifest.name);
400
+ const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
401
+ out(`${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`);
402
+ }
403
+ for (const rec of installed.values()) if (!packaged.some((r) => r.manifest.name === rec.recipe)) out(`${rec.recipe.padEnd(17)} installed from ${rec.source} (${rec.level})`);
404
+ if (!packaged.length && !installed.size) out("no recipes packaged, none installed here");
405
+ return 0;
406
+ }
407
+ if (sub === "show" || sub === "add") {
408
+ const ref = positional[1];
409
+ if (!ref) throw new Error(`recipe ${sub} needs a recipe name or a directory`);
410
+ const resolved = resolveRecipeRef(ref, { cwd });
411
+ const recipe = loadRecipe(resolved.dir);
412
+ const m = recipe.manifest;
413
+ const rel = path.join(INSTALL_ROOT, m.name);
414
+ if (sub === "show") {
415
+ out(`${m.name} (${recipe.dir})`);
416
+ out(` ${m.summary}`);
417
+ out(` levels: ${m.levels.join(", ")} tracker: ${m.needsTracker ? "needs docs/agents/issue-tracker.md" : "not needed"}${m.setup ? ` setup: ${m.setup}` : ""}`);
418
+ out(` files → ${rel}/: ${playbookFiles(m).join(", ")}`);
419
+ for (const j of m.jobs) out(` /cron add ${addLineFor(j, rel)}`);
420
+ const record = readRecord(installDirFor(cwd, m.name));
421
+ if (record) out(` installed in ${cwd}: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`);
422
+ return 0;
423
+ }
424
+ let level = m.levels[0];
425
+ if (opts.level) {
426
+ if (!m.levels.includes(opts.level as AutonomyLevel)) throw new Error(`${m.name} supports ${m.levels.join(", ")}, not "${opts.level}" (levels are ${AUTONOMY_LEVELS.join(", ")})`);
427
+ level = opts.level as AutonomyLevel;
428
+ }
429
+ const plan = planInstall(recipe, cwd, level);
430
+ const record = installFiles(recipe, cwd, level, { overwrite: opts.overwrite, source: resolved.source });
431
+ const excluded = ensureExcluded(cwd, rel);
432
+ if (m.needsTracker && fs.existsSync(path.join(cwd, TRACKER_FILE))) ensureExcluded(cwd, TRACKER_FILE, "file");
433
+ out(`copied ${record.files.length} file(s) → ${plan.targetDir}/ (level ${level}${opts.level ? "" : ", the lowest; --level to choose"})`);
434
+ for (const f of plan.changed) out(` ${f}: ${opts.overwrite ? "overwritten" : "kept as edited (--overwrite replaces it)"}`);
435
+ out(excluded === "no-git" ? "not a git repository: nothing to exclude" : `.git/info/exclude: ${excluded === "added" ? "added" : "already listed"}`);
436
+ if (m.needsTracker && !fs.existsSync(path.join(cwd, TRACKER_FILE))) out(`${m.name} reads docs/agents/issue-tracker.md and this project has none: /recipe add ${m.name} in pi writes it with you first`);
437
+ out(`no jobs created: open pi in ${cwd} and run /recipe add ${m.name} — it shows ${m.setup ? `${m.setup} and ` : ""}these lines, then creates them:`);
438
+ for (const line of plan.addLines) out(` /cron add ${line}`);
439
+ return 0;
440
+ }
441
+ out(`unknown recipe command ${JSON.stringify(sub)}: pi-loops recipe list | show <name|path> | add <name|path>`);
442
+ return 2;
443
+ }
379
444
 
380
445
  /** `v1.2.3` → comparable parts; anything else is not a release and is ignored. */
381
446
  function semver(tag: string): [number, number, number] | undefined {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What pi loads (`pi.extensions` in package.json): a version check, then the extension.
3
+ *
4
+ * The check cannot sit in `src/pi-loops.ts`. That file and the modules under it import names pi
5
+ * did not always export (`ModelRuntime`, `readStoredCredential`, `createSyntheticSourceInfo`) and
6
+ * subscribe to events it did not always emit. ESM links a whole import graph before running a line
7
+ * of it, so on an older pi the failure is a link error naming one export — "does not provide an
8
+ * export named ModelRuntime" — and nothing in it says "your pi is too old". This file imports only
9
+ * what every pi has exported for a long time, reads the version, and imports the rest afterwards,
10
+ * so what an old pi prints is the sentence that says which pi it needs.
11
+ *
12
+ * A namespace import on purpose: a name missing from an old pi is then `undefined` rather than a
13
+ * link error, which is exactly the case the check exists for.
14
+ */
15
+ import * as sdk from "@earendil-works/pi-coding-agent";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { piTooOld } from "./pi-floor.ts";
18
+
19
+ export default async function (pi: ExtensionAPI) {
20
+ const refusal = piTooOld(sdk.VERSION);
21
+ if (refusal) throw new Error(refusal);
22
+ const { default: piLoops } = await import("./pi-loops.ts");
23
+ return piLoops(pi);
24
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The oldest pi this extension loads into. `src/extension-entry.ts` reads it before anything else
3
+ * is imported, both READMEs state it, and `test/pi-floor.test.ts` keeps the three the same.
4
+ *
5
+ * 0.84.3 is where the API this code is written against became complete: `session_compact_failed`
6
+ * arrived there, `ModelRuntime` and `readStoredCredential` in 0.80.8, `ctx.thinkingLevel` in
7
+ * between. It was found by type-checking `src/` against older releases rather than by reading
8
+ * changelogs — 0.80.8 fails on the two newest names, 0.84.3 passes clean — and it is the version
9
+ * to re-check the same way when a newer name is used. Nothing enforces it at install time: pi
10
+ * installs packages with `--legacy-peer-deps`, so `peerDependencies` stays `*` as pi's docs ask
11
+ * and the range there would not be read anyway.
12
+ */
13
+ export const PI_MIN_VERSION = "0.84.3";
14
+
15
+ /** `[major, minor, patch]`, or undefined for anything that does not start `N.N.N` (a `v` in front is fine). */
16
+ function parse(version: string): [number, number, number] | undefined {
17
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
18
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
19
+ }
20
+
21
+ /**
22
+ * Why this pi cannot load pi-loops, as the one sentence pi prints after "Failed to load extension",
23
+ * or undefined when it can. A pi that reports no version is refused as well: every pi new enough
24
+ * to load this exports `VERSION`, so a missing one is an old one, not an unknown one.
25
+ */
26
+ export function piTooOld(version: string | undefined, min: string = PI_MIN_VERSION): string | undefined {
27
+ const have = version === undefined ? undefined : parse(version);
28
+ const need = parse(min);
29
+ if (!need) throw new Error(`PI_MIN_VERSION is not a version: ${JSON.stringify(min)}`);
30
+ const upgrade = "upgrade pi (`pi update`, or `npm i -g @earendil-works/pi-coding-agent`)";
31
+ if (!have) return `pi-loops needs pi ${min} or newer and this pi reports ${version === undefined ? "no version" : `version ${JSON.stringify(version)}`}: ${upgrade}`;
32
+ for (let i = 0; i < 3; i++) {
33
+ if (have[i] > need[i]) return undefined;
34
+ if (have[i] < need[i]) return `pi-loops needs pi ${min} or newer and this is pi ${version}: ${upgrade}`;
35
+ }
36
+ return undefined;
37
+ }