@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.
- package/CHANGELOG.md +156 -0
- package/CONTRACT.md +139 -0
- package/README.md +11 -7
- package/README.zh-CN.md +6 -4
- package/docs/cli.md +26 -3
- package/docs/downstream.md +60 -0
- package/docs/goal.md +4 -2
- package/docs/loops.md +55 -9
- package/docs/recipes.md +94 -18
- package/docs/session-archive.md +8 -3
- package/docs/web-ui-parity.md +11 -1
- package/package.json +5 -2
- package/recipes/_tracker/issue-tracker-local.md +3 -1
- package/recipes/autoresearch/experiment.md +30 -6
- package/recipes/autoresearch/recipe.toml +2 -0
- package/recipes/changelog-draft/recipe.toml +4 -0
- package/recipes/changelog-draft/release.md +8 -3
- package/recipes/ci-sweeper/recipe.toml +4 -0
- package/recipes/ci-sweeper/sweep.md +2 -1
- package/recipes/daily-digest/digest.md +5 -4
- package/recipes/daily-digest/recipe.toml +2 -0
- package/recipes/deps-sweeper/README.md +11 -0
- package/recipes/deps-sweeper/audit.md +99 -0
- package/recipes/deps-sweeper/recipe.toml +17 -0
- package/recipes/ecosystem/recipe.toml +3 -0
- package/recipes/ecosystem/scan.md +1 -1
- package/recipes/issue-loop/implement.md +17 -4
- package/recipes/issue-loop/labels.sh +18 -0
- package/recipes/issue-loop/recipe.toml +2 -0
- package/recipes/issue-loop/triage.md +16 -2
- package/recipes/pr-watch/recipe.toml +3 -0
- package/skills/pi-loops/SKILL.md +3 -2
- package/src/archive.ts +97 -2
- package/src/cli.ts +160 -24
- package/src/goal.ts +11 -0
- package/src/hooks.ts +25 -1
- package/src/host-runtime.ts +4 -8
- package/src/inbox.ts +118 -55
- package/src/job-signal.ts +78 -0
- package/src/pi-loops.ts +177 -109
- package/src/presence.ts +5 -0
- package/src/protocol.ts +45 -2
- package/src/recipe.ts +158 -2
- package/src/scheduler.ts +6 -2
- package/src/store.ts +8 -3
- package/src/tools.ts +17 -11
- package/src/triggers.ts +8 -3
- 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)
|
|
99
|
-
|
|
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
|
|
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]]
|
package/skills/pi-loops/SKILL.md
CHANGED
|
@@ -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),
|
|
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.
|