@alphacoder-v0/pi-loops 0.20.0 → 0.22.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 (48) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/CONTRACT.md +139 -0
  3. package/README.md +11 -7
  4. package/README.zh-CN.md +6 -4
  5. package/docs/cli.md +19 -1
  6. package/docs/downstream.md +60 -0
  7. package/docs/goal.md +4 -2
  8. package/docs/loops.md +55 -9
  9. package/docs/recipes.md +94 -18
  10. package/docs/session-archive.md +8 -3
  11. package/docs/web-ui-parity.md +11 -1
  12. package/package.json +5 -2
  13. package/recipes/_tracker/issue-tracker-local.md +3 -1
  14. package/recipes/autoresearch/experiment.md +30 -6
  15. package/recipes/autoresearch/recipe.toml +2 -0
  16. package/recipes/changelog-draft/recipe.toml +4 -0
  17. package/recipes/changelog-draft/release.md +8 -3
  18. package/recipes/ci-sweeper/recipe.toml +4 -0
  19. package/recipes/ci-sweeper/sweep.md +2 -1
  20. package/recipes/daily-digest/digest.md +5 -4
  21. package/recipes/daily-digest/recipe.toml +2 -0
  22. package/recipes/deps-sweeper/README.md +11 -0
  23. package/recipes/deps-sweeper/audit.md +99 -0
  24. package/recipes/deps-sweeper/recipe.toml +17 -0
  25. package/recipes/ecosystem/recipe.toml +3 -0
  26. package/recipes/ecosystem/scan.md +1 -1
  27. package/recipes/issue-loop/implement.md +17 -4
  28. package/recipes/issue-loop/labels.sh +18 -0
  29. package/recipes/issue-loop/recipe.toml +2 -0
  30. package/recipes/issue-loop/triage.md +16 -2
  31. package/recipes/pr-watch/recipe.toml +3 -0
  32. package/skills/pi-loops/SKILL.md +3 -2
  33. package/src/archive.ts +97 -2
  34. package/src/cli.ts +103 -16
  35. package/src/goal.ts +11 -0
  36. package/src/hooks.ts +25 -1
  37. package/src/host-runtime.ts +4 -8
  38. package/src/inbox.ts +118 -55
  39. package/src/job-signal.ts +78 -0
  40. package/src/pi-loops.ts +177 -109
  41. package/src/presence.ts +5 -0
  42. package/src/protocol.ts +45 -2
  43. package/src/recipe.ts +158 -2
  44. package/src/scheduler.ts +6 -2
  45. package/src/store.ts +8 -3
  46. package/src/tools.ts +17 -11
  47. package/src/triggers.ts +8 -3
  48. package/src/web.mjs +73 -7
@@ -33,7 +33,10 @@ Do these in order; stop after the first that produces a full run's work.
33
33
  review state is `CHANGES_REQUESTED`, or that has a comment by a person newer than the
34
34
  timestamp in your notes: check out its worktree, address every point (reply to each comment
35
35
  saying what you did, or why not), run the project's checks, push, and re-request review.
36
- Report: `#<n> review feedback addressed — PR <url>`.
36
+ Report: `#<n> review feedback addressed — PR <url>`. With a local Markdown tracker there is no
37
+ pull request: the feedback is a comment under the item's `## Comments` newer than your notes'
38
+ timestamp, and the person has set the item back to `agent-working`; address it on the branch
39
+ and set `in-review` again.
37
40
  3. **One new issue.** The frontier is the open issues labeled `ready-for-agent` with no assignee
38
41
  whose blockers are all closed (a `Blocked by: #m` line in the body or brief, or the tracker's
39
42
  native dependency). Take the **oldest one**. One per run — a second one waits for the next run.
@@ -95,8 +98,9 @@ branch and worktree path to your notes **now**, before any implementation, so a
95
98
  gh issue edit <n> --add-label in-review --remove-label agent-working
96
99
  ```
97
100
 
98
- Report: `#<n> PR opened — <url> (<k> commits, checks green)`. The maintainer merges; the
99
- `Closes` line closes the issue.
101
+ Report: `#<n> PR opened — <url> (<k> commits, checks green) · waits: your review · if not:
102
+ stays in-review; I address review comments and re-request, and do not nudge`. The maintainer
103
+ merges; the `Closes` line closes the issue.
100
104
 
101
105
  ## Blocked
102
106
 
@@ -108,7 +112,8 @@ gh issue edit <n> --add-label agent-blocked --remove-label agent-working
108
112
  ```
109
113
 
110
114
  Comment with the specific question or the specific failure (command and output), the branch, and
111
- what you would do next given an answer. Report: `#<n> blocked — <one-line reason>`. The
115
+ what you would do next given an answer. Report: `#<n> blocked — <one-line reason> · waits: your
116
+ answer on the issue · if not: untouched; other ready-for-agent issues still get built`. The
112
117
  maintainer answers, edits the brief if needed, and moves the issue back to `ready-for-agent`; do
113
118
  not touch an `agent-blocked` issue again until they do.
114
119
 
@@ -133,3 +138,11 @@ tracker has the rest.
133
138
 
134
139
  One line per state change you made, in the shapes above. Housekeeping produces no inbox lines. A
135
140
  run that only resumed and is still working produces none either.
141
+
142
+ ## Never
143
+
144
+ Never push to the default branch, never force-push, never rebase a branch someone has reviewed.
145
+ Never merge: the pull request — or, with a local tracker, the branch — waits for a person at
146
+ every level. Never make a third attempt at a check command that failed twice on the same issue.
147
+ Never touch an `agent-blocked` issue until a person has moved it back to `ready-for-agent`.
148
+ Never post a comment without the line saying it was generated by AI.
@@ -2,8 +2,26 @@
2
2
  # The labels the issue loop moves issues through. Idempotent: --force updates the colour and
3
3
  # description of a label that already exists and never touches the issues carrying it.
4
4
  # Run inside a clone of the repository (gh infers the repository from the remote).
5
+ #
6
+ # Labels are GitHub's: a local Markdown tracker (docs/agents/issue-tracker.md naming
7
+ # .scratch/issues/) has none to create, and a GitHub tracker without a working `gh` gets its
8
+ # labels later — the first run reports the missing CLI as a finding. Neither stops the install.
5
9
  set -e
6
10
 
11
+ tracker="docs/agents/issue-tracker.md"
12
+ if [ -f "$tracker" ] && grep -q '\.scratch/issues' "$tracker"; then
13
+ echo "local Markdown tracker ($tracker): no labels to create"
14
+ exit 0
15
+ fi
16
+ if ! command -v gh >/dev/null 2>&1; then
17
+ echo "gh is not on the PATH: no labels created; install the GitHub CLI and run this script by hand, or let the first run report it"
18
+ exit 0
19
+ fi
20
+ if ! gh auth status >/dev/null 2>&1; then
21
+ echo "gh is not logged in: no labels created; run 'gh auth login' and this script by hand, or let the first run report it"
22
+ exit 0
23
+ fi
24
+
7
25
  lab() { gh label create "$1" --description "$2" --color "$3" --force >/dev/null && echo " $1"; }
8
26
 
9
27
  echo "category labels"
@@ -1,6 +1,8 @@
1
1
  # The issue loop: the tracker as a state machine that three loops turn. docs/recipes.md explains it.
2
2
  name = "issue-loop"
3
3
  summary = "Triage new issues into agent briefs, build the accepted ones in a worktree, open pull requests; a person promotes and merges."
4
+ tier = "advanced"
5
+ useful_when = ["Issues arrive faster than you read them, and most are buildable once someone writes the brief.", "You want the tracker to be the state machine, and your part to be two clicks: accept the brief, merge the pull request."]
4
6
  needs_tracker = true
5
7
  levels = ["propose", "act"]
6
8
  setup = "labels.sh"
@@ -74,9 +74,12 @@ instead (the states and the checkpoints are the same).
74
74
  4. **Apply the outcome.**
75
75
  - **Fully specified and verified** → post an *Agent Brief* (template below), make sure the
76
76
  labels are `needs-triage` plus the category, and report to the inbox:
77
- `#<n> brief posted — recommend ready-for-agent (<category>, <verified how>)`.
77
+ `#<n> brief posted — recommend ready-for-agent (<category>, <verified how>) · waits: the
78
+ implement loop skips it until labeled · if not: stays needs-triage, no reminder`.
78
79
  - **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
+ anyway with a line saying why it cannot be delegated; recommend `ready-for-human`:
81
+ `#<n> brief posted — recommend ready-for-human (<why>) · waits: a person · if not: stays
82
+ needs-triage`.
80
83
  - **Not enough to act on** → post *Triage Notes* (template below) with specific questions,
81
84
  move to `needs-info`, report: `#<n> → needs-info (asked: <one phrase per question>)`.
82
85
  - **Already implemented** → say where it lives in a comment; recommend `wontfix (already
@@ -86,6 +89,10 @@ instead (the states and the checkpoints are the same).
86
89
  - **Reporter replied on a `needs-info` issue** → move it back to `needs-triage` and evaluate
87
90
  it again from step 2 in this same run.
88
91
 
92
+ Every recommendation you report is a checkpoint, and the shape is fixed: the recommendation,
93
+ then ` · waits: <what the label unblocks> · if not: <what stays as it is>`. Under `act`, the
94
+ moves you make yourself are reported as news, without the two clauses.
95
+
89
96
  Labels: `gh issue edit <n> --add-label "<state>" --remove-label "<old state>"`. Never leave an
90
97
  issue with two state labels. Under `propose`, never close an issue.
91
98
 
@@ -145,3 +152,10 @@ the oldest `needs-info` lines; the tracker still has everything.
145
152
 
146
153
  One line per issue you acted on, in the shapes above. No issues needing attention → no inbox
147
154
  lines. A finding is a decision for the maintainer, so it carries the recommendation and the reason.
155
+
156
+ ## Never
157
+
158
+ Never edit code, open a branch or a worktree, or touch an issue in `ready-for-agent`,
159
+ `agent-working`, `in-review` or `agent-blocked`. Never leave an issue with two state labels.
160
+ Never close an issue under `propose`; under `act`, never close anything but an already-implemented
161
+ request or a duplicate. Never post a comment without the line saying it was generated by AI.
@@ -1,8 +1,11 @@
1
1
  # pr-watch: the open pull requests, and only what changed about them. docs/recipes.md.
2
2
  name = "pr-watch"
3
3
  summary = "Watches the open pull requests for what stalls them — a red check, a conflict, a review nobody answered — and reports only what changed since last time."
4
+ tier = "starter"
5
+ useful_when = ["Pull requests stall for days before anyone notices the red check or the unanswered review.", "You want to hear what changed about them, not what they are."]
4
6
  needs_tracker = true
5
7
  levels = ["report", "propose"]
8
+ needs = ["gh", "tracker-github"]
6
9
  budget_hint_usd = 2
7
10
 
8
11
  [[job]]
@@ -17,7 +17,7 @@ The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops
17
17
  | 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) |
18
18
  | "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 |
19
19
  | a reminder in N minutes | `cron_create` with schedule `in 10m` |
20
- | 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` |
20
+ | run the project's issue tracker as a standing process (triage, build, review), an experiment loop against a research contract, a CI or dependency sweeper, a PR watch, a daily digest | 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` |
21
21
 
22
22
  Never use `new_trigger` for time-based schedules; never use `cron_create` for conditions.
23
23
 
@@ -42,7 +42,8 @@ reported in your notes; report only new or newly closed issues". Findings should
42
42
 
43
43
  ## Triage
44
44
 
45
- `/inbox` lists findings; `/inbox claim <n>` hands one to you as a real turn; `/inbox dismiss <n>`.
45
+ `/inbox` lists findings; `/inbox claim <n>` hands one to you as a real turn; `/inbox dismiss <n> [reason]`
46
+ (a reason is shown to the loop's next run, so a finding the user calls noise can say why).
46
47
  `/cron` lists jobs, `/cron runs`, `/cron trace <job> [k]` shows what a run did. `/triggers rules`,
47
48
  `/triggers audit` for dynamic triggers.
48
49
 
package/src/archive.ts CHANGED
@@ -17,11 +17,12 @@ import { createHash, randomUUID } from "node:crypto";
17
17
  import * as fs from "node:fs";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
+ import { sameProject } from "./presence.ts";
20
21
  import { LOOP_STATE_MAX_CHARS, capChars } from "./protocol.ts";
21
22
  import { previewRedacted } from "./redact.ts";
22
23
  import { computeNext, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
23
- import { newId, type LoopJob } from "./store.ts";
24
- import { type DynamicTriggerRule, newRuleId } from "./triggers.ts";
24
+ import { type JobStore, newId, type LoopJob } from "./store.ts";
25
+ import { type DynamicTriggerRule, type TriggerStore, newRuleId } from "./triggers.ts";
25
26
 
26
27
  export const ARCHIVE_SCHEMA = "pi-loops.session_export.v1";
27
28
  export const ARCHIVE_EXT = ".pisession";
@@ -182,6 +183,24 @@ export function defaultExportPath(cwd: string, sessionId: string): string {
182
183
  return path.join(cwd, `pi-session-${sessionId.slice(0, 16)}${ARCHIVE_EXT}`);
183
184
  }
184
185
 
186
+ /**
187
+ * What an export of `sessionId` in `cwd` carries: this project's automation, narrowed to what that
188
+ * session created.
189
+ *
190
+ * The store is machine-global, so the project alone would sweep in a colleague's loops — or your
191
+ * own from another window — and the session that created a job or rule is what scopes the archive.
192
+ * A job or rule from before `createdBy` existed has no owner to ask, and counts as this session's
193
+ * when it is in this project. "This project" is `sameProject`, not a string comparison: a worktree
194
+ * or a symlinked path is the same project and its loops belong in the same archive.
195
+ */
196
+ export function automationOf(jobs: LoopJob[], rules: DynamicTriggerRule[], cwd: string, sessionId?: string): { jobs: LoopJob[]; rules: DynamicTriggerRule[] } {
197
+ const mine = (owner: { sessionId?: string } | undefined, ownerCwd: string): boolean => (owner?.sessionId ? owner.sessionId === sessionId : sameProject(ownerCwd, cwd));
198
+ return {
199
+ jobs: jobs.filter((j) => sameProject(j.cwd, cwd) && mine(j.createdBy, j.cwd)),
200
+ rules: rules.filter((r) => sameProject(r.cwd, cwd) && mine(r.createdBy, r.cwd)),
201
+ };
202
+ }
203
+
185
204
  export function exportSession(input: ExportInput): ExportSummary {
186
205
  const sessionBytes = fs.readFileSync(input.sessionFile);
187
206
  if (sessionBytes.length > MAX_SESSION_BYTES) throw new Error("session file exceeds the 50 MiB archive cap");
@@ -437,6 +456,82 @@ export function importSession(input: ImportInput): ImportSummary {
437
456
  };
438
457
  }
439
458
 
459
+ /**
460
+ * What `--activate-triggers` says about the automation an archive carries: leave it off and say
461
+ * nothing (`off`), ask once after the import whether to switch back on what the source had on
462
+ * (`ask`, the default), or activate it as part of the import (`on`).
463
+ */
464
+ export type ActivateMode = "off" | "ask" | "on";
465
+
466
+ /** `--activate-triggers=X` and `--activate-triggers X`, wherever either is typed. */
467
+ export function parseActivateMode(value: string | undefined): ActivateMode {
468
+ if (value === undefined) return "ask";
469
+ if (value === "off" || value === "ask" || value === "on") return value;
470
+ throw new Error(`--activate-triggers must be off, ask or on (got ${value})`);
471
+ }
472
+
473
+ /**
474
+ * Commit an import to the stores: jobs, then their loop state, then rules.
475
+ *
476
+ * A write that fails part way is rolled back — the imported session file, the imported jobs and
477
+ * their state files go, and the original error is rethrown — because the alternative is a store
478
+ * holding half an archive: jobs whose rules never arrived, or a session file nothing points at.
479
+ * The rollback is best effort by construction; the failure it is undoing is usually a store that
480
+ * cannot be written.
481
+ */
482
+ export async function applyImport(imp: ImportSummary, jobStore: JobStore, triggerStore: TriggerStore): Promise<void> {
483
+ try {
484
+ if (imp.jobs.length) await jobStore.mutate((jobs) => ({ jobs: [...jobs, ...imp.jobs], result: undefined }));
485
+ for (const [id, text] of Object.entries(imp.states)) jobStore.writeState(id, text);
486
+ if (imp.rules.length) await triggerStore.mutate((rules) => rules.push(...imp.rules));
487
+ } catch (err) {
488
+ const ids = new Set(imp.jobs.map((j) => j.id));
489
+ const attempt = (fn: () => void) => {
490
+ try {
491
+ fn();
492
+ } catch {
493
+ /* best effort */
494
+ }
495
+ };
496
+ attempt(() => fs.rmSync(imp.sessionPath, { force: true }));
497
+ await jobStore.mutate((jobs) => ({ jobs: jobs.filter((j) => !ids.has(j.id)), result: undefined })).catch(() => {});
498
+ for (const id of ids) attempt(() => fs.rmSync(jobStore.statePath(id), { force: true }));
499
+ throw err;
500
+ }
501
+ }
502
+
503
+ /**
504
+ * What `ask` means: after an import that did not activate, offer once to switch back on what the
505
+ * source session had on, and do it if the answer is yes. Returns what was enabled.
506
+ *
507
+ * `confirm` is the caller's way of asking — a TUI dialog, a terminal prompt — and a caller with
508
+ * nobody to ask passes nothing, which is the same as no. `onJobEnabled` is the hook the extension
509
+ * uses to keep its cron audit trail; nothing else needs it.
510
+ */
511
+ export async function offerToEnable(
512
+ imp: ImportSummary,
513
+ jobStore: JobStore,
514
+ triggerStore: TriggerStore,
515
+ confirm?: (question: string, detail: string) => boolean | Promise<boolean>,
516
+ onJobEnabled?: (before: LoopJob, after: LoopJob) => void,
517
+ ): Promise<{ jobs: number; rules: number }> {
518
+ const none = { jobs: 0, rules: 0 };
519
+ if (imp.automationEnabled) return none;
520
+ const pending = imp.originallyEnabledJobs.length + imp.originallyEnabledRules.length;
521
+ if (!pending || !confirm) return none;
522
+ const ok = await confirm("Enable imported automation?", `${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s) were enabled in the source session. Enable them here now?`);
523
+ if (!ok) return none;
524
+ for (const id of imp.originallyEnabledJobs) {
525
+ const before = jobStore.load().find((j) => j.id === id);
526
+ const after = await jobStore.update(id, (j) => {
527
+ j.enabled = true;
528
+ });
529
+ if (before && after) onJobEnabled?.(before, after);
530
+ }
531
+ for (const id of imp.originallyEnabledRules) await triggerStore.setEnabled(id, true);
532
+ return { jobs: imp.originallyEnabledJobs.length, rules: imp.originallyEnabledRules.length };
533
+ }
534
+
440
535
  /**
441
536
  * Recognises automation this store already imported from the same archive. Without it, importing an
442
537
  * archive twice leaves two live copies of every job and rule, each running and each billing.
package/src/cli.ts CHANGED
@@ -15,8 +15,11 @@ import * as path from "node:path";
15
15
  import * as readline from "node:readline";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
18
- import { exportSession, defaultExportPath, importSession, inspectArchive } from "./archive.ts";
18
+ import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
19
19
  import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
20
+ import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } from "./inbox.ts";
21
+ import { sameProject } from "./presence.ts";
22
+ import { redact } from "./redact.ts";
20
23
  import { liveHost, stopHost } from "./host-control.ts";
21
24
  import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
22
25
  import { JobStore, defaultLoopsDir } from "./store.ts";
@@ -96,7 +99,7 @@ export const CLI_USAGE = [
96
99
  "",
97
100
  "pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
98
101
  " Restore an archive into --cwd (default: the current directory).",
99
- " Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
102
+ " --activate-triggers: off never asks, on activates, ask (the default) prompts after the import.",
100
103
  "",
101
104
  "pi-loops sessions [--all] [--limit <n>]",
102
105
  " List sessions, newest first: short id, when it started, its automation, what was first said in it.",
@@ -110,6 +113,10 @@ export const CLI_USAGE = [
110
113
  "pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]",
111
114
  " The packaged recipes; what one installs; copy its playbooks into a project (.agents/skills/<name>/).",
112
115
  " The setup script and the jobs need a pi open in the project: /recipe add <name> finds the copies and finishes.",
116
+ "",
117
+ "pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]",
118
+ " The triage inbox with no pi open: what /inbox lists, and the two things it does to a finding.",
119
+ " --json prints the shape docs/downstream.md fixes for programs.",
113
120
  ].join("\n");
114
121
 
115
122
  /**
@@ -188,6 +195,12 @@ interface Parsed {
188
195
  flags: Map<string, string | true>;
189
196
  }
190
197
 
198
+ /**
199
+ * Flags that never take a value, so the word after them is a positional: `inbox claim --json <id>`
200
+ * must not read the id as the value of `--json`.
201
+ */
202
+ const BOOLEAN_FLAGS = new Set(["all", "json", "check", "overwrite", "exclude-triggers", "help"]);
203
+
191
204
  export function parseCliArgs(argv: string[]): Parsed {
192
205
  const [command = "", ...rest] = argv;
193
206
  const positional: string[] = [];
@@ -200,7 +213,7 @@ export function parseCliArgs(argv: string[]): Parsed {
200
213
  }
201
214
  const eq = arg.indexOf("=");
202
215
  if (eq > 0) flags.set(arg.slice(2, eq), arg.slice(eq + 1));
203
- else if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) flags.set(arg.slice(2), rest[++i]);
216
+ else if (!BOOLEAN_FLAGS.has(arg.slice(2)) && rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) flags.set(arg.slice(2), rest[++i]);
204
217
  else flags.set(arg.slice(2), true);
205
218
  }
206
219
  return { command, positional, flags };
@@ -250,6 +263,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
250
263
  if (command === "upgrade") return upgrade(flags.has("check"), out);
251
264
  if (command === "install-launcher") return installLauncher(str("dir"), out);
252
265
  if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
266
+ if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
253
267
 
254
268
  if (command === "sessions") {
255
269
  // `pickSession` refuses an unknown id, and there was no way to discover one.
@@ -287,9 +301,9 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
287
301
  const sessions = listSessions(path.join(agentDir, "sessions"));
288
302
  const picked = pickSession(sessions, { id: str("session"), cwd });
289
303
  const jobStore = new JobStore(loopsDir);
290
- // The archive carries this project's automation, the way the slash command does.
291
- const jobs = jobStore.load().filter((job) => job.cwd === picked.cwd);
292
- const rules = new TriggerStore(loopsDir).load().filter((rule) => rule.cwd === picked.cwd);
304
+ // The archive carries what the picked session created in its project — the one rule
305
+ // `/session-export` follows too, in `automationOf`.
306
+ const { jobs, rules } = automationOf(jobStore.load(), new TriggerStore(loopsDir).load(), picked.cwd, picked.id);
293
307
  const states: Record<string, string> = {};
294
308
  for (const job of jobs) {
295
309
  const loopState = job.stateful ? jobStore.readState(job.id) : undefined;
@@ -305,28 +319,31 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
305
319
  if (command === "import") {
306
320
  const file = positional[0];
307
321
  if (!file) throw new Error("import needs an archive path");
308
- const mode = str("activate-triggers") ?? "off";
309
- if (!["off", "ask", "on"].includes(mode)) throw new Error(`--activate-triggers must be off, ask or on (got ${mode})`);
322
+ const mode = parseActivateMode(str("activate-triggers"));
310
323
  const jobStore = new JobStore(loopsDir);
311
324
  const triggerStore = new TriggerStore(loopsDir);
312
325
  // pi encodes a project as `--home-u-proj--` and `SessionManager.list()` reads only that one
313
326
  // directory, with no fallback — a hand-rolled name here would restore a session pi never sees.
314
327
  const sessionDir = SessionManager.create(cwd).getSessionDir();
315
- const activate = mode === "on" || (mode === "ask" && (await askYesNo("Activate the imported automation now?")));
316
328
  const existingJobs = jobStore.load();
317
329
  const existingRules = triggerStore.load();
318
- const summary = importSession({ archivePath: path.resolve(file), sessionDir, targetCwd: cwd, activate, existingJobs, existingRules, existingJobIds: new Set(existingJobs.map((j) => j.id)), existingRuleIds: new Set(existingRules.map((r) => r.id)) });
319
- // The same order the slash command uses, so a half-import leaves neither store inconsistent.
320
- if (summary.jobs.length) await jobStore.mutate((jobs) => ({ jobs: [...jobs, ...summary.jobs], result: undefined }));
321
- for (const [id, text] of Object.entries(summary.states)) jobStore.writeState(id, text);
322
- if (summary.rules.length) await triggerStore.mutate((rules) => rules.push(...summary.rules));
330
+ const summary = importSession({ archivePath: path.resolve(file), sessionDir, targetCwd: cwd, activate: mode === "on", existingJobs, existingRules, existingJobIds: new Set(existingJobs.map((j) => j.id)), existingRuleIds: new Set(existingRules.map((r) => r.id)) });
331
+ // The writes and the rollback the slash command does, in `applyImport`.
332
+ await applyImport(summary, jobStore, triggerStore);
323
333
  const skipped = (summary.skippedJobs ?? 0) + (summary.skippedRules ?? 0);
324
334
  // The id and the notes come out of the archive's own header, like everything `inspect` prints.
325
335
  out(`imported ${plain(summary.originalSessionId)} → ${summary.sessionId}`);
326
336
  out(`entries=${plain(String(summary.entryCount))} cron=${summary.jobs.length} triggers=${summary.rules.length} automation=${summary.automationEnabled ? "enabled" : "disabled"}${skipped ? ` skipped=${skipped} (already imported)` : ""}`);
327
337
  for (const note of summary.notes ?? []) out(`note: ${plain(note)}`);
328
338
  out(`session: ${summary.sessionPath}`);
329
- if (!summary.automationEnabled && (summary.jobs.length || summary.rules.length)) out("automation is disabled; enable it with /cron enable <id> or re-import with --activate-triggers=on");
339
+ // `ask` is the default: the same question the slash command asks, once, after the import.
340
+ // `askYesNo` already answers no with no terminal to ask at.
341
+ const enabled = await offerToEnable(summary, jobStore, triggerStore, mode === "ask" ? (question, detail) => {
342
+ if (process.stdin.isTTY) out(detail); // what the dialog shows above the question
343
+ return askYesNo(question);
344
+ } : undefined);
345
+ if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`);
346
+ else if (!summary.automationEnabled && (summary.jobs.length || summary.rules.length)) out("automation is disabled; enable it with /cron enable <id> or re-import with --activate-triggers=on");
330
347
  return 0;
331
348
  }
332
349
 
@@ -393,7 +410,77 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
393
410
  return asked ? 2 : 0;
394
411
  }
395
412
 
396
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "web", "upgrade", "install-launcher", "help"]);
413
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
414
+
415
+ /**
416
+ * `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
417
+ * for a program. The JSON is the contract in docs/downstream.md: the fields, their names and their
418
+ * types are fixed there, and `test/downstream/inbox.sh` reads them back with jq. What is not fixed:
419
+ * the plain text, which is for a person at a prompt. A claim here marks the finding and stops,
420
+ * since there is no session to hand it to; the caller is the one who acts on it.
421
+ */
422
+ async function inboxCommand(positional: string[], opts: { all: boolean; json: boolean; reason?: string | true }, cwd: string, loopsDir: string, out: (line: string) => void): Promise<number> {
423
+ const fail = (why: string): number => {
424
+ out(opts.json ? JSON.stringify({ error: why }) : why);
425
+ return 1;
426
+ };
427
+ try {
428
+ return await inboxCommandBody(positional, opts, cwd, loopsDir, out, fail);
429
+ } catch (err: any) {
430
+ // A program reads one object either way: an unreadable file or a lock that timed out is an
431
+ // `error` on stdout, not a usage dump on stderr.
432
+ return fail(err?.message ?? String(err));
433
+ }
434
+ }
435
+
436
+ async function inboxCommandBody(positional: string[], opts: { all: boolean; json: boolean; reason?: string | true }, cwd: string, loopsDir: string, out: (line: string) => void, fail: (why: string) => number): Promise<number> {
437
+ const sub = positional[0] ?? "list";
438
+ const inbox = new Inbox(loopsDir);
439
+ if (sub === "list") {
440
+ const entries = opts.all ? inbox.listNew() : inProject(inbox.listNew(), cwd, sameProject);
441
+ if (opts.json) {
442
+ out(JSON.stringify({ findings: entries.map(findingJson) }));
443
+ return 0;
444
+ }
445
+ if (!entries.length) out(opts.all ? "inbox: nothing new on this machine" : `inbox: nothing new in ${cwd} (--all lists every project)`);
446
+ for (const e of entries) out(findingLine(e));
447
+ return 0;
448
+ }
449
+ if (sub === "claim" || sub === "dismiss") {
450
+ const ref = positional[1];
451
+ if (!ref) return fail(`inbox ${sub} needs a finding id`);
452
+ if (opts.reason === true) return fail("--reason needs a text (--reason=<text> when it starts with --)");
453
+ // An id or a prefix resolves machine-wide, as it does in /inbox, and only among what is new:
454
+ // a finding already claimed or dismissed is not decided twice. A number resolves to nothing
455
+ // here — `/inbox claim 3` is the number on a screen, and this command printed none — and the
456
+ // digits only pick the wording, since "no entry matching '1'" reads like a missing finding.
457
+ const entry = resolveInboxRef(inbox.listNew(), ref);
458
+ if (!entry) return fail(/^\d+$/.test(ref.trim()) ? `inbox ${sub} takes a finding id (inb-…) or a prefix of one, not a number` : `no new inbox entry matching '${ref}'`);
459
+ const updated = await inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? "cli" : undefined, opts.reason);
460
+ if (!updated) return fail(`inbox entry ${entry.id} is gone`);
461
+ out(opts.json ? JSON.stringify({ finding: findingJson(updated) }) : `${updated.status} ${updated.id}`);
462
+ return 0;
463
+ }
464
+ return fail(`unknown inbox command ${JSON.stringify(sub)} (list | claim <id> | dismiss <id> [--reason <text>])`);
465
+ }
466
+
467
+ /**
468
+ * One finding as docs/downstream.md fixes it: the contract's fields of the entry and nothing else,
469
+ * in their order, with what a person may read redacted. Fields are added at the end, never renamed.
470
+ */
471
+ function findingJson(e: InboxEntry): Record<string, unknown> {
472
+ const out: Record<string, unknown> = {};
473
+ for (const field of FINDING_FIELDS) out[field] = e[field];
474
+ out.text = redact(e.text);
475
+ out.dismiss_reason = e.dismiss_reason === null ? null : redact(e.dismiss_reason);
476
+ return out;
477
+ }
478
+
479
+ /** A person's line: the full id (it is what `claim` takes), the finding, where and when it came from. */
480
+ function findingLine(e: InboxEntry): string {
481
+ const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
482
+ return `${e.id} ${mark}${plain(redact(e.text))} (${path.basename(e.cwd) || e.cwd || "—"}, ${e.source}, ${e.created_at})`;
483
+ }
397
484
 
398
485
  /**
399
486
  * `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
package/src/goal.ts CHANGED
@@ -171,6 +171,17 @@ export function pauseFor(state: GoalState, reason: string, now = new Date()): {
171
171
  return { state: { ...state, status: "paused", lastReason: reason, updatedAt: stamp(now.getTime()) }, action: { kind: "pause", reason } };
172
172
  }
173
173
 
174
+ /**
175
+ * Esc on a turn is a person saying stop. The goal it was pursuing pauses — appended to the session,
176
+ * so a `--resume` finds it paused — until `/goal resume`. Without this the turn was merely not
177
+ * judged, and the next message the person sent brought the evaluator back to send them to work.
178
+ * Only a pursued goal changes; a paused, achieved or budget-limited one has nothing to stop.
179
+ */
180
+ export function abortedTurn(state: GoalState, now = new Date()): { state: GoalState; action: GoalAction } | undefined {
181
+ if (state.status !== "pursuing") return undefined;
182
+ return pauseFor(state, "you stopped the turn (Esc); /goal resume to continue", now);
183
+ }
184
+
174
185
  /** One line for `/goal` and the panel. */
175
186
  export function goalLine(state: GoalState): string {
176
187
  const iter = `${state.iterations}/${MAX_CONTINUATIONS}`;
package/src/hooks.ts CHANGED
@@ -12,7 +12,8 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { envFlag } from "./config.ts";
15
- import { previewRedacted } from "./redact.ts";
15
+ import { previewRedacted, redact } from "./redact.ts";
16
+ import type { LoopJob, RunRecord } from "./store.ts";
16
17
  import { parseToml } from "./toml.ts";
17
18
  import { PI_LOOPS_VERSION } from "./version.ts";
18
19
 
@@ -82,6 +83,29 @@ export interface HookPayload {
82
83
  /** Event-specific fields; the runner fills in the session-level ones. */
83
84
  export type HookEventData = Pick<HookPayload, "event" | "message_kind" | "message_summary" | "assistant_event" | "tool_call_id" | "tool_name" | "tool_is_error" | "tool_args" | "tool_result_summary" | "compaction_trigger" | "compaction_tokens_before" | "compaction_summary" | "compaction_failed" | "run_job" | "run_id" | "run_ok" | "run_findings" | "run_error" | "run_cost_usd">;
84
85
 
86
+ /**
87
+ * The same two events the interactive extension and the headless host fire. Whether a run happens
88
+ * here or there is an accident of who held the clock, and a hook rule should not be able to tell —
89
+ * so both processes fire the object these two return.
90
+ */
91
+ export function runStartEvent(job: LoopJob, runId: string): HookEventData {
92
+ return { event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) };
93
+ }
94
+
95
+ export function runEndEvent(job: LoopJob, record: RunRecord): HookEventData {
96
+ const label = job.name ?? job.id;
97
+ return {
98
+ event: "run_end",
99
+ run_job: label,
100
+ run_id: record.runId,
101
+ run_ok: record.ok,
102
+ run_findings: record.findings,
103
+ run_error: record.error ? redact(record.error) : null,
104
+ run_cost_usd: record.usage?.cost ?? null,
105
+ message_summary: truncateSummary(record.ok ? `${label}: ok · ${record.findings} finding(s)` : `${label}: failed: ${record.error ?? "unknown error"}`),
106
+ };
107
+ }
108
+
85
109
  export interface ParsedHooksFile {
86
110
  allowProjectHooks: boolean;
87
111
  hooks: HookConfig[];
@@ -7,7 +7,7 @@
7
7
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
8
8
  import * as path from "node:path";
9
9
  import type { LoopsConfig } from "./config.ts";
10
- import { HookRunner, truncateSummary } from "./hooks.ts";
10
+ import { HookRunner, runEndEvent, runStartEvent } from "./hooks.ts";
11
11
  import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
12
12
  import { redact } from "./redact.ts";
13
13
  import type { SubagentRequest, SubagentRunner } from "./runner.ts";
@@ -119,7 +119,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
119
119
  // scheduler calls this synchronously). `.catch` because a rejection nobody owns takes
120
120
  // the host down — hooks warn per rule, so there is nothing else to do with one.
121
121
  const hooks = hookRunnerFor(job, runId);
122
- if (hooks) void hooks.fire({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) }).catch((err: any) => log(`hooks: ${redact(err?.message ?? String(err))}`));
122
+ if (hooks) void hooks.fire(runStartEvent(job, runId)).catch((err: any) => log(`hooks: ${redact(err?.message ?? String(err))}`));
123
123
  },
124
124
  onCatchUp: (job) => log(`loop ${job.name ?? job.id}: catching up a missed tick`),
125
125
  onRunFinished: ({ job, record, result }) => {
@@ -129,14 +129,10 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
129
129
  if (!hooks) return;
130
130
  // `$PI_RUN_OK` alone answers "did last night's loop fail" — the thing the host exists to
131
131
  // be able to tell someone.
132
- const label = job.name ?? job.id;
133
- const summary = record.ok ? `${label}: ok · ${record.findings} finding(s)` : `${label}: failed: ${record.error ?? "unknown error"}`;
134
132
  // Its own hooks are the last thing a run does; the runner is dropped once they have run
135
133
  // (until then `stop()` still has to drain it).
136
134
  const forget = () => void runHooks.delete(record.runId);
137
- void hooks
138
- .fire({ event: "run_end", run_job: label, run_id: record.runId, run_ok: record.ok, run_findings: record.findings, run_error: record.error ? redact(record.error) : null, run_cost_usd: record.usage?.cost ?? null, message_summary: truncateSummary(summary) })
139
- .then(forget, forget);
135
+ void hooks.fire(runEndEvent(job, record)).then(forget, forget);
140
136
  },
141
137
  onTick: async (now, leader) => {
142
138
  if (!leader) {
@@ -153,7 +149,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
153
149
  });
154
150
 
155
151
  const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }) => {
156
- await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
152
+ await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
157
153
  return "inbox" as const;
158
154
  };
159
155
  triggers = new TriggerRuntime({