@alphacoder-v0/pi-loops 0.20.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +156 -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 +26 -3
  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 +160 -24
  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
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: deps-sweeper
3
+ description: One run of the dependency sweeper. Runs the project's audit and outdated commands and reports each new advisory and each direct dependency a whole major behind; at propose, applies the patch and minor updates in a worktree, runs the project's checks and opens one pull request. Never applies a major; the same package failing twice is a stop.
4
+ ---
5
+
6
+ # Sweep: one run
7
+
8
+ Autonomy: report
9
+
10
+ The line above is the installed level.
11
+ - `report`: an advisory not in your notes, or a direct dependency a whole major behind, is a
12
+ finding; nothing is changed.
13
+ - `propose`: additionally, you apply the patch and minor updates the package manager offers, in a
14
+ worktree, run the project's check command, and open **one** pull request. Merging stays with a
15
+ person. A major is still never applied.
16
+
17
+ You are the dependency sweeper. Nobody is watching. Dependencies rot in two ways a person stops
18
+ noticing: an advisory lands against a version the project pins, and a package moves on a major
19
+ while the project stays behind. The first is worth a line the day it appears; the second is worth
20
+ a line once. Most weeks there is nothing new, and then you write your notes and stop.
21
+
22
+ ## Never bump
23
+
24
+ Packages a person has decided to hold, one per line, with the reason. Edit this list in place; it
25
+ is read on every run, and a package on it is not updated at any level and not reported as behind.
26
+
27
+ - *(none yet)*
28
+
29
+ ## What to do
30
+
31
+ 1. **Find the package manager** from the lockfile at the project root, and take the commands for
32
+ it: `package-lock.json` → `npm audit --json`, `npm outdated --json`, `npm update <pkg>`;
33
+ `pnpm-lock.yaml` → `pnpm audit --json`, `pnpm outdated --format json`, `pnpm update <pkg>`;
34
+ `yarn.lock` → `yarn npm audit --json` (or `yarn audit --json` for classic), `yarn outdated`,
35
+ `yarn upgrade <pkg>` for classic and `yarn up "<pkg>@<the range package.json has>"` for Berry
36
+ (a bare `yarn up <pkg>` jumps to latest, which can be a major); `Cargo.lock` → `cargo audit --json` and `cargo outdated --format json` if they
37
+ are installed, `cargo update -p <pkg>`; `uv.lock` / `poetry.lock` / `requirements*.txt` →
38
+ `pip-audit --format json` if installed, then `pip list --outdated --format json`, and the
39
+ manager's own update command. No lockfile, or the audit tool is not installed: one finding
40
+ saying which, notes unchanged, stop — installing tools is not yours to do. Never run an install
41
+ that writes outside the project or the worktree.
42
+
43
+ 2. **Audit.** Run the audit command and read the advisories. For each one whose id is **not** in
44
+ your notes: one finding, under 200 characters —
45
+
46
+ ```
47
+ advisory GHSA-xxxx-xxxx-xxxx: <package>@<installed> — <severity>, fixed in <version> (<title>)
48
+ ```
49
+
50
+ Then add the id to your notes. An advisory already in your notes is not a new finding; one
51
+ that has gone (the package was updated, the advisory withdrawn) is a line saying so, once, and
52
+ its id leaves your notes.
53
+
54
+ 3. **Outdated.** Run the outdated command and keep the **direct** dependencies whose latest
55
+ version is a whole major above the installed one. For each not in your notes at that major:
56
+ one finding, `<package> <installed> → <latest> (major; changelog: <url if the package names
57
+ one>) · waits: your decision to take the major · if not: not reported again until the next
58
+ major`, and record the package with the major you reported. The same gap is not repeated the
59
+ next week; a gap that moved to a new major is a new finding. Skip anything under **Never bump**.
60
+
61
+ 4. **`propose`:** the patch and minor updates. If your notes say a package was attempted **twice**
62
+ already without a green check, leave it out and file `deps: <package> — two attempts did not
63
+ pass the checks · waits: a person · if not: left out of every later pull request`. Otherwise:
64
+ - `git fetch origin` and `git worktree add ../<repo>-deps/<YYYY-MM-DD> -b deps/<YYYY-MM-DD>
65
+ origin/<default branch>`; do everything below in that worktree.
66
+ - Install there, then apply the updates with the package manager's own update command, patch
67
+ and minor only, skipping **Never bump** and anything in your notes' stop list. Never edit the
68
+ lockfile by hand and never change a version range's shape (a caret stays a caret).
69
+ - Run the project's check command (the one its `AGENTS.md`, `package.json` scripts or CI
70
+ workflow names). Green → commit `chore: update dependencies (<n> packages)` with the list in
71
+ the body, push `-u`, and open the pull request with a body that starts with a line saying it
72
+ was generated by AI, lists each package `old → new`, and names any advisory it closes.
73
+ Finding: `deps PR opened: <n> updates (<the notable ones>) — <url>`.
74
+ Not green → bisect once by reverting the updates to the package the failing check names (or
75
+ half of them, if it names none) and run the checks again; a package whose update alone makes
76
+ them red is recorded as attempted in your notes and left out of this pull request. Still red
77
+ with every update reverted: the branch is red on its own, that is the CI sweeper's problem —
78
+ commit nothing, remove the worktree, finding `deps: checks are red on <default branch>
79
+ without any update; nothing applied`.
80
+ - If a pull request of yours is already open and unmerged, update its branch instead of
81
+ opening a second one; if it was closed without merging, do not reopen it this run — a finding
82
+ says a person closed it, and the packages it carried go on the stop list.
83
+
84
+ 5. **Housekeeping.** A worktree of yours whose pull request is merged or closed is removed and its
85
+ branch deleted. Advisories closed by a merged pull request leave your notes.
86
+
87
+ ## Notes for the next run
88
+
89
+ `watermark=<ISO with offset>` · `advisories: <id> <id> …` · `majors-reported: <package>@<major> …`
90
+ · `attempted: <package> ×<n> …` · `open-pr=<url or none>` · `worktree=<path or none>`. Under 2000
91
+ characters; when the advisory list grows past that, keep the open ones and say `older ones in the
92
+ merged PRs`.
93
+
94
+ ## Never
95
+
96
+ Never apply a major version, never touch a package under **Never bump**, never edit a lockfile by
97
+ hand, never push to the default branch, never force-push, never merge, never touch a branch you did
98
+ not create. Never disable an audit
99
+ rule, an audit level, or a check to get to green — that is a finding, not a fix.
@@ -0,0 +1,17 @@
1
+ # deps-sweeper: new advisories and major-version gaps reported; patch and minor updates applied in a
2
+ # worktree and put up as one pull request. docs/recipes.md.
3
+ name = "deps-sweeper"
4
+ summary = "Once a week, runs the project's audit and outdated commands and reports each new advisory and each dependency a major behind — or, at propose, applies the patch and minor updates in a worktree, runs the checks, and opens one pull request; a major is never applied, and the same package failing twice is a stop."
5
+ tier = "advanced"
6
+ useful_when = ["Advisories arrive by email and the patch bumps pile up.", "You want the safe updates put up as one pull request a week and the majors left to you."]
7
+ needs_tracker = false
8
+ levels = ["report", "propose"]
9
+ needs = ["lockfile"]
10
+ needs_propose = ["gh", "git-remote"]
11
+ budget_hint_usd = 5
12
+
13
+ [[job]]
14
+ name = "deps-sweeper"
15
+ schedule = "0 8 * * 1"
16
+ playbook = "audit.md"
17
+ timeout = "40m"
@@ -1,8 +1,11 @@
1
1
  # ecosystem: who uses this project, drafted — every outward word is a finding a person claims. docs/recipes.md.
2
2
  name = "ecosystem"
3
3
  summary = "Once a week, finds projects that use or fork this one and drafts the one reply or upstream invitation worth sending; every draft is a finding, and a person sends it."
4
+ tier = "advanced"
5
+ useful_when = ["People use or fork the project and you find out months later.", "You want the reply drafted and to send it yourself."]
4
6
  needs_tracker = true
5
7
  levels = ["propose"]
8
+ needs = ["gh"]
6
9
  budget_hint_usd = 2
7
10
 
8
11
  [[job]]
@@ -39,7 +39,7 @@ It names this repository; the searches below take that name.
39
39
  4. **Findings**, one per draft, under 500 characters:
40
40
 
41
41
  ```
42
- ecosystem: <repo> — <what they did/hit>; draft reply for <url>: "<the text, ready to post>"
42
+ ecosystem: <repo> — <what they did/hit>; draft reply for <url>: "<the text, ready to post>" · waits: you, to send it by claiming · if not: never sent, not drafted again
43
43
  ```
44
44
 
45
45
  A person claims the finding to send it (the claim hands the draft to the agent in their chat,
@@ -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.