feature-flow-cli 0.1.5__tar.gz → 0.2.0__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.
- {feature_flow_cli-0.1.5/feature_flow_cli.egg-info → feature_flow_cli-0.2.0}/PKG-INFO +11 -8
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/README.md +10 -7
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/adapters/codex/feature-flow/SKILL.md +3 -3
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/__init__.py +1 -1
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/checks.py +18 -16
- feature_flow_cli-0.2.0/feature_flow/codehash.py +61 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/command.py +1 -1
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/conductor.py +49 -8
- feature_flow_cli-0.2.0/feature_flow/floorguard.py +392 -0
- feature_flow_cli-0.2.0/feature_flow/gate.py +115 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/install.py +79 -8
- feature_flow_cli-0.2.0/feature_flow/proc.py +31 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/prompts.py +3 -1
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0/feature_flow_cli.egg-info}/PKG-INFO +11 -8
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow_cli.egg-info/SOURCES.txt +2 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/brief.md +2 -2
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/build.md +2 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/install.py +1 -1
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/install.sh +1 -1
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/skills/feature-flow/SKILL.md +3 -3
- feature_flow_cli-0.1.5/feature_flow/floorguard.py +0 -340
- feature_flow_cli-0.1.5/feature_flow/gate.py +0 -100
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/LICENSE +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/MANIFEST.in +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/adapters/codex/feature-flow/agents/openai.yaml +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/agents/feature-planner.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/agents/plan-reviewer.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/agents/ticket-builder.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/agents/ticket-reviewer.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/__main__.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/cli.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/git.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/released.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/state.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/status.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/suggest.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/tickets.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow/view.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow_cli.egg-info/dependency_links.txt +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow_cli.egg-info/entry_points.txt +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/feature_flow_cli.egg-info/top_level.txt +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/plan-review.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/plan.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/review.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/show.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/commands.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/learnings.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/map.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/spec.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/ticket.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/guides/templates/ui-mockup.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/pyproject.toml +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/floor-guard.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/flow-status.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/flow-view.html +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/flow-view.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/flow.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/scripts/gate.py +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/setup.cfg +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/skills/architect-review/SKILL.md +0 -0
- {feature_flow_cli-0.1.5 → feature_flow_cli-0.2.0}/skills/automation-design/SKILL.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: feature-flow-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A ticket-graph workflow for coding agents: installs the feature-flow skill into a repo
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/hamilton-sky/feature-flow
|
|
@@ -55,19 +55,21 @@ The package puts a `feature-flow` command on your PATH with `install`, `status <
|
|
|
55
55
|
|
|
56
56
|
The installer lists what it wrote in `.feature-flow/installed.txt`, with each file's hash in `.feature-flow/installed.sha256`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes. Installing a newer version over an older one updates every file nobody edited since it was installed, and keeps (and names) the ones you changed; `--force` replaces those too.
|
|
57
57
|
|
|
58
|
+
To keep the install out of git, so only your plans and tickets get committed, install with `--private`. It lists the installed files in `.git/info/exclude`, which git reads like `.gitignore` but never commits, and later installs keep that list up to date. If you already committed the install, it prints the `git rm --cached` command that untracks it and keeps the files. Every clone then needs its own install.
|
|
59
|
+
|
|
58
60
|
Then, in your repo, in the agent:
|
|
59
61
|
|
|
60
62
|
| | Claude Code | Codex |
|
|
61
63
|
|---|---|---|
|
|
62
64
|
| Plan a feature (no plan yet) | `/feature-flow csv-export` | `$feature-flow csv-export` |
|
|
63
|
-
| Plan what you just talked through | `/feature-flow` | `$feature-flow` |
|
|
65
|
+
| Plan and build what you just talked through | `/feature-flow` | `$feature-flow` |
|
|
64
66
|
| Build its tickets (a plan exists) | `/feature-flow csv-export` | `$feature-flow csv-export` |
|
|
65
67
|
| The same, asking nothing | `/feature-flow csv-export auto` | `$feature-flow csv-export auto` |
|
|
66
68
|
| Watch the graph, animated | `/feature-flow csv-export show` | `$feature-flow csv-export show` |
|
|
67
69
|
|
|
68
|
-
The same command plans when `plans/csv-export/` does not exist yet and builds when it does.
|
|
70
|
+
The same command plans when `plans/csv-export/` does not exist yet and builds when it does. After your second yes it commits the new plan folder, and only that folder, then goes straight on to building it; say so if you want it to stop after planning.
|
|
69
71
|
|
|
70
|
-
**Planning asks you twice.** First the session shows a short feature brief, written from your conversation (what, why, scope, the bar, and anything it had to assume), and waits for yes, edit or cancel. Then a fresh `feature-planner` subagent, given only that brief, reads the code, looks up outside docs where the code cannot answer, and writes a draft plan into the git-ignored `.feature-flow/state/draft/`. A fresh `plan-reviewer` checks the draft against the brief. The session shows you the goal, the commands, the ticket graph, what was dropped and the review result, and waits for a second yes. Only then does `flow.py plan-accept` copy the draft into `plans/<feature
|
|
72
|
+
**Planning asks you twice.** First the session shows a short feature brief, written from your conversation (what, why, scope, the bar, and anything it had to assume), and waits for yes, edit or cancel. Then a fresh `feature-planner` subagent, given only that brief, reads the code, looks up outside docs where the code cannot answer, and writes a draft plan into the git-ignored `.feature-flow/state/draft/`. A fresh `plan-reviewer` checks the draft against the brief. The session shows you the goal, the commands, the ticket graph, what was dropped and the review result, and waits for a second yes. Only then does `flow.py plan-accept` copy the draft into `plans/<feature>/`, and the session commits it and starts the build.
|
|
71
73
|
|
|
72
74
|
Try the graph first, with no setup and no agent: `bash examples/demo.sh --open`.
|
|
73
75
|
|
|
@@ -173,7 +175,7 @@ Floor: allow config optional, only when a human decides the guard may let
|
|
|
173
175
|
- **The Answers are the handoff between subagents.** The next builder reads the Answers of the tickets it depends on.
|
|
174
176
|
- `settle` tickets record a decision. `convert` tickets migrate something and must be safe to run twice.
|
|
175
177
|
- **A worker may change only** its own Status line and Answer, lines appended to `map.md` and `learnings.md`, and the code the ticket calls for. Rewriting its own Done when, editing another ticket, or touching `commands.md` fails the guard.
|
|
176
|
-
- `Floor:` categories: `skip`, `suppress`, `empty-catch`, `test-delete`, `threshold`, `config`, `ticket-edit`, `commands-edit`. The line is read from the ticket as it was before the work started, so a worker cannot excuse itself.
|
|
178
|
+
- `Floor:` categories: `skip`, `suppress`, `empty-catch`, `test-delete`, `threshold`, `config`, `ticket-edit`, `commands-edit`, and `flow-edit` (allows the conductor's code check below, for the build only). `skip` covers skipped, expected-fail and focused tests (`.only(`, `fit(`, `fdescribe(` on test paths); `config` covers more lint and test config files, the lint and test sections of `pyproject.toml` and `setup.cfg`, scripts and test keys in `package.json`, new skip or xfail words in `conftest.py`, and a `Makefile` only when `commands.md` uses `make`. A test file that loses assertions is shown to the reviewer as a warning, not a finding. The line is read from the ticket as it was before the work started, so a worker cannot excuse itself.
|
|
177
179
|
- The guard needs the plan to be tracked by git. If you keep tickets in an ignored folder, it cannot see changes to them.
|
|
178
180
|
- The commands in `commands.md` run with nobody watching and must exit non zero on failure.
|
|
179
181
|
|
|
@@ -239,12 +241,13 @@ The commands in `commands.md` run through `bash -c` on Linux and macOS and throu
|
|
|
239
241
|
| `FLOW_MAX_RETRIES` | `2` | builds or reviews per step before the run stops |
|
|
240
242
|
| `FLOW_MAX_REVIEW_ROUNDS` | `3` | times a ticket may be sent back before the run stops |
|
|
241
243
|
| `FLOW_GATE` | `on` | `off` skips Build, Test and Lint after each ticket |
|
|
244
|
+
| `FLOW_GATE_TIMEOUT` | `30` | minutes each gate command and the smoke test may run; `0` means no limit. A failed smoke test writes `.feature-flow/state/<feature>.smoke.log` |
|
|
242
245
|
| `FLOW_SMOKE` | the `Smoke:` line of `commands.md` | command run before every ticket; the run stops if it fails |
|
|
243
246
|
| `FLOW_DIR`, `FLOW_TICKETS` | `plans`, `tasks` | where the plans and the ticket folder live |
|
|
244
247
|
| `FLOW_NO_OPEN` | unset | `1` makes `flow-view.py` never open a browser |
|
|
245
248
|
| `FLOW_WATCH_SECONDS` | `3` | how often `flow-view.py --watch` rewrites the page |
|
|
246
249
|
|
|
247
|
-
The run also stops on its own after a number of
|
|
250
|
+
The run also stops on its own after a number of phases (`BUILD` and `REVIEW` hand-outs) that grows with the ticket count, recomputed at every pick, so a loop of failures cannot go on forever.
|
|
248
251
|
|
|
249
252
|
## What is tested
|
|
250
253
|
|
|
@@ -271,7 +274,7 @@ bash tests/run.sh
|
|
|
271
274
|
python3 -m unittest discover -s tests/py
|
|
272
275
|
```
|
|
273
276
|
|
|
274
|
-
Offline and free: no model is called. The suite covers the conductor's every answer and how a run stops, sessions, handoff and takeover, a `.git` the agent cannot write, the gate, the floor guard, plan protection, the prompts and guides, both skills, the installer for both agents, the graph page and its data, and the acceptance harness. The page's layout and replay logic are also tested under Node (`tests/viewer-logic.test.js`, skipped when Node is absent). `.github/workflows/tests.yml` runs it on every push to `main` and every pull request, on Ubuntu
|
|
277
|
+
Offline and free: no model is called. The suite covers the conductor's every answer and how a run stops, sessions, handoff and takeover, a `.git` the agent cannot write, the gate, the floor guard, plan protection, the prompts and guides, both skills, the installer for both agents, the graph page and its data, and the acceptance harness. The page's layout and replay logic are also tested under Node (`tests/viewer-logic.test.js`, skipped when Node is absent). `.github/workflows/tests.yml` runs it on every push to `main` and every pull request, on Ubuntu and on macOS. A fourth job runs the Python unit tests on Windows, including a fixture drive that takes a two-ticket plan through `python scripts/flow.py f start` and `next` to `BUILD` and then `REVIEW`. `tests/run.sh` itself is a bash harness and does not run on Windows. Windows is tested this way only: no real agent run has been done there.
|
|
275
278
|
|
|
276
279
|
A `package` job builds the wheel and the sdist and runs `tests/package_smoke.py` on Ubuntu (Python 3.9 and the latest), macOS and Windows: it installs the wheel in a fresh virtual environment, runs `feature-flow install`, and checks that the repo gets the same files as from `install.py` in a clone. Run it locally with `python3 -m pip install build && python3 tests/package_smoke.py`.
|
|
277
280
|
|
|
@@ -302,7 +305,7 @@ git tag v0.1.1 && git push origin v0.1.1
|
|
|
302
305
|
|
|
303
306
|
## Caution
|
|
304
307
|
|
|
305
|
-
The builder subagent has edit and shell access and commits after each ticket. Each ticket costs at least two subagents. Build on a branch you can throw away. Ignore build output in `.gitignore`, because the conductor stops if the tree is dirty after a ticket. The gate runs
|
|
308
|
+
The builder subagent has edit and shell access and commits after each ticket. Each ticket costs at least two subagents. Build on a branch you can throw away. Ignore build output in `.gitignore`, because the conductor stops if the tree is dirty after a ticket. The gate and the smoke test stop after 30 minutes (`FLOW_GATE_TIMEOUT`). The conductor also checks that the code it runs (the `feature_flow` package, `scripts/*.py` and the build and review role and guide files) did not change during a build or review; if it did, the run stops with `STOP flow code changed while building <ticket>: <files>`, and `Floor: allow flow-edit` on the ticket allows it for the build. That check is a tripwire, not a sandbox. The floor guard is pattern matching: it can miss things and it can raise false alarms, and `Floor: allow` is the release valve. The run stops on its own when a ticket stays unresolved, when the smoke test or the gate keeps failing, when a ticket keeps failing review, or when it reaches its phase limit.
|
|
306
309
|
|
|
307
310
|
## License
|
|
308
311
|
|
|
@@ -36,19 +36,21 @@ The package puts a `feature-flow` command on your PATH with `install`, `status <
|
|
|
36
36
|
|
|
37
37
|
The installer lists what it wrote in `.feature-flow/installed.txt`, with each file's hash in `.feature-flow/installed.sha256`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes. Installing a newer version over an older one updates every file nobody edited since it was installed, and keeps (and names) the ones you changed; `--force` replaces those too.
|
|
38
38
|
|
|
39
|
+
To keep the install out of git, so only your plans and tickets get committed, install with `--private`. It lists the installed files in `.git/info/exclude`, which git reads like `.gitignore` but never commits, and later installs keep that list up to date. If you already committed the install, it prints the `git rm --cached` command that untracks it and keeps the files. Every clone then needs its own install.
|
|
40
|
+
|
|
39
41
|
Then, in your repo, in the agent:
|
|
40
42
|
|
|
41
43
|
| | Claude Code | Codex |
|
|
42
44
|
|---|---|---|
|
|
43
45
|
| Plan a feature (no plan yet) | `/feature-flow csv-export` | `$feature-flow csv-export` |
|
|
44
|
-
| Plan what you just talked through | `/feature-flow` | `$feature-flow` |
|
|
46
|
+
| Plan and build what you just talked through | `/feature-flow` | `$feature-flow` |
|
|
45
47
|
| Build its tickets (a plan exists) | `/feature-flow csv-export` | `$feature-flow csv-export` |
|
|
46
48
|
| The same, asking nothing | `/feature-flow csv-export auto` | `$feature-flow csv-export auto` |
|
|
47
49
|
| Watch the graph, animated | `/feature-flow csv-export show` | `$feature-flow csv-export show` |
|
|
48
50
|
|
|
49
|
-
The same command plans when `plans/csv-export/` does not exist yet and builds when it does.
|
|
51
|
+
The same command plans when `plans/csv-export/` does not exist yet and builds when it does. After your second yes it commits the new plan folder, and only that folder, then goes straight on to building it; say so if you want it to stop after planning.
|
|
50
52
|
|
|
51
|
-
**Planning asks you twice.** First the session shows a short feature brief, written from your conversation (what, why, scope, the bar, and anything it had to assume), and waits for yes, edit or cancel. Then a fresh `feature-planner` subagent, given only that brief, reads the code, looks up outside docs where the code cannot answer, and writes a draft plan into the git-ignored `.feature-flow/state/draft/`. A fresh `plan-reviewer` checks the draft against the brief. The session shows you the goal, the commands, the ticket graph, what was dropped and the review result, and waits for a second yes. Only then does `flow.py plan-accept` copy the draft into `plans/<feature
|
|
53
|
+
**Planning asks you twice.** First the session shows a short feature brief, written from your conversation (what, why, scope, the bar, and anything it had to assume), and waits for yes, edit or cancel. Then a fresh `feature-planner` subagent, given only that brief, reads the code, looks up outside docs where the code cannot answer, and writes a draft plan into the git-ignored `.feature-flow/state/draft/`. A fresh `plan-reviewer` checks the draft against the brief. The session shows you the goal, the commands, the ticket graph, what was dropped and the review result, and waits for a second yes. Only then does `flow.py plan-accept` copy the draft into `plans/<feature>/`, and the session commits it and starts the build.
|
|
52
54
|
|
|
53
55
|
Try the graph first, with no setup and no agent: `bash examples/demo.sh --open`.
|
|
54
56
|
|
|
@@ -154,7 +156,7 @@ Floor: allow config optional, only when a human decides the guard may let
|
|
|
154
156
|
- **The Answers are the handoff between subagents.** The next builder reads the Answers of the tickets it depends on.
|
|
155
157
|
- `settle` tickets record a decision. `convert` tickets migrate something and must be safe to run twice.
|
|
156
158
|
- **A worker may change only** its own Status line and Answer, lines appended to `map.md` and `learnings.md`, and the code the ticket calls for. Rewriting its own Done when, editing another ticket, or touching `commands.md` fails the guard.
|
|
157
|
-
- `Floor:` categories: `skip`, `suppress`, `empty-catch`, `test-delete`, `threshold`, `config`, `ticket-edit`, `commands-edit`. The line is read from the ticket as it was before the work started, so a worker cannot excuse itself.
|
|
159
|
+
- `Floor:` categories: `skip`, `suppress`, `empty-catch`, `test-delete`, `threshold`, `config`, `ticket-edit`, `commands-edit`, and `flow-edit` (allows the conductor's code check below, for the build only). `skip` covers skipped, expected-fail and focused tests (`.only(`, `fit(`, `fdescribe(` on test paths); `config` covers more lint and test config files, the lint and test sections of `pyproject.toml` and `setup.cfg`, scripts and test keys in `package.json`, new skip or xfail words in `conftest.py`, and a `Makefile` only when `commands.md` uses `make`. A test file that loses assertions is shown to the reviewer as a warning, not a finding. The line is read from the ticket as it was before the work started, so a worker cannot excuse itself.
|
|
158
160
|
- The guard needs the plan to be tracked by git. If you keep tickets in an ignored folder, it cannot see changes to them.
|
|
159
161
|
- The commands in `commands.md` run with nobody watching and must exit non zero on failure.
|
|
160
162
|
|
|
@@ -220,12 +222,13 @@ The commands in `commands.md` run through `bash -c` on Linux and macOS and throu
|
|
|
220
222
|
| `FLOW_MAX_RETRIES` | `2` | builds or reviews per step before the run stops |
|
|
221
223
|
| `FLOW_MAX_REVIEW_ROUNDS` | `3` | times a ticket may be sent back before the run stops |
|
|
222
224
|
| `FLOW_GATE` | `on` | `off` skips Build, Test and Lint after each ticket |
|
|
225
|
+
| `FLOW_GATE_TIMEOUT` | `30` | minutes each gate command and the smoke test may run; `0` means no limit. A failed smoke test writes `.feature-flow/state/<feature>.smoke.log` |
|
|
223
226
|
| `FLOW_SMOKE` | the `Smoke:` line of `commands.md` | command run before every ticket; the run stops if it fails |
|
|
224
227
|
| `FLOW_DIR`, `FLOW_TICKETS` | `plans`, `tasks` | where the plans and the ticket folder live |
|
|
225
228
|
| `FLOW_NO_OPEN` | unset | `1` makes `flow-view.py` never open a browser |
|
|
226
229
|
| `FLOW_WATCH_SECONDS` | `3` | how often `flow-view.py --watch` rewrites the page |
|
|
227
230
|
|
|
228
|
-
The run also stops on its own after a number of
|
|
231
|
+
The run also stops on its own after a number of phases (`BUILD` and `REVIEW` hand-outs) that grows with the ticket count, recomputed at every pick, so a loop of failures cannot go on forever.
|
|
229
232
|
|
|
230
233
|
## What is tested
|
|
231
234
|
|
|
@@ -252,7 +255,7 @@ bash tests/run.sh
|
|
|
252
255
|
python3 -m unittest discover -s tests/py
|
|
253
256
|
```
|
|
254
257
|
|
|
255
|
-
Offline and free: no model is called. The suite covers the conductor's every answer and how a run stops, sessions, handoff and takeover, a `.git` the agent cannot write, the gate, the floor guard, plan protection, the prompts and guides, both skills, the installer for both agents, the graph page and its data, and the acceptance harness. The page's layout and replay logic are also tested under Node (`tests/viewer-logic.test.js`, skipped when Node is absent). `.github/workflows/tests.yml` runs it on every push to `main` and every pull request, on Ubuntu
|
|
258
|
+
Offline and free: no model is called. The suite covers the conductor's every answer and how a run stops, sessions, handoff and takeover, a `.git` the agent cannot write, the gate, the floor guard, plan protection, the prompts and guides, both skills, the installer for both agents, the graph page and its data, and the acceptance harness. The page's layout and replay logic are also tested under Node (`tests/viewer-logic.test.js`, skipped when Node is absent). `.github/workflows/tests.yml` runs it on every push to `main` and every pull request, on Ubuntu and on macOS. A fourth job runs the Python unit tests on Windows, including a fixture drive that takes a two-ticket plan through `python scripts/flow.py f start` and `next` to `BUILD` and then `REVIEW`. `tests/run.sh` itself is a bash harness and does not run on Windows. Windows is tested this way only: no real agent run has been done there.
|
|
256
259
|
|
|
257
260
|
A `package` job builds the wheel and the sdist and runs `tests/package_smoke.py` on Ubuntu (Python 3.9 and the latest), macOS and Windows: it installs the wheel in a fresh virtual environment, runs `feature-flow install`, and checks that the repo gets the same files as from `install.py` in a clone. Run it locally with `python3 -m pip install build && python3 tests/package_smoke.py`.
|
|
258
261
|
|
|
@@ -283,7 +286,7 @@ git tag v0.1.1 && git push origin v0.1.1
|
|
|
283
286
|
|
|
284
287
|
## Caution
|
|
285
288
|
|
|
286
|
-
The builder subagent has edit and shell access and commits after each ticket. Each ticket costs at least two subagents. Build on a branch you can throw away. Ignore build output in `.gitignore`, because the conductor stops if the tree is dirty after a ticket. The gate runs
|
|
289
|
+
The builder subagent has edit and shell access and commits after each ticket. Each ticket costs at least two subagents. Build on a branch you can throw away. Ignore build output in `.gitignore`, because the conductor stops if the tree is dirty after a ticket. The gate and the smoke test stop after 30 minutes (`FLOW_GATE_TIMEOUT`). The conductor also checks that the code it runs (the `feature_flow` package, `scripts/*.py` and the build and review role and guide files) did not change during a build or review; if it did, the run stops with `STOP flow code changed while building <ticket>: <files>`, and `Floor: allow flow-edit` on the ticket allows it for the build. That check is a tripwire, not a sandbox. The floor guard is pattern matching: it can miss things and it can raise false alarms, and `Floor: allow` is the release valve. The run stops on its own when a ticket stays unresolved, when the smoke test or the gate keeps failing, when a ticket keeps failing review, or when it reaches its phase limit.
|
|
287
290
|
|
|
288
291
|
## License
|
|
289
292
|
|
|
@@ -23,7 +23,7 @@ When `plans/<feature>/` does not exist, or you are planning from the conversatio
|
|
|
23
23
|
- `plan-review-prompt`: start a new reviewer the same way, with `task_name="plan_reviewer"` and `fork_turns="none"`, never with the planner's reply. Get its final reply with `wait_agent` and save it with the shell into `.feature-flow/state/plan-review-<feature>.txt`.
|
|
24
24
|
- `plan-accept`: writes `plans/<feature>/` from the draft, only after the second yes.
|
|
25
25
|
|
|
26
|
-
Then run `python3 scripts/flow-status.py <feature> --check
|
|
26
|
+
Then run `python3 scripts/flow-status.py <feature> --check` and list the files `plan-accept` wrote. The second yes also approves committing the plan, so commit the folder `plan-accept` printed (`OK <folder>`, `plans/<feature>` unless `FLOW_DIR` is set) and nothing else: `git add -- <folder> && git commit -m "docs(<feature>): plan" -- <folder>`. If the commit fails, report why and stop. Then go straight on to building it below, unless the user asked to stop after planning.
|
|
27
27
|
|
|
28
28
|
To add tickets to a plan that exists, edit it by hand following the ticket rules in `.feature-flow/guides/plan.md`.
|
|
29
29
|
|
|
@@ -41,7 +41,7 @@ If `start` prints `STOP` naming another owner, another session may still be work
|
|
|
41
41
|
|
|
42
42
|
If the user says the flow is stuck on a ticket or asks for a reset, run `reset` (or `reset <NN>` to redo one ticket): it reopens a half-built ticket, commits that, and clears the run, then run `start` again. It needs `FLOW_TAKEOVER=1` while a session owns the feature, on the same clear yes. Never reset on your own.
|
|
43
43
|
|
|
44
|
-
Say what will happen: for each ticket, a builder subagent and then a reviewer subagent. After every few tickets (`FLOW_TICKETS_PER_SESSION`, default 4) you hand off to a new session. Ask for a yes, unless `auto` was given.
|
|
44
|
+
Say what will happen: for each ticket, a builder subagent and then a reviewer subagent. After every few tickets (`FLOW_TICKETS_PER_SESSION`, default 4) you hand off to a new session. Ask for a yes, unless `auto` was given or you came straight from planning: the plan's yes already covered the build.
|
|
45
45
|
|
|
46
46
|
## The loop
|
|
47
47
|
|
|
@@ -62,7 +62,7 @@ A conductor command looks like this:
|
|
|
62
62
|
These apply from `start` on, once a plan exists.
|
|
63
63
|
|
|
64
64
|
- Never run the gate, the floor guard or a review yourself. The conductor runs the checks, and the reviewer subagent reviews.
|
|
65
|
-
- Never edit a ticket, the plan or the code, and never commit. The builder does that.
|
|
65
|
+
- Never edit a ticket, the plan or the code, and never commit, apart from the approved plan above. The builder does that.
|
|
66
66
|
- Never skip a step, reorder steps, or decide the next step yourself. Only `next` decides.
|
|
67
67
|
- Never read a ticket's `## Answer` into the reviewer's prompt. `prompt` already holds everything it needs.
|
|
68
68
|
- Tell the user one short line per phase (`01 built`, `01 review: PASS`), not the subagents' reports.
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
"""The checks the conductor runs
|
|
1
|
+
"""The checks the conductor runs, in process.
|
|
2
2
|
|
|
3
3
|
Each call lives in one function so plans/interactive-flow-python can swap it for an
|
|
4
4
|
in-process call without touching the conductor.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
|
-
import
|
|
8
|
+
import tempfile
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
+
from feature_flow import proc
|
|
11
12
|
from feature_flow import status
|
|
12
13
|
from feature_flow import gate as gate_module
|
|
13
14
|
from feature_flow import floorguard
|
|
@@ -36,12 +37,6 @@ class _Collect:
|
|
|
36
37
|
return "".join(self.parts)
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
def _bash(script, *args):
|
|
40
|
-
result = subprocess.run(["bash", str(script)] + [str(a) for a in args],
|
|
41
|
-
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
|
|
42
|
-
return Result(result.returncode, result.stdout)
|
|
43
|
-
|
|
44
|
-
|
|
45
40
|
def flow_status(scripts, feature, mode, root=None):
|
|
46
41
|
"""The ticket graph reader with --next, --counts or --check, in process. stderr is folded into the output.
|
|
47
42
|
root, when given, stands in for FLOW_DIR for this one call (the planner's draft folder)."""
|
|
@@ -60,11 +55,13 @@ def flow_status(scripts, feature, mode, root=None):
|
|
|
60
55
|
return Result(code, out.text())
|
|
61
56
|
|
|
62
57
|
|
|
63
|
-
def gate(scripts, feature):
|
|
64
|
-
"""The gate, run in process. stderr is folded into the output as the subprocess call did.
|
|
58
|
+
def gate(scripts, feature, timeout=None):
|
|
59
|
+
"""The gate, run in process. stderr is folded into the output as the subprocess call did.
|
|
60
|
+
timeout is minutes, None for FLOW_GATE_TIMEOUT."""
|
|
65
61
|
chunks = []
|
|
66
|
-
code = gate_module.run(str(feature), chunks.append, chunks.append)
|
|
67
|
-
out =
|
|
62
|
+
code = gate_module.run(str(feature), chunks.append, chunks.append, timeout)
|
|
63
|
+
out = "".join(chunks).encode("utf-8", "surrogateescape").decode("utf-8", "replace")
|
|
64
|
+
out = out.replace("\r\n", "\n").replace("\r", "\n")
|
|
68
65
|
return Result(code, out)
|
|
69
66
|
|
|
70
67
|
|
|
@@ -76,8 +73,13 @@ def floor_guard(scripts, feature, num, base):
|
|
|
76
73
|
return Result(code, out)
|
|
77
74
|
|
|
78
75
|
|
|
79
|
-
def smoke(command):
|
|
76
|
+
def smoke(command, timeout=None):
|
|
77
|
+
"""The smoke command. timeout is minutes, 0 or None for none. Result.timed_out is set when it ran out."""
|
|
80
78
|
args, use_shell = gate_module.shell(command)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
with tempfile.TemporaryFile() as log:
|
|
80
|
+
code, timed_out = proc.run(args, use_shell, log, timeout)
|
|
81
|
+
log.seek(0)
|
|
82
|
+
out = log.read().decode("utf-8", "replace").replace("\r\n", "\n")
|
|
83
|
+
result = Result(1 if timed_out else code, out)
|
|
84
|
+
result.timed_out = timed_out
|
|
85
|
+
return result
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A fingerprint of the code and prompt files a conductor run uses, so a change during a build shows."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from feature_flow import git, prompts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _files(scripts):
|
|
12
|
+
package = Path(__file__).resolve().parent
|
|
13
|
+
found = [p for p in package.rglob("*.py") if "__pycache__" not in p.parts]
|
|
14
|
+
found += Path(scripts).resolve().glob("*.py")
|
|
15
|
+
for phase in ("build", "review"):
|
|
16
|
+
for folder, names in (("agents", prompts.ROLES), ("guides", prompts.GUIDES)):
|
|
17
|
+
try:
|
|
18
|
+
found.append(prompts.find_file(scripts, folder, names[phase]).resolve())
|
|
19
|
+
except FileNotFoundError:
|
|
20
|
+
pass
|
|
21
|
+
return sorted(set(found))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _name(path, top):
|
|
25
|
+
try:
|
|
26
|
+
return path.relative_to(top).as_posix()
|
|
27
|
+
except ValueError:
|
|
28
|
+
return path.as_posix()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def flow_code(scripts):
|
|
32
|
+
"""(the sha256 over every file's path and bytes, {path relative to the repo: sha256 of the file})."""
|
|
33
|
+
top = git.toplevel()
|
|
34
|
+
top = top.resolve() if top is not None else Path(os.getcwd()).resolve()
|
|
35
|
+
each = {}
|
|
36
|
+
for path in _files(scripts):
|
|
37
|
+
try:
|
|
38
|
+
each[_name(path, top)] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
39
|
+
except OSError:
|
|
40
|
+
each[_name(path, top)] = ""
|
|
41
|
+
whole = hashlib.sha256()
|
|
42
|
+
for name in sorted(each):
|
|
43
|
+
whole.update(name.encode("utf-8") + b"\0" + each[name].encode("ascii") + b"\0")
|
|
44
|
+
return whole.hexdigest(), each
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def changed(before, now):
|
|
48
|
+
"""The names that differ between two {name: sha256} maps, sorted."""
|
|
49
|
+
return sorted(name for name in set(before) | set(now) if before.get(name) != now.get(name))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def dump(each):
|
|
53
|
+
return json.dumps(each, sort_keys=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load(text):
|
|
57
|
+
try:
|
|
58
|
+
value = json.loads(text)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return {}
|
|
61
|
+
return value if isinstance(value, dict) else {}
|
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from feature_flow import __version__, cli, install, status, suggest, view
|
|
13
13
|
|
|
14
14
|
USAGE = """\
|
|
15
|
-
usage: feature-flow install [target-repo] [--agent claude|codex|all] [--user] [--force] [--dry-run]
|
|
15
|
+
usage: feature-flow install [target-repo] [--agent claude|codex|all] [--user] [--private] [--force] [--dry-run]
|
|
16
16
|
feature-flow status <feature> [--next | --counts | --check | --mermaid [plain] | --json]
|
|
17
17
|
feature-flow view <feature> [--watch] [--no-open] [--out FILE]
|
|
18
18
|
feature-flow reset <feature> [NN]
|
|
@@ -10,7 +10,7 @@ import secrets
|
|
|
10
10
|
import shutil
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
from feature_flow import checks, git, prompts, state, suggest, tickets
|
|
13
|
+
from feature_flow import checks, codehash, floorguard, gate, git, prompts, state, suggest, tickets
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
VERDICT = re.compile(r"^REVIEW: (PASS|FAIL)[ \t\r\f\v]*$")
|
|
@@ -41,6 +41,7 @@ class Conductor:
|
|
|
41
41
|
self.commands = self.plan / "commands.md"
|
|
42
42
|
self.max_retries = _env_int("FLOW_MAX_RETRIES", 2)
|
|
43
43
|
self.max_rounds = _env_int("FLOW_MAX_REVIEW_ROUNDS", 3)
|
|
44
|
+
self.gate_timeout = _env_int("FLOW_GATE_TIMEOUT", 30)
|
|
44
45
|
self.gate_on = os.environ.get("FLOW_GATE", "on") != "off"
|
|
45
46
|
self.per_session = _env_int("FLOW_TICKETS_PER_SESSION", 4)
|
|
46
47
|
self.relay = os.environ.get("FLOW_RELAY", "0") == "1"
|
|
@@ -101,10 +102,17 @@ class Conductor:
|
|
|
101
102
|
cmd = self.smoke_command()
|
|
102
103
|
if not cmd:
|
|
103
104
|
return
|
|
104
|
-
result = checks.smoke(cmd)
|
|
105
|
+
result = checks.smoke(cmd, self.gate_timeout)
|
|
105
106
|
if not result.ok:
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
log = self.state_file.with_name("%s.smoke.log" % self.feature)
|
|
108
|
+
text = result.out
|
|
109
|
+
if result.timed_out:
|
|
110
|
+
text += "\nsmoke test timed out after %d minutes\n" % self.gate_timeout
|
|
111
|
+
log.write_text(gate.tail(text), encoding="utf-8")
|
|
112
|
+
raise Stop("smoke test %s before %s: the base is already broken. fix it first. command: %s. "
|
|
113
|
+
"the last %d lines are in %s"
|
|
114
|
+
% ("timed out after %d minutes" % self.gate_timeout if result.timed_out else "failed",
|
|
115
|
+
self.ticket_name(), cmd, gate.TAIL, log))
|
|
108
116
|
|
|
109
117
|
def run_limit(self):
|
|
110
118
|
result = checks.flow_status(self.scripts, self.feature, "--counts")
|
|
@@ -118,7 +126,7 @@ class Conductor:
|
|
|
118
126
|
runs = self.num("runs") + 1
|
|
119
127
|
limit = self.num("limit")
|
|
120
128
|
if runs > limit:
|
|
121
|
-
raise Stop("run limit of %d
|
|
129
|
+
raise Stop("run limit of %d phases reached, stopping" % limit)
|
|
122
130
|
self.st["runs"] = runs
|
|
123
131
|
|
|
124
132
|
# ---- phases --------------------------------------------------------
|
|
@@ -180,12 +188,41 @@ class Conductor:
|
|
|
180
188
|
if self.per_session > 0 and self.num("session_done") >= self.per_session:
|
|
181
189
|
return self.handoff()
|
|
182
190
|
path = nxt.out.strip().splitlines()[-1]
|
|
191
|
+
self.st["limit"] = self.run_limit()
|
|
183
192
|
self.st.update({"ticket": path, "num": tickets.number(path), "base": git.head(), "phase": "",
|
|
184
|
-
"attempt": 0, "review_attempt": 0, "round": 0, "review_sha": ""})
|
|
193
|
+
"attempt": 0, "review_attempt": 0, "round": 0, "review_sha": "", "guard_warning": ""})
|
|
194
|
+
self.snapshot_code()
|
|
185
195
|
self.run_smoke()
|
|
186
196
|
return self.hand_out_build()
|
|
187
197
|
|
|
198
|
+
def snapshot_code(self):
|
|
199
|
+
whole, each = codehash.flow_code(self.scripts)
|
|
200
|
+
self.st["code_sha"] = whole
|
|
201
|
+
self.st["code_files"] = codehash.dump(each)
|
|
202
|
+
|
|
203
|
+
def check_code(self, allowed=False):
|
|
204
|
+
"""Stop when the files the conductor runs from changed since the ticket was picked."""
|
|
205
|
+
whole, each = codehash.flow_code(self.scripts)
|
|
206
|
+
if whole == self.get("code_sha"):
|
|
207
|
+
return
|
|
208
|
+
if allowed:
|
|
209
|
+
self.snapshot_code()
|
|
210
|
+
return
|
|
211
|
+
names = codehash.changed(codehash.load(self.get("code_files")), each)
|
|
212
|
+
raise Stop("flow code changed while building %s: %s. if intended, the ticket needs a line: "
|
|
213
|
+
"Floor: allow flow-edit" % (self.ticket_name(), ", ".join(names)))
|
|
214
|
+
|
|
215
|
+
def flow_edit_allowed(self):
|
|
216
|
+
try:
|
|
217
|
+
source = git._git("show", "%s:%s" % (self.get("base"), Path(self.ticket()).as_posix()), check=False)
|
|
218
|
+
except OSError:
|
|
219
|
+
return False
|
|
220
|
+
if source.returncode != 0:
|
|
221
|
+
return False
|
|
222
|
+
return "flow-edit" in floorguard.allow_line(source.stdout).split()
|
|
223
|
+
|
|
188
224
|
def judge_build(self):
|
|
225
|
+
self.check_code(self.flow_edit_allowed())
|
|
189
226
|
status = tickets.status(self.ticket())
|
|
190
227
|
if status not in tickets.DONE:
|
|
191
228
|
if status in tickets.RESET:
|
|
@@ -196,7 +233,7 @@ class Conductor:
|
|
|
196
233
|
if not git.is_clean():
|
|
197
234
|
raise Stop("working tree is dirty after %s, it should have been committed" % self.ticket_name())
|
|
198
235
|
if self.gate_on:
|
|
199
|
-
result = checks.gate(self.scripts, self.feature)
|
|
236
|
+
result = checks.gate(self.scripts, self.feature, self.gate_timeout)
|
|
200
237
|
self.log("GATE-PASS" if result.ok else "GATE-FAIL")
|
|
201
238
|
if not result.ok:
|
|
202
239
|
return self.send_back("gate", result.out)
|
|
@@ -204,6 +241,8 @@ class Conductor:
|
|
|
204
241
|
self.log("GUARD-PASS" if result.ok else "GUARD-FAIL")
|
|
205
242
|
if not result.ok:
|
|
206
243
|
return self.send_back("floor guard", result.out)
|
|
244
|
+
warnings = [l for l in result.out.splitlines() if l.startswith("warning:")]
|
|
245
|
+
self.st["guard_warning"] = warnings[0] if warnings else ""
|
|
207
246
|
return self.hand_out_review()
|
|
208
247
|
|
|
209
248
|
# ---- commands ------------------------------------------------------
|
|
@@ -294,6 +333,7 @@ class Conductor:
|
|
|
294
333
|
return "OK %s. run %s %s" % (", ".join(done), self.invoke, self.feature)
|
|
295
334
|
|
|
296
335
|
def judge_review(self):
|
|
336
|
+
self.check_code()
|
|
297
337
|
if not git.tracked_clean() or git.head() != self.get("review_sha"):
|
|
298
338
|
raise Stop("the reviewer changed tracked files, which a reviewer must never do")
|
|
299
339
|
verdict = self.get("verdict")
|
|
@@ -318,7 +358,8 @@ class Conductor:
|
|
|
318
358
|
if phase not in ("build", "review"):
|
|
319
359
|
raise NoPhase("no BUILD or REVIEW is pending for %s" % self.feature)
|
|
320
360
|
try:
|
|
321
|
-
return prompts.build(phase, self.scripts, self.feature, self.ticket(), self.get("num"), self.get("base")
|
|
361
|
+
return prompts.build(phase, self.scripts, self.feature, self.ticket(), self.get("num"), self.get("base"),
|
|
362
|
+
self.get("guard_warning") if phase == "review" else "")
|
|
322
363
|
except FileNotFoundError as err:
|
|
323
364
|
raise Stop(str(err))
|
|
324
365
|
|