perturb 0.0.1__tar.gz
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.
- perturb-0.0.1/.gitignore +8 -0
- perturb-0.0.1/CHANGELOG.md +39 -0
- perturb-0.0.1/LICENSE +21 -0
- perturb-0.0.1/PKG-INFO +220 -0
- perturb-0.0.1/README.md +192 -0
- perturb-0.0.1/SECURITY.md +32 -0
- perturb-0.0.1/pyproject.toml +63 -0
- perturb-0.0.1/src/perturb/__init__.py +3 -0
- perturb-0.0.1/src/perturb/ack.py +47 -0
- perturb-0.0.1/src/perturb/adr.py +294 -0
- perturb-0.0.1/src/perturb/areas.py +72 -0
- perturb-0.0.1/src/perturb/check.py +244 -0
- perturb-0.0.1/src/perturb/cli.py +1284 -0
- perturb-0.0.1/src/perturb/config.py +111 -0
- perturb-0.0.1/src/perturb/envelope.py +66 -0
- perturb-0.0.1/src/perturb/events.py +217 -0
- perturb-0.0.1/src/perturb/github.py +115 -0
- perturb-0.0.1/src/perturb/graph.py +170 -0
- perturb-0.0.1/src/perturb/graph_export.py +71 -0
- perturb-0.0.1/src/perturb/ids.py +15 -0
- perturb-0.0.1/src/perturb/inbox.py +202 -0
- perturb-0.0.1/src/perturb/init.py +98 -0
- perturb-0.0.1/src/perturb/propose.py +913 -0
- perturb-0.0.1/src/perturb/push.py +56 -0
- perturb-0.0.1/src/perturb/queue.py +65 -0
- perturb-0.0.1/src/perturb/refs.py +45 -0
- perturb-0.0.1/src/perturb/show.py +81 -0
- perturb-0.0.1/src/perturb/stale.py +44 -0
- perturb-0.0.1/src/perturb/sync.py +166 -0
- perturb-0.0.1/src/perturb/transitions.py +47 -0
- perturb-0.0.1/tests/test_ack.py +230 -0
- perturb-0.0.1/tests/test_adr.py +362 -0
- perturb-0.0.1/tests/test_areas.py +210 -0
- perturb-0.0.1/tests/test_check.py +313 -0
- perturb-0.0.1/tests/test_cli.py +2669 -0
- perturb-0.0.1/tests/test_cli_config.py +103 -0
- perturb-0.0.1/tests/test_config.py +81 -0
- perturb-0.0.1/tests/test_envelope.py +124 -0
- perturb-0.0.1/tests/test_events.py +270 -0
- perturb-0.0.1/tests/test_github.py +185 -0
- perturb-0.0.1/tests/test_graph.py +220 -0
- perturb-0.0.1/tests/test_graph_export.py +147 -0
- perturb-0.0.1/tests/test_ids.py +22 -0
- perturb-0.0.1/tests/test_inbox.py +319 -0
- perturb-0.0.1/tests/test_init.py +157 -0
- perturb-0.0.1/tests/test_propose.py +1576 -0
- perturb-0.0.1/tests/test_push.py +106 -0
- perturb-0.0.1/tests/test_queue.py +154 -0
- perturb-0.0.1/tests/test_refs.py +42 -0
- perturb-0.0.1/tests/test_show.py +167 -0
- perturb-0.0.1/tests/test_stale.py +174 -0
- perturb-0.0.1/tests/test_sync.py +564 -0
- perturb-0.0.1/tests/test_transitions.py +247 -0
perturb-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.0.1] - 2026-09-15
|
|
10
|
+
|
|
11
|
+
First public release. perturb was developed privately under the name `planner`; nothing was
|
|
12
|
+
published under that name. Repositories that used a private build need these changes:
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Renamed.** The command is `perturb`, the committed ledger is `perturb/` (was `planning/`), and
|
|
17
|
+
the local cache is `.perturb/` (was `.planner/`).
|
|
18
|
+
- **The label marking a planned issue is `ready-to-implement`** (was `hardened`), configurable as
|
|
19
|
+
`labels.ready_to_implement` in `perturb/config.yaml`.
|
|
20
|
+
- **`perturb show plan:<slug>` reports `files`**, the plan's declared files, instead of `cycles`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`perturb/config.yaml`** sets where plans, friction logs and audits live, and which labels mark
|
|
25
|
+
epics and ready issues.
|
|
26
|
+
- **`PERTURB_GH`** names the GitHub CLI to call; the default is `gh`.
|
|
27
|
+
- **Friction logs can list their commits in front-matter** (`commits:`).
|
|
28
|
+
- **`perturb init` adds `.perturb/` to `.gitignore`**, creating the file if needed.
|
|
29
|
+
- **An ADR can supersede a single consequence** of an earlier one:
|
|
30
|
+
`supersedes: ["adr:0003#postgres-over-sqlite"]` raises `supersede` events only to the issues that
|
|
31
|
+
acknowledged that consequence, and `perturb check` reports entries that don't resolve.
|
|
32
|
+
- **Every verb has `--help`** with a description of the verb and each argument.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`perturb adr migrate` no longer drops text silently.** A `**Supersedes:**` line naming whole
|
|
37
|
+
ADRs is carried into `supersedes`; one it can't interpret stays in the body with a warning, as
|
|
38
|
+
does any annotation on the status line. Prose between the status line and the first heading is
|
|
39
|
+
kept.
|
perturb-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geuben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
perturb-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: perturb
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Planning ledger for agent-driven development: issue graph, decision inbox, staleness gate
|
|
5
|
+
Project-URL: Homepage, https://github.com/geuben/perturb
|
|
6
|
+
Project-URL: Repository, https://github.com/geuben/perturb
|
|
7
|
+
Project-URL: Issues, https://github.com/geuben/perturb/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/geuben/perturb/blob/main/CHANGELOG.md
|
|
9
|
+
Author: geuben
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: adr,agents,ai,github,issues,planning,workflow
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: pathspec>=1.1.1
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# perturb
|
|
30
|
+
|
|
31
|
+
**A planning ledger for GitHub issues.** When a decision made on one piece of work changes
|
|
32
|
+
another, perturb makes sure the other one hears about it before anyone plans or builds it.
|
|
33
|
+
|
|
34
|
+
It was designed for agentic development, where every planning or implementing session starts
|
|
35
|
+
cold, and it works just as well for people at a terminal.
|
|
36
|
+
|
|
37
|
+
## The problem
|
|
38
|
+
|
|
39
|
+
Work is split into issues, and the decisions that shape them get made all over the place: while
|
|
40
|
+
planning a neighbouring issue, in an architecture decision record (ADR), in the review of a
|
|
41
|
+
finished piece of work. Those decisions are written down where they are *made*, not where they
|
|
42
|
+
are *needed*.
|
|
43
|
+
|
|
44
|
+
Take a bike-share app with an epic to backfill historical data:
|
|
45
|
+
|
|
46
|
+
- Planning **#31** (backfill fares), you decide fare periods are half-open. **#32** (backfill
|
|
47
|
+
refunds) depends on fare periods, but its issue says nothing about it, so whoever plans #32 next
|
|
48
|
+
month doesn't know.
|
|
49
|
+
- An ADR decides that every refund records its grain, the period it covers (a day, a month or a
|
|
50
|
+
year), and names #13 in its consequences. Nothing puts that in front of the person planning #13.
|
|
51
|
+
- **#29** was planned last week and is ready to implement. Yesterday a decision changed its scope.
|
|
52
|
+
The implementer, often an agent with no planning context, builds the old plan.
|
|
53
|
+
|
|
54
|
+
People cope by remembering. Agents can't: each session knows the issue text and whatever it is
|
|
55
|
+
told to read, and nothing more.
|
|
56
|
+
|
|
57
|
+
## How perturb solves it
|
|
58
|
+
|
|
59
|
+
perturb keeps a ledger of **events** in your repository. Each one says *"this changed something that
|
|
60
|
+
may affect that issue."*
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
flowchart LR
|
|
64
|
+
decision["Decision while planning #31"] -- "perturb push" --> event
|
|
65
|
+
adr["ADR consequence"] -- "perturb propose" --> event
|
|
66
|
+
friction["Friction log or audit"] -- "perturb propose" --> event
|
|
67
|
+
closed["Blocker closed"] -- "perturb sync" --> event
|
|
68
|
+
event(["Event pending on #32"]) --> inbox["perturb inbox 32"]
|
|
69
|
+
inbox --> ack["Plan for #32 absorbs it: perturb ack"]
|
|
70
|
+
ack --> gate{"perturb stale 32"}
|
|
71
|
+
gate -- "nothing newer" --> build["Implement #32"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
1. **Events connect a source to the issue it affects.** Record one by hand with `perturb push`, or
|
|
75
|
+
let perturb propose them from ADRs, from plans, and from the friction logs and audits that
|
|
76
|
+
implementation leaves behind. Proposals wait for a person or a planning agent to confirm them,
|
|
77
|
+
so they never turn into noise. Closing a blocker records an `unblock` event automatically.
|
|
78
|
+
2. **Every issue has an inbox.** Whoever plans the issue reads it (`perturb inbox 32`), folds each
|
|
79
|
+
event into the plan, and acknowledges it (`perturb ack`) with a note on what they did. The
|
|
80
|
+
acknowledgement pins the exact version of the plan that absorbed the event.
|
|
81
|
+
3. **A stale gate stands before implementation.** `perturb stale 32` fails if an event arrived after
|
|
82
|
+
the plan was last committed, or if the plan changed after it was acknowledged. Run it before
|
|
83
|
+
building, by hand or as the first step of an agent's run.
|
|
84
|
+
|
|
85
|
+
Around the ledger, perturb reads sub-issues and blocked-by links from GitHub to answer *what should
|
|
86
|
+
be planned next* (`perturb next`), and `perturb check` lints the whole thing in CI.
|
|
87
|
+
|
|
88
|
+
GitHub stays the source of truth for issues. Events are small YAML files committed next to your
|
|
89
|
+
code and reviewed in the same pull request as the change that caused them.
|
|
90
|
+
|
|
91
|
+
### Areas
|
|
92
|
+
|
|
93
|
+
Many findings are about code rather than an issue: a friction log shows a run touched
|
|
94
|
+
`src/core/fares/`, or an ADR changes anything that prices a ride. An **area** is a named part of your
|
|
95
|
+
codebase, declared by hand as path globs in `perturb/areas.yaml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
areas:
|
|
99
|
+
fares:
|
|
100
|
+
paths: ["src/core/fares/**", "migrations/*fare*"]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
An issue belongs to an area when it carries the `area:fares` label or its plan lists
|
|
104
|
+
`areas: [fares]`. perturb then proposes findings about those paths, and ADR consequences that
|
|
105
|
+
`affects: [area:fares]`, to the open issues in that area. A handful of areas is usually enough.
|
|
106
|
+
|
|
107
|
+
## Built for agents, fine for people
|
|
108
|
+
|
|
109
|
+
- **Machine-readable.** Every verb takes `--json` and prints a stable envelope, with a `reason` when
|
|
110
|
+
it refuses; gates report through exit codes.
|
|
111
|
+
- **Fails closed.** Verbs that need GitHub sync first and refuse rather than answer from a stale
|
|
112
|
+
cache.
|
|
113
|
+
- **Review built in.** A proposed event does nothing until an agent's planning step or a person
|
|
114
|
+
confirms it.
|
|
115
|
+
- **Guides, not prescriptions.** perturb doesn't ship planning, implementing or reviewing agents.
|
|
116
|
+
A guide for each phase shows where perturb fits and why, with example instructions to adapt into
|
|
117
|
+
whatever prompts or skills you already use. A small [agent skill](docs/agents.md#the-perturb-skill),
|
|
118
|
+
installable as a Claude Code plugin, answers "what's next?" or "is 29 still valid?".
|
|
119
|
+
- **By hand.** The same verbs work at a terminal, and `perturb propose adr:0002 --review` walks you
|
|
120
|
+
through proposals interactively.
|
|
121
|
+
|
|
122
|
+
## Downsides
|
|
123
|
+
|
|
124
|
+
- **GitHub only, and online.** Issues, sub-issues and blocked-by links must live on GitHub, and the
|
|
125
|
+
graph is only as good as your use of them. Verbs that read the issue graph sync first and refuse
|
|
126
|
+
when GitHub is unreachable; the ledger verbs (`inbox`, `ack`, `confirm`, `dismiss`) and `init`
|
|
127
|
+
work offline.
|
|
128
|
+
- **It runs on discipline.** The ledger knows only what gets pushed, confirmed and acknowledged. If
|
|
129
|
+
decisions aren't recorded or inboxes aren't read, it quietly says nothing. Someone, or some
|
|
130
|
+
agent's planning step, has to own that.
|
|
131
|
+
- **More files in your pull requests.** Every event is a committed YAML file, so reviews carry
|
|
132
|
+
ledger changes alongside code.
|
|
133
|
+
- **Branches blur the stale gate.** `stale` compares when an event was created with when the plan
|
|
134
|
+
was last committed. An event recorded on a branch that merges after someone else has planned
|
|
135
|
+
the issue slips past the gate: it still waits in the inbox, but nothing fails. Each person also
|
|
136
|
+
sees only their own checkout, so events on unmerged branches are invisible to `inbox`, `stale`
|
|
137
|
+
and CI. Short-lived branches and checking an up-to-date `main` before implementing help.
|
|
138
|
+
- **Parallel work duplicates and scatters events.** Duplicates are only detected within your own
|
|
139
|
+
checkout, so two people proposing from the same ADR, or both syncing after a blocker closes,
|
|
140
|
+
record the same event twice. Because a sync can write `unblock` events, they can land in
|
|
141
|
+
unrelated pull requests. And proposals need a clear owner, or they pile up unreviewed.
|
|
142
|
+
- **Proposals are heuristics.** Routing uses `#N` mentions and path globs in a hand-maintained
|
|
143
|
+
`areas.yaml`. Loose areas mean noisy proposals to dismiss; missing areas mean findings that reach
|
|
144
|
+
nobody. How well the rules hold up on real projects is not yet measured.
|
|
145
|
+
- **The stale gate is coarse.** Any pending event newer than the plan's last commit marks it
|
|
146
|
+
stale, however small the change, and any edit to an acknowledged plan, even a typo fix, means
|
|
147
|
+
acknowledging its events again.
|
|
148
|
+
- **It assumes a way of planning.** Plans must be Markdown files with `closes:` front-matter, and
|
|
149
|
+
issues need the ready-to-implement label. Plans kept in issue bodies, documents or tickets
|
|
150
|
+
elsewhere don't count.
|
|
151
|
+
- **Some sources need specific formats.** ADR proposals need the [structured ADR
|
|
152
|
+
format](docs/adr-format.md) (`perturb adr migrate` converts prose ADRs). Friction and audit
|
|
153
|
+
proposals need files in the [run evidence format](docs/run-evidence-format.md). Your workflow
|
|
154
|
+
has to write them, though a friction log only needs a list of commits.
|
|
155
|
+
- **One repository at a time.** There is no ledger across repositories.
|
|
156
|
+
- **Early.** Formats and JSON output may change before 1.0, and it isn't published to PyPI.
|
|
157
|
+
|
|
158
|
+
## Install
|
|
159
|
+
|
|
160
|
+
You need Python 3.11+, `git`, a repository hosted on GitHub, and the [`gh`](https://cli.github.com/)
|
|
161
|
+
CLI logged in (or `GH_TOKEN` set). If you use a wrapper around `gh`, point `PERTURB_GH` at it.
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
uv tool install git+https://github.com/geuben/perturb
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Quick start
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
cd your-repo
|
|
171
|
+
perturb init # creates perturb/: areas.yaml, config.yaml, README.md, events/
|
|
172
|
+
perturb next # ready, unplanned issues, the ones that unblock the most first
|
|
173
|
+
perturb inbox 32 # what has changed for #32
|
|
174
|
+
|
|
175
|
+
# Planning #31, you lock a decision that affects #32:
|
|
176
|
+
perturb push --from 31 --to 32 --kind decision "Fare periods are half-open"
|
|
177
|
+
|
|
178
|
+
# Planning #32: fold the event into the plan, commit the plan, then acknowledge it.
|
|
179
|
+
perturb ack 32 --all --plan tasks/backfill-refunds.md --note "Design decision 1"
|
|
180
|
+
|
|
181
|
+
perturb stale 32 # exit 0: the plan has seen everything
|
|
182
|
+
perturb check # the same checks, for CI
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Commit the `perturb/` directory along with your work; `perturb init` has already added its local
|
|
186
|
+
cache, `.perturb/`, to `.gitignore`. To run the checks on every pull request, see
|
|
187
|
+
[Getting started](docs/getting-started.md#run-the-checks-in-ci).
|
|
188
|
+
|
|
189
|
+
A plan is a Markdown file with `closes: <issue number>` in its front-matter, at `tasks/<slug>.md` by
|
|
190
|
+
default. An issue counts as planned once its plan exists and the issue carries the
|
|
191
|
+
`ready-to-implement` label. Both are configurable in `perturb/config.yaml`, along with where
|
|
192
|
+
friction logs and audits live; declare your codebase's [areas](#areas) in `perturb/areas.yaml` so
|
|
193
|
+
friction reaches the issues that touch the same code.
|
|
194
|
+
|
|
195
|
+
## Status
|
|
196
|
+
|
|
197
|
+
Early. Version `0.0.1` does everything described here and is used to build perturb itself, but the
|
|
198
|
+
JSON envelope and file formats may still change.
|
|
199
|
+
|
|
200
|
+
## Documentation
|
|
201
|
+
|
|
202
|
+
- [Getting started](docs/getting-started.md): set up a repository and walk through the whole loop
|
|
203
|
+
- [Concepts](docs/concepts.md): events, inboxes, the stale gate, areas and the issue graph
|
|
204
|
+
- [Using perturb with agents](docs/agents.md), with a guide for each phase:
|
|
205
|
+
[planning](docs/planning.md), [implementing](docs/implementing.md) and
|
|
206
|
+
[reviewing](docs/reviewing.md)
|
|
207
|
+
- [Using perturb with tdd-cli](docs/tdd-cli.md): what a test-driven executor gives perturb for free
|
|
208
|
+
- [Working in a team](docs/teams.md): branches, duplicates and the habits that avoid them
|
|
209
|
+
- [CLI reference](docs/cli.md) and [configuration](docs/configuration.md)
|
|
210
|
+
- Formats: [ADRs](docs/adr-format.md) and [plans, friction logs and audits](docs/run-evidence-format.md)
|
|
211
|
+
- [Design notes](docs/design/README.md): why perturb is built the way it is
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and how this repository uses perturb to
|
|
216
|
+
build perturb.
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
perturb-0.0.1/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# perturb
|
|
2
|
+
|
|
3
|
+
**A planning ledger for GitHub issues.** When a decision made on one piece of work changes
|
|
4
|
+
another, perturb makes sure the other one hears about it before anyone plans or builds it.
|
|
5
|
+
|
|
6
|
+
It was designed for agentic development, where every planning or implementing session starts
|
|
7
|
+
cold, and it works just as well for people at a terminal.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
Work is split into issues, and the decisions that shape them get made all over the place: while
|
|
12
|
+
planning a neighbouring issue, in an architecture decision record (ADR), in the review of a
|
|
13
|
+
finished piece of work. Those decisions are written down where they are *made*, not where they
|
|
14
|
+
are *needed*.
|
|
15
|
+
|
|
16
|
+
Take a bike-share app with an epic to backfill historical data:
|
|
17
|
+
|
|
18
|
+
- Planning **#31** (backfill fares), you decide fare periods are half-open. **#32** (backfill
|
|
19
|
+
refunds) depends on fare periods, but its issue says nothing about it, so whoever plans #32 next
|
|
20
|
+
month doesn't know.
|
|
21
|
+
- An ADR decides that every refund records its grain, the period it covers (a day, a month or a
|
|
22
|
+
year), and names #13 in its consequences. Nothing puts that in front of the person planning #13.
|
|
23
|
+
- **#29** was planned last week and is ready to implement. Yesterday a decision changed its scope.
|
|
24
|
+
The implementer, often an agent with no planning context, builds the old plan.
|
|
25
|
+
|
|
26
|
+
People cope by remembering. Agents can't: each session knows the issue text and whatever it is
|
|
27
|
+
told to read, and nothing more.
|
|
28
|
+
|
|
29
|
+
## How perturb solves it
|
|
30
|
+
|
|
31
|
+
perturb keeps a ledger of **events** in your repository. Each one says *"this changed something that
|
|
32
|
+
may affect that issue."*
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
flowchart LR
|
|
36
|
+
decision["Decision while planning #31"] -- "perturb push" --> event
|
|
37
|
+
adr["ADR consequence"] -- "perturb propose" --> event
|
|
38
|
+
friction["Friction log or audit"] -- "perturb propose" --> event
|
|
39
|
+
closed["Blocker closed"] -- "perturb sync" --> event
|
|
40
|
+
event(["Event pending on #32"]) --> inbox["perturb inbox 32"]
|
|
41
|
+
inbox --> ack["Plan for #32 absorbs it: perturb ack"]
|
|
42
|
+
ack --> gate{"perturb stale 32"}
|
|
43
|
+
gate -- "nothing newer" --> build["Implement #32"]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
1. **Events connect a source to the issue it affects.** Record one by hand with `perturb push`, or
|
|
47
|
+
let perturb propose them from ADRs, from plans, and from the friction logs and audits that
|
|
48
|
+
implementation leaves behind. Proposals wait for a person or a planning agent to confirm them,
|
|
49
|
+
so they never turn into noise. Closing a blocker records an `unblock` event automatically.
|
|
50
|
+
2. **Every issue has an inbox.** Whoever plans the issue reads it (`perturb inbox 32`), folds each
|
|
51
|
+
event into the plan, and acknowledges it (`perturb ack`) with a note on what they did. The
|
|
52
|
+
acknowledgement pins the exact version of the plan that absorbed the event.
|
|
53
|
+
3. **A stale gate stands before implementation.** `perturb stale 32` fails if an event arrived after
|
|
54
|
+
the plan was last committed, or if the plan changed after it was acknowledged. Run it before
|
|
55
|
+
building, by hand or as the first step of an agent's run.
|
|
56
|
+
|
|
57
|
+
Around the ledger, perturb reads sub-issues and blocked-by links from GitHub to answer *what should
|
|
58
|
+
be planned next* (`perturb next`), and `perturb check` lints the whole thing in CI.
|
|
59
|
+
|
|
60
|
+
GitHub stays the source of truth for issues. Events are small YAML files committed next to your
|
|
61
|
+
code and reviewed in the same pull request as the change that caused them.
|
|
62
|
+
|
|
63
|
+
### Areas
|
|
64
|
+
|
|
65
|
+
Many findings are about code rather than an issue: a friction log shows a run touched
|
|
66
|
+
`src/core/fares/`, or an ADR changes anything that prices a ride. An **area** is a named part of your
|
|
67
|
+
codebase, declared by hand as path globs in `perturb/areas.yaml`:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
areas:
|
|
71
|
+
fares:
|
|
72
|
+
paths: ["src/core/fares/**", "migrations/*fare*"]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An issue belongs to an area when it carries the `area:fares` label or its plan lists
|
|
76
|
+
`areas: [fares]`. perturb then proposes findings about those paths, and ADR consequences that
|
|
77
|
+
`affects: [area:fares]`, to the open issues in that area. A handful of areas is usually enough.
|
|
78
|
+
|
|
79
|
+
## Built for agents, fine for people
|
|
80
|
+
|
|
81
|
+
- **Machine-readable.** Every verb takes `--json` and prints a stable envelope, with a `reason` when
|
|
82
|
+
it refuses; gates report through exit codes.
|
|
83
|
+
- **Fails closed.** Verbs that need GitHub sync first and refuse rather than answer from a stale
|
|
84
|
+
cache.
|
|
85
|
+
- **Review built in.** A proposed event does nothing until an agent's planning step or a person
|
|
86
|
+
confirms it.
|
|
87
|
+
- **Guides, not prescriptions.** perturb doesn't ship planning, implementing or reviewing agents.
|
|
88
|
+
A guide for each phase shows where perturb fits and why, with example instructions to adapt into
|
|
89
|
+
whatever prompts or skills you already use. A small [agent skill](docs/agents.md#the-perturb-skill),
|
|
90
|
+
installable as a Claude Code plugin, answers "what's next?" or "is 29 still valid?".
|
|
91
|
+
- **By hand.** The same verbs work at a terminal, and `perturb propose adr:0002 --review` walks you
|
|
92
|
+
through proposals interactively.
|
|
93
|
+
|
|
94
|
+
## Downsides
|
|
95
|
+
|
|
96
|
+
- **GitHub only, and online.** Issues, sub-issues and blocked-by links must live on GitHub, and the
|
|
97
|
+
graph is only as good as your use of them. Verbs that read the issue graph sync first and refuse
|
|
98
|
+
when GitHub is unreachable; the ledger verbs (`inbox`, `ack`, `confirm`, `dismiss`) and `init`
|
|
99
|
+
work offline.
|
|
100
|
+
- **It runs on discipline.** The ledger knows only what gets pushed, confirmed and acknowledged. If
|
|
101
|
+
decisions aren't recorded or inboxes aren't read, it quietly says nothing. Someone, or some
|
|
102
|
+
agent's planning step, has to own that.
|
|
103
|
+
- **More files in your pull requests.** Every event is a committed YAML file, so reviews carry
|
|
104
|
+
ledger changes alongside code.
|
|
105
|
+
- **Branches blur the stale gate.** `stale` compares when an event was created with when the plan
|
|
106
|
+
was last committed. An event recorded on a branch that merges after someone else has planned
|
|
107
|
+
the issue slips past the gate: it still waits in the inbox, but nothing fails. Each person also
|
|
108
|
+
sees only their own checkout, so events on unmerged branches are invisible to `inbox`, `stale`
|
|
109
|
+
and CI. Short-lived branches and checking an up-to-date `main` before implementing help.
|
|
110
|
+
- **Parallel work duplicates and scatters events.** Duplicates are only detected within your own
|
|
111
|
+
checkout, so two people proposing from the same ADR, or both syncing after a blocker closes,
|
|
112
|
+
record the same event twice. Because a sync can write `unblock` events, they can land in
|
|
113
|
+
unrelated pull requests. And proposals need a clear owner, or they pile up unreviewed.
|
|
114
|
+
- **Proposals are heuristics.** Routing uses `#N` mentions and path globs in a hand-maintained
|
|
115
|
+
`areas.yaml`. Loose areas mean noisy proposals to dismiss; missing areas mean findings that reach
|
|
116
|
+
nobody. How well the rules hold up on real projects is not yet measured.
|
|
117
|
+
- **The stale gate is coarse.** Any pending event newer than the plan's last commit marks it
|
|
118
|
+
stale, however small the change, and any edit to an acknowledged plan, even a typo fix, means
|
|
119
|
+
acknowledging its events again.
|
|
120
|
+
- **It assumes a way of planning.** Plans must be Markdown files with `closes:` front-matter, and
|
|
121
|
+
issues need the ready-to-implement label. Plans kept in issue bodies, documents or tickets
|
|
122
|
+
elsewhere don't count.
|
|
123
|
+
- **Some sources need specific formats.** ADR proposals need the [structured ADR
|
|
124
|
+
format](docs/adr-format.md) (`perturb adr migrate` converts prose ADRs). Friction and audit
|
|
125
|
+
proposals need files in the [run evidence format](docs/run-evidence-format.md). Your workflow
|
|
126
|
+
has to write them, though a friction log only needs a list of commits.
|
|
127
|
+
- **One repository at a time.** There is no ledger across repositories.
|
|
128
|
+
- **Early.** Formats and JSON output may change before 1.0, and it isn't published to PyPI.
|
|
129
|
+
|
|
130
|
+
## Install
|
|
131
|
+
|
|
132
|
+
You need Python 3.11+, `git`, a repository hosted on GitHub, and the [`gh`](https://cli.github.com/)
|
|
133
|
+
CLI logged in (or `GH_TOKEN` set). If you use a wrapper around `gh`, point `PERTURB_GH` at it.
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
uv tool install git+https://github.com/geuben/perturb
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Quick start
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
cd your-repo
|
|
143
|
+
perturb init # creates perturb/: areas.yaml, config.yaml, README.md, events/
|
|
144
|
+
perturb next # ready, unplanned issues, the ones that unblock the most first
|
|
145
|
+
perturb inbox 32 # what has changed for #32
|
|
146
|
+
|
|
147
|
+
# Planning #31, you lock a decision that affects #32:
|
|
148
|
+
perturb push --from 31 --to 32 --kind decision "Fare periods are half-open"
|
|
149
|
+
|
|
150
|
+
# Planning #32: fold the event into the plan, commit the plan, then acknowledge it.
|
|
151
|
+
perturb ack 32 --all --plan tasks/backfill-refunds.md --note "Design decision 1"
|
|
152
|
+
|
|
153
|
+
perturb stale 32 # exit 0: the plan has seen everything
|
|
154
|
+
perturb check # the same checks, for CI
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Commit the `perturb/` directory along with your work; `perturb init` has already added its local
|
|
158
|
+
cache, `.perturb/`, to `.gitignore`. To run the checks on every pull request, see
|
|
159
|
+
[Getting started](docs/getting-started.md#run-the-checks-in-ci).
|
|
160
|
+
|
|
161
|
+
A plan is a Markdown file with `closes: <issue number>` in its front-matter, at `tasks/<slug>.md` by
|
|
162
|
+
default. An issue counts as planned once its plan exists and the issue carries the
|
|
163
|
+
`ready-to-implement` label. Both are configurable in `perturb/config.yaml`, along with where
|
|
164
|
+
friction logs and audits live; declare your codebase's [areas](#areas) in `perturb/areas.yaml` so
|
|
165
|
+
friction reaches the issues that touch the same code.
|
|
166
|
+
|
|
167
|
+
## Status
|
|
168
|
+
|
|
169
|
+
Early. Version `0.0.1` does everything described here and is used to build perturb itself, but the
|
|
170
|
+
JSON envelope and file formats may still change.
|
|
171
|
+
|
|
172
|
+
## Documentation
|
|
173
|
+
|
|
174
|
+
- [Getting started](docs/getting-started.md): set up a repository and walk through the whole loop
|
|
175
|
+
- [Concepts](docs/concepts.md): events, inboxes, the stale gate, areas and the issue graph
|
|
176
|
+
- [Using perturb with agents](docs/agents.md), with a guide for each phase:
|
|
177
|
+
[planning](docs/planning.md), [implementing](docs/implementing.md) and
|
|
178
|
+
[reviewing](docs/reviewing.md)
|
|
179
|
+
- [Using perturb with tdd-cli](docs/tdd-cli.md): what a test-driven executor gives perturb for free
|
|
180
|
+
- [Working in a team](docs/teams.md): branches, duplicates and the habits that avoid them
|
|
181
|
+
- [CLI reference](docs/cli.md) and [configuration](docs/configuration.md)
|
|
182
|
+
- Formats: [ADRs](docs/adr-format.md) and [plans, friction logs and audits](docs/run-evidence-format.md)
|
|
183
|
+
- [Design notes](docs/design/README.md): why perturb is built the way it is
|
|
184
|
+
|
|
185
|
+
## Contributing
|
|
186
|
+
|
|
187
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and how this repository uses perturb to
|
|
188
|
+
build perturb.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Trust model
|
|
4
|
+
|
|
5
|
+
**perturb reads your repository as data and never runs commands it declares.** It parses
|
|
6
|
+
`perturb/config.yaml`, `perturb/areas.yaml`, the event files, plans, friction logs, audits and ADRs
|
|
7
|
+
with a safe YAML loader, and no file in the repository can choose a command for perturb to run.
|
|
8
|
+
|
|
9
|
+
Other properties worth knowing:
|
|
10
|
+
|
|
11
|
+
- **It runs two programs:** `git`, and one GitHub CLI: `gh`, or the command named in the
|
|
12
|
+
`PERTURB_GH` environment variable. `PERTURB_GH` is executed with your privileges, so whoever
|
|
13
|
+
controls your environment controls that command. It is only ever read from the environment.
|
|
14
|
+
- **Network access goes through that CLI only**, to GitHub's API for the repository named by
|
|
15
|
+
`origin`. perturb reads issues, and writes to GitHub only when asked to create one:
|
|
16
|
+
`perturb push --new`, or accepting an item with no target in `perturb propose audit: --review`.
|
|
17
|
+
- **perturb never reads, stores or prints a token.** Authentication belongs to the CLI. In CI, the
|
|
18
|
+
`perturb check` job needs only `contents: read` and `issues: read`.
|
|
19
|
+
- **Local writes:** `perturb/events/`, the `.perturb/` cache, `.gitignore` (by `perturb init`), and
|
|
20
|
+
the ADR file passed to `perturb adr migrate`. The cache holds issue titles, labels and states:
|
|
21
|
+
for a private repository that is private data, which is why `init` keeps it out of git.
|
|
22
|
+
- **Ledger text is untrusted input for agents.** Event summaries, notes and quoted `detail` text
|
|
23
|
+
are written by whoever committed them, including pull request authors. An agent reading
|
|
24
|
+
`perturb inbox` should treat that text as data, never as instructions, and ledger changes deserve
|
|
25
|
+
the same review as code.
|
|
26
|
+
|
|
27
|
+
## Reporting a vulnerability
|
|
28
|
+
|
|
29
|
+
Report privately via GitHub's
|
|
30
|
+
[private vulnerability reporting](https://github.com/geuben/perturb/security/advisories/new)
|
|
31
|
+
rather than a public issue. Reports are acknowledged on a best-effort basis; this is a
|
|
32
|
+
solo-maintained project.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "perturb"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Planning ledger for agent-driven development: issue graph, decision inbox, staleness gate"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "geuben" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pathspec>=1.1.1",
|
|
12
|
+
"pyyaml>=6.0",
|
|
13
|
+
]
|
|
14
|
+
keywords = ["planning", "agents", "ai", "github", "issues", "adr", "workflow"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: POSIX",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Software Development",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/geuben/perturb"
|
|
31
|
+
Repository = "https://github.com/geuben/perturb"
|
|
32
|
+
Issues = "https://github.com/geuben/perturb/issues"
|
|
33
|
+
Changelog = "https://github.com/geuben/perturb/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
perturb = "perturb.cli:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/perturb/__init__.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/perturb"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["/src", "/tests", "/README.md", "/LICENSE", "/CHANGELOG.md", "/SECURITY.md"]
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = ["pytest>=8.0", "pytest-json-report>=1.5", "pytest-xdist>=3.5", "ruff>=0.5", "zizmor>=1.29"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
addopts = ["-n", "auto"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
src = ["src", "tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from perturb.envelope import Refusal
|
|
2
|
+
from perturb.events import EventStore
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ack(events_dir, *, target, event_ids, all_pending, plan, plan_blob, by, note, now=None):
|
|
6
|
+
kwargs = {} if now is None else {"now": now}
|
|
7
|
+
store = EventStore(events_dir, **kwargs)
|
|
8
|
+
load_result = store.load()
|
|
9
|
+
events_by_id = {e.id: e for e in load_result.events}
|
|
10
|
+
|
|
11
|
+
if all_pending and event_ids:
|
|
12
|
+
raise Refusal("ack_conflicting_selection")
|
|
13
|
+
if not all_pending and not event_ids:
|
|
14
|
+
raise Refusal("ack_no_selection")
|
|
15
|
+
|
|
16
|
+
if all_pending:
|
|
17
|
+
ids_to_ack = [
|
|
18
|
+
e.id for e in load_result.events if e.target == target and e.status == "pending"
|
|
19
|
+
]
|
|
20
|
+
else:
|
|
21
|
+
for event_id in event_ids:
|
|
22
|
+
if event_id not in events_by_id:
|
|
23
|
+
raise Refusal("unknown_event", f"event {event_id!r} not found")
|
|
24
|
+
event = events_by_id[event_id]
|
|
25
|
+
if event.target != target:
|
|
26
|
+
raise Refusal("event_target_mismatch", f"{event_id} targets {event.target}")
|
|
27
|
+
if event.status not in ("pending", "acknowledged"):
|
|
28
|
+
raise Refusal("event_not_pending", f"{event_id} is {event.status!r}")
|
|
29
|
+
ids_to_ack = list(event_ids)
|
|
30
|
+
|
|
31
|
+
acked = []
|
|
32
|
+
for event_id in ids_to_ack:
|
|
33
|
+
was_acknowledged = events_by_id[event_id].status == "acknowledged"
|
|
34
|
+
store.acknowledge(event_id, by=by, plan=plan, plan_blob=plan_blob, note=note)
|
|
35
|
+
acked.append({"id": event_id, "target": target, "was_acknowledged": was_acknowledged})
|
|
36
|
+
|
|
37
|
+
return {"acked": acked}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_ack(data):
|
|
41
|
+
acked = data["acked"]
|
|
42
|
+
target = acked[0]["target"] if acked else ""
|
|
43
|
+
lines = [f"Acked {len(acked)} event(s) for {target}:"]
|
|
44
|
+
for entry in acked:
|
|
45
|
+
suffix = " (re-acked)" if entry["was_acknowledged"] else ""
|
|
46
|
+
lines.append(f" {entry['id']}{suffix}")
|
|
47
|
+
return "\n".join(lines) + "\n"
|