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.
Files changed (53) hide show
  1. perturb-0.0.1/.gitignore +8 -0
  2. perturb-0.0.1/CHANGELOG.md +39 -0
  3. perturb-0.0.1/LICENSE +21 -0
  4. perturb-0.0.1/PKG-INFO +220 -0
  5. perturb-0.0.1/README.md +192 -0
  6. perturb-0.0.1/SECURITY.md +32 -0
  7. perturb-0.0.1/pyproject.toml +63 -0
  8. perturb-0.0.1/src/perturb/__init__.py +3 -0
  9. perturb-0.0.1/src/perturb/ack.py +47 -0
  10. perturb-0.0.1/src/perturb/adr.py +294 -0
  11. perturb-0.0.1/src/perturb/areas.py +72 -0
  12. perturb-0.0.1/src/perturb/check.py +244 -0
  13. perturb-0.0.1/src/perturb/cli.py +1284 -0
  14. perturb-0.0.1/src/perturb/config.py +111 -0
  15. perturb-0.0.1/src/perturb/envelope.py +66 -0
  16. perturb-0.0.1/src/perturb/events.py +217 -0
  17. perturb-0.0.1/src/perturb/github.py +115 -0
  18. perturb-0.0.1/src/perturb/graph.py +170 -0
  19. perturb-0.0.1/src/perturb/graph_export.py +71 -0
  20. perturb-0.0.1/src/perturb/ids.py +15 -0
  21. perturb-0.0.1/src/perturb/inbox.py +202 -0
  22. perturb-0.0.1/src/perturb/init.py +98 -0
  23. perturb-0.0.1/src/perturb/propose.py +913 -0
  24. perturb-0.0.1/src/perturb/push.py +56 -0
  25. perturb-0.0.1/src/perturb/queue.py +65 -0
  26. perturb-0.0.1/src/perturb/refs.py +45 -0
  27. perturb-0.0.1/src/perturb/show.py +81 -0
  28. perturb-0.0.1/src/perturb/stale.py +44 -0
  29. perturb-0.0.1/src/perturb/sync.py +166 -0
  30. perturb-0.0.1/src/perturb/transitions.py +47 -0
  31. perturb-0.0.1/tests/test_ack.py +230 -0
  32. perturb-0.0.1/tests/test_adr.py +362 -0
  33. perturb-0.0.1/tests/test_areas.py +210 -0
  34. perturb-0.0.1/tests/test_check.py +313 -0
  35. perturb-0.0.1/tests/test_cli.py +2669 -0
  36. perturb-0.0.1/tests/test_cli_config.py +103 -0
  37. perturb-0.0.1/tests/test_config.py +81 -0
  38. perturb-0.0.1/tests/test_envelope.py +124 -0
  39. perturb-0.0.1/tests/test_events.py +270 -0
  40. perturb-0.0.1/tests/test_github.py +185 -0
  41. perturb-0.0.1/tests/test_graph.py +220 -0
  42. perturb-0.0.1/tests/test_graph_export.py +147 -0
  43. perturb-0.0.1/tests/test_ids.py +22 -0
  44. perturb-0.0.1/tests/test_inbox.py +319 -0
  45. perturb-0.0.1/tests/test_init.py +157 -0
  46. perturb-0.0.1/tests/test_propose.py +1576 -0
  47. perturb-0.0.1/tests/test_push.py +106 -0
  48. perturb-0.0.1/tests/test_queue.py +154 -0
  49. perturb-0.0.1/tests/test_refs.py +42 -0
  50. perturb-0.0.1/tests/test_show.py +167 -0
  51. perturb-0.0.1/tests/test_stale.py +174 -0
  52. perturb-0.0.1/tests/test_sync.py +564 -0
  53. perturb-0.0.1/tests/test_transitions.py +247 -0
@@ -0,0 +1,8 @@
1
+ .perturb/
2
+ __pycache__/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ .tdd/
8
+ .claude/worktrees/
@@ -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
@@ -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,3 @@
1
+ """Planning ledger for agent-driven development. See docs/ for the design."""
2
+
3
+ __version__ = "0.0.1"
@@ -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"