feature-flow-cli 0.1.0__tar.gz → 0.1.2__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 (57) hide show
  1. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/MANIFEST.in +1 -0
  2. {feature_flow_cli-0.1.0/feature_flow_cli.egg-info → feature_flow_cli-0.1.2}/PKG-INFO +21 -6
  3. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/README.md +20 -5
  4. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/adapters/codex/feature-flow/SKILL.md +13 -9
  5. feature_flow_cli-0.1.2/agents/feature-planner.md +33 -0
  6. feature_flow_cli-0.1.2/agents/plan-reviewer.md +16 -0
  7. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/__init__.py +1 -1
  8. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/checks.py +15 -3
  9. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/cli.py +19 -4
  10. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/conductor.py +75 -2
  11. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/git.py +28 -2
  12. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/install.py +32 -0
  13. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/prompts.py +28 -2
  14. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2/feature_flow_cli.egg-info}/PKG-INFO +21 -6
  15. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow_cli.egg-info/SOURCES.txt +4 -1
  16. feature_flow_cli-0.1.2/guides/brief.md +62 -0
  17. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/build.md +1 -1
  18. feature_flow_cli-0.1.2/guides/plan-review.md +29 -0
  19. feature_flow_cli-0.1.2/guides/plan.md +94 -0
  20. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/show.md +1 -1
  21. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/skills/feature-flow/SKILL.md +13 -9
  22. feature_flow_cli-0.1.0/guides/plan.md +0 -93
  23. feature_flow_cli-0.1.0/skills/.DS_Store +0 -0
  24. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/LICENSE +0 -0
  25. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/adapters/codex/feature-flow/agents/openai.yaml +0 -0
  26. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/agents/ticket-builder.md +0 -0
  27. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/agents/ticket-reviewer.md +0 -0
  28. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/__main__.py +0 -0
  29. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/command.py +0 -0
  30. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/floorguard.py +0 -0
  31. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/gate.py +0 -0
  32. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/state.py +0 -0
  33. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/status.py +0 -0
  34. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/tickets.py +0 -0
  35. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow/view.py +0 -0
  36. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow_cli.egg-info/dependency_links.txt +0 -0
  37. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow_cli.egg-info/entry_points.txt +0 -0
  38. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/feature_flow_cli.egg-info/top_level.txt +0 -0
  39. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/review.md +0 -0
  40. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/commands.md +0 -0
  41. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/learnings.md +0 -0
  42. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/map.md +0 -0
  43. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/spec.md +0 -0
  44. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/ticket.md +0 -0
  45. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/guides/templates/ui-mockup.md +0 -0
  46. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/install.py +0 -0
  47. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/install.sh +0 -0
  48. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/pyproject.toml +0 -0
  49. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/floor-guard.py +0 -0
  50. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/flow-status.py +0 -0
  51. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/flow-view.html +0 -0
  52. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/flow-view.py +0 -0
  53. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/flow.py +0 -0
  54. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/scripts/gate.py +0 -0
  55. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/setup.cfg +0 -0
  56. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/skills/architect-review/SKILL.md +0 -0
  57. {feature_flow_cli-0.1.0 → feature_flow_cli-0.1.2}/skills/automation-design/SKILL.md +0 -0
@@ -5,3 +5,4 @@ graft guides
5
5
  graft adapters/codex
6
6
  include scripts/*.py scripts/*.html
7
7
  include LICENSE README.md install.py install.sh
8
+ global-exclude .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feature-flow-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -45,7 +45,7 @@ python3 install.py /path/to/your/repo --agent all # both, side by side
45
45
  # `bash install.sh ...` still works on Linux and macOS: it only runs install.py
46
46
  ```
47
47
 
48
- Without a clone, once the package is on PyPI (it is not published yet), the same installer runs from it:
48
+ Without a clone, the same installer runs from [PyPI](https://pypi.org/project/feature-flow-cli/):
49
49
 
50
50
  ```bash
51
51
  uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run feature-flow-cli install ...
@@ -53,17 +53,22 @@ uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run fea
53
53
 
54
54
  The package puts a `feature-flow` command on your PATH with `install`, `status <feature>` and `view <feature>`. It installs the same files, byte for byte, as `install.py` from a clone.
55
55
 
56
+ The installer lists what it wrote in `.feature-flow/installed.txt`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes.
57
+
56
58
  Then, in your repo, in the agent:
57
59
 
58
60
  | | Claude Code | Codex |
59
61
  |---|---|---|
60
62
  | 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` |
61
64
  | Build its tickets (a plan exists) | `/feature-flow csv-export` | `$feature-flow csv-export` |
62
65
  | The same, asking nothing | `/feature-flow csv-export auto` | `$feature-flow csv-export auto` |
63
66
  | Watch the graph, animated | `/feature-flow csv-export show` | `$feature-flow csv-export show` |
64
67
 
65
68
  The same command plans when `plans/csv-export/` does not exist yet and builds when it does. Commit the plan before you build it.
66
69
 
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>/`.
71
+
67
72
  Try the graph first, with no setup and no agent: `bash examples/demo.sh --open`.
68
73
 
69
74
  ### What the installer does
@@ -112,7 +117,7 @@ The Claude Code skill is `skills/feature-flow/`. The Codex skill is written by h
112
117
 
113
118
  | Skill | What it does |
114
119
  |---|---|
115
- | `feature-flow` | **Plan:** writes `plans/<feature>/` with a spec, a map, commands, learnings and tickets, shows you the graph, and waits for a yes. **Build:** runs the tickets one by one, a builder and a reviewer subagent each, with `flow.py` deciding every step. **Show:** the animated ticket graph and a summary of what is ready and what blocks the finish. |
120
+ | `feature-flow` | **Plan:** turns the conversation (or your answers) into a feature brief and waits for a yes, has a planner and a plan reviewer subagent draft and check `plans/<feature>/` (a spec, a map, commands, learnings and tickets), shows you the graph, and writes it after a second yes. **Build:** runs the tickets one by one, a builder and a reviewer subagent each, with `flow.py` deciding every step. **Show:** the animated ticket graph and a summary of what is ready and what blocks the finish. |
116
121
  | `architect-review` | Architecture review of a file, diff or feature, with severity rated findings. Reads `CLAUDE.md` or `AGENTS.md` for the project's own rules. |
117
122
  | `automation-design` | Blueprint for an automation pipeline. Hands off to `feature-flow` for the plan. |
118
123
 
@@ -120,10 +125,12 @@ The Claude Code skill is `skills/feature-flow/`. The Codex skill is written by h
120
125
  |---|---|---|
121
126
  | `ticket-builder` | Read, Glob, Grep, Edit, Write, Bash | Builds one ticket by the build guide in its prompt. |
122
127
  | `ticket-reviewer` | Read, Glob, Grep, Bash (no Edit, no Write) | Reviews one ticket by the review guide in its prompt and ends with `REVIEW: PASS` or `REVIEW: FAIL`. |
128
+ | `feature-planner` | Read, Glob, Grep, Bash, Write, Edit, WebSearch, WebFetch | Plans one feature from the approved brief by the plan guide, writes only the draft, cites outside sources, and ends with `PLAN: READY` or `PLAN: QUESTIONS`. |
129
+ | `plan-reviewer` | Read, Glob, Grep, Bash (no Edit, no Write) | Checks the draft against the brief by the plan review guide and ends with `PLAN-REVIEW: PASS` or `PLAN-REVIEW: FAIL`. |
123
130
 
124
- In Claude Code the reviewer's tool list is enforced, so it cannot edit. A Codex subagent cannot be limited that way, so there the reviewer works from its instructions, and the conductor stops the run if it changed anything.
131
+ In Claude Code the reviewers' tool lists are enforced, so they cannot edit. A Codex subagent cannot be limited that way, so there the reviewers work from their instructions: the conductor stops the run if a ticket reviewer changed anything, and a planner's draft reaches `plans/` only through `plan-accept`. On Codex the planner can research the web only if web search is on; otherwise it plans from the code and says so. The Codex planning path has not been run by hand.
125
132
 
126
- The guides in `guides/` (installed to `.feature-flow/guides/`) hold the protocol: how to build, review, plan and show. The skill holds only what differs per agent.
133
+ The guides in `guides/` (installed to `.feature-flow/guides/`) hold the protocol: how to build, review, write the brief, plan, review a plan and show. The skill holds only what differs per agent.
127
134
 
128
135
  ## Plans and tickets
129
136
 
@@ -282,7 +289,15 @@ The installer never deletes the old skill folders, but it names any it finds. De
282
289
 
283
290
  ## Releasing to PyPI
284
291
 
285
- The package is `feature-flow-cli` (the shorter `feature-flow` is likely refused by PyPI as too close to the existing `featureflow`). Its version is `__version__` in `feature_flow/__init__.py`. To release: bump it, then `python3 -m build` and `python3 -m twine upload dist/*` with a PyPI API token, or publish from a GitHub Actions workflow set up as a PyPI trusted publisher. Nothing in this repo uploads on its own.
292
+ The package is [`feature-flow-cli`](https://pypi.org/project/feature-flow-cli/) (the shorter `feature-flow` is likely refused by PyPI as too close to the existing `featureflow`). Its version is `__version__` in `feature_flow/__init__.py`.
293
+
294
+ To release, bump `__version__`, merge it to `main`, then tag that commit and push the tag:
295
+
296
+ ```bash
297
+ git tag v0.1.1 && git push origin v0.1.1
298
+ ```
299
+
300
+ `.github/workflows/release.yml` checks that the tag matches `__version__`, builds the wheel and the sdist, runs `tests/package_smoke.py` on them and publishes them to PyPI. PyPI trusts the workflow as a trusted publisher (owner `hamilton-sky`, repository `feature-flow`, workflow `release.yml`, environment `pypi`), so no token is stored. PyPI never accepts the same version twice.
286
301
 
287
302
  ## Caution
288
303
 
@@ -26,7 +26,7 @@ python3 install.py /path/to/your/repo --agent all # both, side by side
26
26
  # `bash install.sh ...` still works on Linux and macOS: it only runs install.py
27
27
  ```
28
28
 
29
- Without a clone, once the package is on PyPI (it is not published yet), the same installer runs from it:
29
+ Without a clone, the same installer runs from [PyPI](https://pypi.org/project/feature-flow-cli/):
30
30
 
31
31
  ```bash
32
32
  uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run feature-flow-cli install ...
@@ -34,17 +34,22 @@ uvx feature-flow-cli install /path/to/your/repo --agent all # or: pipx run fea
34
34
 
35
35
  The package puts a `feature-flow` command on your PATH with `install`, `status <feature>` and `view <feature>`. It installs the same files, byte for byte, as `install.py` from a clone.
36
36
 
37
+ The installer lists what it wrote in `.feature-flow/installed.txt`. Commit those files (`git add --pathspec-from-file=.feature-flow/installed.txt`); until you do, the flow does not count them as uncommitted changes.
38
+
37
39
  Then, in your repo, in the agent:
38
40
 
39
41
  | | Claude Code | Codex |
40
42
  |---|---|---|
41
43
  | 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` |
42
45
  | Build its tickets (a plan exists) | `/feature-flow csv-export` | `$feature-flow csv-export` |
43
46
  | The same, asking nothing | `/feature-flow csv-export auto` | `$feature-flow csv-export auto` |
44
47
  | Watch the graph, animated | `/feature-flow csv-export show` | `$feature-flow csv-export show` |
45
48
 
46
49
  The same command plans when `plans/csv-export/` does not exist yet and builds when it does. Commit the plan before you build it.
47
50
 
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>/`.
52
+
48
53
  Try the graph first, with no setup and no agent: `bash examples/demo.sh --open`.
49
54
 
50
55
  ### What the installer does
@@ -93,7 +98,7 @@ The Claude Code skill is `skills/feature-flow/`. The Codex skill is written by h
93
98
 
94
99
  | Skill | What it does |
95
100
  |---|---|
96
- | `feature-flow` | **Plan:** writes `plans/<feature>/` with a spec, a map, commands, learnings and tickets, shows you the graph, and waits for a yes. **Build:** runs the tickets one by one, a builder and a reviewer subagent each, with `flow.py` deciding every step. **Show:** the animated ticket graph and a summary of what is ready and what blocks the finish. |
101
+ | `feature-flow` | **Plan:** turns the conversation (or your answers) into a feature brief and waits for a yes, has a planner and a plan reviewer subagent draft and check `plans/<feature>/` (a spec, a map, commands, learnings and tickets), shows you the graph, and writes it after a second yes. **Build:** runs the tickets one by one, a builder and a reviewer subagent each, with `flow.py` deciding every step. **Show:** the animated ticket graph and a summary of what is ready and what blocks the finish. |
97
102
  | `architect-review` | Architecture review of a file, diff or feature, with severity rated findings. Reads `CLAUDE.md` or `AGENTS.md` for the project's own rules. |
98
103
  | `automation-design` | Blueprint for an automation pipeline. Hands off to `feature-flow` for the plan. |
99
104
 
@@ -101,10 +106,12 @@ The Claude Code skill is `skills/feature-flow/`. The Codex skill is written by h
101
106
  |---|---|---|
102
107
  | `ticket-builder` | Read, Glob, Grep, Edit, Write, Bash | Builds one ticket by the build guide in its prompt. |
103
108
  | `ticket-reviewer` | Read, Glob, Grep, Bash (no Edit, no Write) | Reviews one ticket by the review guide in its prompt and ends with `REVIEW: PASS` or `REVIEW: FAIL`. |
109
+ | `feature-planner` | Read, Glob, Grep, Bash, Write, Edit, WebSearch, WebFetch | Plans one feature from the approved brief by the plan guide, writes only the draft, cites outside sources, and ends with `PLAN: READY` or `PLAN: QUESTIONS`. |
110
+ | `plan-reviewer` | Read, Glob, Grep, Bash (no Edit, no Write) | Checks the draft against the brief by the plan review guide and ends with `PLAN-REVIEW: PASS` or `PLAN-REVIEW: FAIL`. |
104
111
 
105
- In Claude Code the reviewer's tool list is enforced, so it cannot edit. A Codex subagent cannot be limited that way, so there the reviewer works from its instructions, and the conductor stops the run if it changed anything.
112
+ In Claude Code the reviewers' tool lists are enforced, so they cannot edit. A Codex subagent cannot be limited that way, so there the reviewers work from their instructions: the conductor stops the run if a ticket reviewer changed anything, and a planner's draft reaches `plans/` only through `plan-accept`. On Codex the planner can research the web only if web search is on; otherwise it plans from the code and says so. The Codex planning path has not been run by hand.
106
113
 
107
- The guides in `guides/` (installed to `.feature-flow/guides/`) hold the protocol: how to build, review, plan and show. The skill holds only what differs per agent.
114
+ The guides in `guides/` (installed to `.feature-flow/guides/`) hold the protocol: how to build, review, write the brief, plan, review a plan and show. The skill holds only what differs per agent.
108
115
 
109
116
  ## Plans and tickets
110
117
 
@@ -263,7 +270,15 @@ The installer never deletes the old skill folders, but it names any it finds. De
263
270
 
264
271
  ## Releasing to PyPI
265
272
 
266
- The package is `feature-flow-cli` (the shorter `feature-flow` is likely refused by PyPI as too close to the existing `featureflow`). Its version is `__version__` in `feature_flow/__init__.py`. To release: bump it, then `python3 -m build` and `python3 -m twine upload dist/*` with a PyPI API token, or publish from a GitHub Actions workflow set up as a PyPI trusted publisher. Nothing in this repo uploads on its own.
273
+ The package is [`feature-flow-cli`](https://pypi.org/project/feature-flow-cli/) (the shorter `feature-flow` is likely refused by PyPI as too close to the existing `featureflow`). Its version is `__version__` in `feature_flow/__init__.py`.
274
+
275
+ To release, bump `__version__`, merge it to `main`, then tag that commit and push the tag:
276
+
277
+ ```bash
278
+ git tag v0.1.1 && git push origin v0.1.1
279
+ ```
280
+
281
+ `.github/workflows/release.yml` checks that the tag matches `__version__`, builds the wheel and the sdist, runs `tests/package_smoke.py` on them and publishes them to PyPI. PyPI trusts the workflow as a trusted publisher (owner `hamilton-sky`, repository `feature-flow`, workflow `release.yml`, environment `pypi`), so no token is stored. PyPI never accepts the same version twice.
267
282
 
268
283
  ## Caution
269
284
 
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: feature-flow
3
- description: "Use to plan a feature or build its tickets, one fresh builder and one fresh reviewer subagent per ticket, with scripts/flow.py deciding every step. Also draws the ticket graph with show."
3
+ description: "Use to plan a feature (from the conversation so far, with a fresh planner and plan reviewer subagent) or build its tickets, one fresh builder and one fresh reviewer subagent per ticket, with scripts/flow.py deciding every step. Also draws the ticket graph with show."
4
4
  ---
5
5
 
6
6
  `<arguments>` below stands for the text the user typed after `$feature-flow`.
7
7
 
8
8
  Plan or build the feature in `<arguments>`.
9
9
 
10
- The first word is the **feature**. `show` means draw the graph. `auto` means ask nothing and go. If no feature was given, list `plans/*/` and ask which one.
10
+ The first word is the **feature**. `show` means draw the graph. `auto` means ask nothing and go. If no feature was given and the conversation so far describes work to build, plan it (below) and propose the feature's name in the brief. If no feature was given otherwise, list `plans/*/` and ask which one.
11
11
 
12
12
  The conductor is `python3 scripts/flow.py <feature> <command>`. It decides the order, runs the gate and the floor guard, and keeps its state in `.feature-flow/state/`, a folder git ignores. You ask it, and you do what it says. The guides it uses are in `.feature-flow/guides/` (in a feature-flow checkout, `guides/`). Read the project's `AGENTS.md` for its conventions.
13
13
 
@@ -17,19 +17,23 @@ With `show`, follow `.feature-flow/guides/show.md` for the feature and stop.
17
17
 
18
18
  ## Plan
19
19
 
20
- If `plans/<feature>/` does not exist, run `FLOW_INVOKE='$feature-flow' python3 scripts/flow.py <feature> start` and check that it prints `PLAN`. Then:
20
+ When `plans/<feature>/` does not exist, or you are planning from the conversation, follow `.feature-flow/guides/brief.md` with the user. You write the brief and ask for both yeses (unless `auto`); a child agent plans, another reviews, and you never draft the plan yourself. Once the brief names the feature, run `FLOW_INVOKE='$feature-flow' python3 scripts/flow.py <feature> start` and check that it prints `PLAN`, and check that `.agents/flow-roles/feature-planner.md` and `.agents/flow-roles/plan-reviewer.md` exist (see below).
21
21
 
22
- 1. Follow `.feature-flow/guides/plan.md` with the user.
23
- 2. Run `python3 scripts/flow-status.py <feature> --check` and fix every problem.
24
- 3. Stop. List the files you created and suggest the commit command (`git add plans/<feature> && git commit -m "docs(<feature>): plan"`). Say to commit the plan and run `$feature-flow <feature>` again. While the plan is uncommitted, do not say the feature is ready to build.
22
+ - `plan-prompt <brief>`: start the planner with `spawn_agent(task_name="feature_planner", fork_turns="none", message=...)`. The message is "Work only in <repo>." followed by the whole output. Get its final reply with `wait_agent`. A child cannot be limited to the draft folder, so it works from its instructions, and `plan-accept` is the only way its draft reaches `plans/`. If web search is off in this Codex, the planner plans from the codebase and says so.
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
+ - `plan-accept`: writes `plans/<feature>/` from the draft, only after the second yes.
25
+
26
+ Then run `python3 scripts/flow-status.py <feature> --check`, list the files `plan-accept` wrote, and suggest the commit command (`git add plans/<feature> && git commit -m "docs(<feature>): plan"`). Say to commit the plan and run `$feature-flow <feature>` again. While the plan is uncommitted, do not say the feature is ready to build.
27
+
28
+ To add tickets to a plan that exists, edit it by hand following the ticket rules in `.feature-flow/guides/plan.md`.
25
29
 
26
30
  ## Before building
27
31
 
28
32
  With a plan present, check these before `start`, and stop at the first that fails:
29
33
 
30
- - `git status --porcelain` is empty.
34
+ - `git status --porcelain` is empty, apart from untracked files listed in `.feature-flow/installed.txt`: those are feature-flow's own install, so never stop for them. Suggest committing them (`git add --pathspec-from-file=.feature-flow/installed.txt && git commit -m "chore: install feature-flow"`) and carry on.
31
35
  - `python3 scripts/flow-status.py <feature> --check` prints `OK`.
32
- - `.agents/flow-roles/ticket-builder.md` and `.agents/flow-roles/ticket-reviewer.md` exist. If not, say to run `bash install.sh --agent codex` from feature-flow.
36
+ - `.agents/flow-roles/ticket-builder.md` and `.agents/flow-roles/ticket-reviewer.md` exist. If not, say to run `uvx feature-flow-cli install . --agent codex` in this repo (or `python3 install.py <repo> --agent codex` from a feature-flow clone).
33
37
 
34
38
  Then run `FLOW_INVOKE='$feature-flow' python3 scripts/flow.py <feature> start`. It prints `OK <token>`. Keep the token and put `FLOW_SESSION=<token>` in front of **every** later conductor command, with `FLOW_INVOKE='$feature-flow'`. The conductor keeps its state in `.feature-flow/state/`, a git-ignored folder in the repo, so it never needs to write `.git`. If it prints `STOP cannot write the flow state`, tell the user this session must be allowed to write that folder.
35
39
 
@@ -53,7 +57,7 @@ A conductor command looks like this:
53
57
 
54
58
  ## Rules while building
55
59
 
56
- These apply from `start` on, once a plan exists. Planning (above) writes the plan files itself.
60
+ These apply from `start` on, once a plan exists.
57
61
 
58
62
  - Never run the gate, the floor guard or a review yourself. The conductor runs the checks, and the reviewer subagent reviews.
59
63
  - Never edit a ticket, the plan or the code, and never commit. The builder does that.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: feature-planner
3
+ description: Plans one feature from an approved feature brief by following the plan guide in its prompt. Writes a draft plan into the flow's state folder and nothing else. Use it from /feature-flow, in a fresh session that never saw the conversation.
4
+ tools:
5
+ - Read
6
+ - Glob
7
+ - Grep
8
+ - Bash
9
+ - Write
10
+ - Edit
11
+ - WebSearch
12
+ - WebFetch
13
+ model: inherit
14
+ ---
15
+
16
+ You are the planner. You turn one feature brief into a draft plan, then stop.
17
+
18
+ Follow the plan guide in your prompt exactly. It tells you what to read, how to research, how to cut the graph into tickets, where to write, and how to reply.
19
+
20
+ How you think, in this order:
21
+
22
+ - The brief is the whole ask. You never saw the conversation behind it. Plan what the brief says; a gap goes under Open questions or into a `settle` ticket, never into a silent guess.
23
+ - Read before you plan: the project's conventions, similar features, the files that will change, and the real build, test and lint commands from the project's own config. Never guess a command.
24
+ - Look outside the repo only where the code cannot answer: a library's documentation for the version the lockfile pins, an outside API the brief names. Every outside fact gets its source in `map.md`. If you cannot reach the web, say so in your reply and plan from the codebase.
25
+ - At every real fork, write two options in a sentence each, pick one and say why. If the choice belongs to the user, make it a `settle` ticket instead of picking.
26
+ - Be lazy: drop every ticket the bar does not need, and list what you dropped.
27
+ - Prove the draft is well formed with the check the guide names, and fix every problem.
28
+
29
+ Rules that never bend:
30
+
31
+ - Write and edit only inside the draft folder your prompt names. Never change a file outside it, never write under `plans/`, never commit.
32
+ - You cannot talk to the user. Anything only the user can decide comes back in your reply as an open question.
33
+ - End your reply with exactly `PLAN: READY` or `PLAN: QUESTIONS`.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: plan-reviewer
3
+ description: Independently checks a draft plan against the approved feature brief by following the plan review guide in its prompt. Read only. Use it in a fresh session so it never saw the planner's reasoning.
4
+ tools:
5
+ - Read
6
+ - Glob
7
+ - Grep
8
+ - Bash
9
+ model: inherit
10
+ ---
11
+
12
+ You are the plan reviewer, not the planner. You did not write this plan and you do not trust the planner's account of it.
13
+
14
+ Follow the plan review guide in your prompt exactly. Read the brief and the draft, run the commands the draft relies on, and end with the single line `PLAN-REVIEW: PASS` or `PLAN-REVIEW: FAIL`.
15
+
16
+ You cannot edit, write, stage or commit anything, and you must not try to get around that with shell commands. You may run read only commands, the plan check, and the commands the draft's `commands.md` lists.
@@ -1,3 +1,3 @@
1
1
  """Feature Flow: a ticket-graph workflow for coding agents."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -4,6 +4,7 @@ Each call lives in one function so plans/interactive-flow-python can swap it for
4
4
  in-process call without touching the conductor.
5
5
  """
6
6
 
7
+ import os
7
8
  import subprocess
8
9
  from pathlib import Path
9
10
 
@@ -41,10 +42,21 @@ def _bash(script, *args):
41
42
  return Result(result.returncode, result.stdout)
42
43
 
43
44
 
44
- def flow_status(scripts, feature, mode):
45
- """The ticket graph reader with --next, --counts or --check, in process. stderr is folded into the output."""
45
+ def flow_status(scripts, feature, mode, root=None):
46
+ """The ticket graph reader with --next, --counts or --check, in process. stderr is folded into the output.
47
+ root, when given, stands in for FLOW_DIR for this one call (the planner's draft folder)."""
46
48
  out = _Collect()
47
- code = status.run([feature, mode], out, out)
49
+ saved = os.environ.get("FLOW_DIR")
50
+ if root is not None:
51
+ os.environ["FLOW_DIR"] = str(root)
52
+ try:
53
+ code = status.run([feature, mode], out, out)
54
+ finally:
55
+ if root is not None:
56
+ if saved is None:
57
+ del os.environ["FLOW_DIR"]
58
+ else:
59
+ os.environ["FLOW_DIR"] = saved
48
60
  return Result(code, out.text())
49
61
 
50
62
 
@@ -5,8 +5,10 @@ from pathlib import Path
5
5
 
6
6
  from feature_flow.conductor import Conductor, NoPhase, Stop
7
7
 
8
- USAGE = "usage: python3 scripts/flow.py <feature> start | next | prompt | verdict <file>"
9
- COMMANDS = ("start", "next", "prompt", "verdict")
8
+ USAGE = ("usage: python3 scripts/flow.py <feature> start | next | prompt | verdict <file>\n"
9
+ " python3 scripts/flow.py <feature> plan-prompt <brief> [findings] | plan-review-prompt | plan-accept")
10
+ COMMANDS = ("start", "next", "prompt", "verdict", "plan-prompt", "plan-review-prompt", "plan-accept")
11
+ ARGS = {"verdict": (3,), "plan-prompt": (3, 4)}
10
12
 
11
13
 
12
14
  def main(argv=None, scripts=None):
@@ -15,7 +17,10 @@ def main(argv=None, scripts=None):
15
17
  print(USAGE, file=sys.stderr)
16
18
  return 2
17
19
  feature, command = args[0], args[1]
18
- if (command == "verdict") != (len(args) == 3) or len(args) > 3:
20
+ if feature in (".", "..") or any(c in feature for c in "/\\:"):
21
+ print("the feature must be a plain folder name, not %s" % feature, file=sys.stderr)
22
+ return 2
23
+ if len(args) not in ARGS.get(command, (2,)):
19
24
  print(USAGE, file=sys.stderr)
20
25
  return 2
21
26
  scripts = Path(scripts) if scripts else Path.cwd() / "scripts"
@@ -24,6 +29,12 @@ def main(argv=None, scripts=None):
24
29
  conductor = Conductor(feature, scripts)
25
30
  if command == "verdict":
26
31
  line = conductor.verdict(args[2])
32
+ elif command == "plan-prompt":
33
+ line = conductor.plan_prompt(args[2], args[3] if len(args) == 4 else None)
34
+ elif command == "plan-review-prompt":
35
+ line = conductor.plan_review_prompt()
36
+ elif command == "plan-accept":
37
+ line = conductor.plan_accept()
27
38
  elif command == "prompt":
28
39
  line = conductor.prompt()
29
40
  elif command == "start":
@@ -39,7 +50,11 @@ def main(argv=None, scripts=None):
39
50
  conductor.log("STOP")
40
51
  print(line)
41
52
  return 1
42
- print(line, end="" if line.endswith("\n") else "\n")
53
+ out = sys.stdout
54
+ if command in ("plan-prompt", "plan-review-prompt") and hasattr(out, "reconfigure"):
55
+ # the plan guides hold non-ASCII text, which a Windows console code page cannot print
56
+ out.reconfigure(encoding="utf-8")
57
+ print(line, end="" if line.endswith("\n") else "\n", file=out)
43
58
  return 0
44
59
 
45
60
 
@@ -7,6 +7,7 @@ caller, and prints exactly one line per command.
7
7
  import os
8
8
  import re
9
9
  import secrets
10
+ import shutil
10
11
  from pathlib import Path
11
12
 
12
13
  from feature_flow import checks, git, prompts, state, tickets
@@ -53,6 +54,9 @@ class Conductor:
53
54
  except OSError as err:
54
55
  raise Stop("cannot write the flow state in %s: %s. this session must be allowed to write there"
55
56
  % (top / state.STATE_DIR, err.strerror or err))
57
+ self.draft_root = state.STATE_DIR / "draft"
58
+ self.draft = self.draft_root / feature
59
+ self.brief_file = state.STATE_DIR / ("brief-%s.md" % feature)
56
60
  self.state_file = state.state_path(folder, feature)
57
61
  self.log_file = state.log_path(folder, feature)
58
62
  self.findings_file = state.file_path(folder, feature, "findings")
@@ -157,8 +161,10 @@ class Conductor:
157
161
  check = checks.flow_status(self.scripts, self.feature, "--check")
158
162
  if not check.ok:
159
163
  raise Stop("ticket check failed, fix the tickets first")
160
- if not git.is_clean():
161
- raise Stop("working tree is not clean, commit or stash first")
164
+ dirty = git.changes()
165
+ if dirty:
166
+ raise Stop("working tree is not clean, commit or stash first: %s%s"
167
+ % (" ".join(dirty[:5]), " and %d more" % (len(dirty) - 5) if len(dirty) > 5 else ""))
162
168
  nxt = checks.flow_status(self.scripts, self.feature, "--next")
163
169
  if nxt.code == 10:
164
170
  done = self.num("done")
@@ -297,3 +303,70 @@ class Conductor:
297
303
  self.save()
298
304
  self.log("VERDICT-%s" % (found or "none").upper())
299
305
  return "OK" if found else "RETRY no review verdict"
306
+
307
+ # ---- planning: stateless, the brief and the draft are all there is --
308
+
309
+ def no_plan_yet(self):
310
+ if self.plan.is_dir():
311
+ raise Stop("%s already exists. pick another name: a plan is never overwritten" % self.plan.as_posix())
312
+
313
+ def draft_text(self):
314
+ return self.draft.as_posix() + "/"
315
+
316
+ def read_text(self, path, what):
317
+ try:
318
+ return Path(path).read_text(encoding="utf-8", errors="replace")
319
+ except OSError as err:
320
+ raise Stop("cannot read the %s %s: %s" % (what, path, err.strerror))
321
+
322
+ def plan_prompt(self, brief, findings=None):
323
+ """The feature-planner's prompt. Saves the brief; a first round (no findings) starts an empty draft."""
324
+ self.no_plan_yet()
325
+ text = self.read_text(brief, "brief")
326
+ notes = self.read_text(findings, "review findings") if findings else ""
327
+ if not text.strip():
328
+ raise Stop("the brief %s is empty" % brief)
329
+ if Path(brief).resolve() != self.brief_file.resolve():
330
+ self.brief_file.write_text(text, encoding="utf-8")
331
+ if not findings and self.draft.is_dir():
332
+ if self.draft.resolve().parent != self.draft_root.resolve():
333
+ raise Stop("the draft folder %s is outside %s" % (self.draft_text(), self.draft_root.as_posix()))
334
+ shutil.rmtree(str(self.draft))
335
+ (self.draft / (os.environ.get("FLOW_TICKETS") or "tasks")).mkdir(parents=True, exist_ok=True)
336
+ self.log("PLAN-PROMPT")
337
+ try:
338
+ return prompts.plan("plan", self.scripts, self.feature, self.draft_text(), text, notes)
339
+ except FileNotFoundError as err:
340
+ raise Stop(str(err))
341
+
342
+ def plan_review_prompt(self):
343
+ """The plan-reviewer's prompt: the saved brief and the draft, nothing from the planner."""
344
+ self.no_plan_yet()
345
+ if not self.brief_file.is_file():
346
+ raise Stop("no brief for %s. run plan-prompt first" % self.feature)
347
+ if not self.draft.is_dir():
348
+ raise Stop("no draft plan in %s. run the planner first" % self.draft_text())
349
+ self.log("PLAN-REVIEW-PROMPT")
350
+ try:
351
+ return prompts.plan("plan-review", self.scripts, self.feature, self.draft_text(),
352
+ self.read_text(self.brief_file, "brief"))
353
+ except FileNotFoundError as err:
354
+ raise Stop(str(err))
355
+
356
+ def plan_accept(self):
357
+ """Copy a draft that passes the check into the plan folder. Never overwrites, never commits."""
358
+ self.no_plan_yet()
359
+ if not self.draft.is_dir():
360
+ raise Stop("no draft plan in %s. run the planner first" % self.draft_text())
361
+ check = checks.flow_status(self.scripts, self.feature, "--check", root=self.draft_root)
362
+ if not check.ok:
363
+ raise Stop("the draft fails the plan check, fix it first: %s"
364
+ % " ".join(check.out.split())[:400])
365
+ self.plan.parent.mkdir(parents=True, exist_ok=True)
366
+ shutil.copytree(str(self.draft), str(self.plan))
367
+ check = checks.flow_status(self.scripts, self.feature, "--check")
368
+ if not check.ok:
369
+ raise Stop("the accepted plan in %s fails the plan check: %s"
370
+ % (self.plan.as_posix(), " ".join(check.out.split())[:400]))
371
+ self.log("PLAN-ACCEPT")
372
+ return "OK %s" % self.plan.as_posix()
@@ -30,9 +30,35 @@ def head():
30
30
  return _git("rev-parse", "HEAD").stdout.strip()
31
31
 
32
32
 
33
+ def changes():
34
+ """Every changed path, untracked files included, as `git status --porcelain` sees them, except
35
+ the untracked files feature-flow's installer wrote: an install not committed yet is not a change."""
36
+ installed = set()
37
+ top = toplevel()
38
+ if top is not None:
39
+ try:
40
+ installed = set((top / ".feature-flow" / "installed.txt").read_text(encoding="utf-8").splitlines())
41
+ except (OSError, UnicodeDecodeError):
42
+ pass
43
+ entries = _git("status", "--porcelain", "-z", "-uall").stdout.split("\0")
44
+ found = []
45
+ i = 0
46
+ while i < len(entries):
47
+ entry = entries[i]
48
+ i += 1
49
+ if len(entry) < 4:
50
+ continue
51
+ code, path = entry[:2], entry[3:]
52
+ if code[0] in "RC":
53
+ i += 1 # the rename's source path follows
54
+ if code == "??" and path in installed:
55
+ continue
56
+ found.append(path)
57
+ return found
58
+
59
+
33
60
  def is_clean():
34
- """No change at all, untracked files included, as `git status --porcelain` sees it."""
35
- return _git("status", "--porcelain").stdout.strip() == ""
61
+ return not changes()
36
62
 
37
63
 
38
64
  def tracked_clean():
@@ -39,6 +39,9 @@ BUNDLE = "_bundle"
39
39
 
40
40
  # the skills this installs
41
41
  SKILLS = ("feature-flow", "architect-review", "automation-design")
42
+ # the in-repo files an install wrote, one path per line, relative to the target. The conductor does not
43
+ # count them as a dirty tree while they are untracked, and `git add --pathspec-from-file` commits them.
44
+ INSTALLED = ".feature-flow/installed.txt"
42
45
 
43
46
  # an old skill that runs the ticket script (the bash one or the Python one) or ends a review with a verdict
44
47
  LEFTOVER = re.compile(rb"scripts/flow-status\.(?:sh|py)|REVIEW: PASS")
@@ -169,6 +172,7 @@ class Installer:
169
172
  package = os.path.dirname(os.path.abspath(__file__))
170
173
  self.package = package
171
174
  self.added = self.updated = self.same = self.kept = 0
175
+ self.written = []
172
176
 
173
177
  def place(self, file, dest, data=None):
174
178
  """One file: add it, replace it (--force), leave it alone (same) or keep the user's version.
@@ -177,14 +181,17 @@ class Installer:
177
181
  if not os.path.exists(dest):
178
182
  self.out(" add " + dest)
179
183
  self.added += 1
184
+ self.written.append(dest)
180
185
  if not self.dry:
181
186
  os.makedirs(os.path.dirname(dest), exist_ok=True)
182
187
  self._copy(file, dest, data)
183
188
  elif new is not None and _read(dest) == new:
184
189
  self.same += 1
190
+ self.written.append(dest)
185
191
  elif self.force:
186
192
  self.out(" update " + dest)
187
193
  self.updated += 1
194
+ self.written.append(dest)
188
195
  if not self.dry:
189
196
  self._copy(file, dest, data)
190
197
  else:
@@ -235,6 +242,23 @@ class Installer:
235
242
  self.place(file, self.target + "/.agents/flow-roles/" + name,
236
243
  role_body(data if data is not None else b""))
237
244
 
245
+ def write_installed(self):
246
+ """Add this run's in-repo files to the list, keeping the earlier runs' files that still exist."""
247
+ path = self.target + "/" + INSTALLED
248
+ names = set()
249
+ try:
250
+ with open(path, encoding="utf-8") as old:
251
+ names.update(line.strip() for line in old)
252
+ except OSError:
253
+ pass
254
+ names = set(n for n in names if n and os.path.isfile(self.target + "/" + n))
255
+ prefix = self.target + "/"
256
+ names.update(dest[len(prefix):] for dest in self.written if dest.startswith(prefix))
257
+ names.add(INSTALLED)
258
+ os.makedirs(os.path.dirname(path), exist_ok=True)
259
+ with open(path, "w", encoding="utf-8", newline="\n") as new:
260
+ new.write("".join(n + "\n" for n in sorted(names)))
261
+
238
262
  def report_leftovers(self, dirs):
239
263
  """A skill folder this installer does not own but that drives the flow (it runs the ticket
240
264
  scripts or ends a review with a verdict) is left from an earlier version: named once, never deleted."""
@@ -270,6 +294,8 @@ class Installer:
270
294
  self.copy_tree(here + "/guides", self.target + "/.feature-flow/guides")
271
295
  self.copy_tree(here + "/agents", self.target + "/.feature-flow/agents")
272
296
  self.copy_tree(self.package, self.target + "/.feature-flow/feature_flow", skip=BUNDLE)
297
+ if not self.dry:
298
+ self.write_installed()
273
299
 
274
300
  self.out("")
275
301
  verb = "would add" if self.dry else "added"
@@ -285,6 +311,12 @@ class Installer:
285
311
  git = 127
286
312
  if git != 0:
287
313
  self.out("note: %s is not a git repository, and the flow needs one" % self.target)
314
+ elif self.added or self.updated:
315
+ git_c = ""
316
+ if os.path.realpath(self.target) != os.path.realpath(os.getcwd()):
317
+ git_c = '-C "%s" ' % self.target if " " in self.target else "-C %s " % self.target
318
+ self.out('next: commit the installed files: git %sadd --pathspec-from-file=%s'
319
+ ' && git %scommit -m "chore: install feature-flow"' % (git_c, INSTALLED, git_c))
288
320
  if not shutil.which("python3"):
289
321
  self.out("note: install python3 (3.9 or later): scripts/flow.py needs it")
290
322
  if agent in ("claude", "all"):
@@ -5,8 +5,9 @@ The text is runtime neutral: any agent that can read a prompt can follow it.
5
5
 
6
6
  from pathlib import Path
7
7
 
8
- ROLES = {"build": "ticket-builder.md", "review": "ticket-reviewer.md"}
9
- GUIDES = {"build": "build.md", "review": "review.md"}
8
+ ROLES = {"build": "ticket-builder.md", "review": "ticket-reviewer.md",
9
+ "plan": "feature-planner.md", "plan-review": "plan-reviewer.md"}
10
+ GUIDES = {"build": "build.md", "review": "review.md", "plan": "plan.md", "plan-review": "plan-review.md"}
10
11
 
11
12
 
12
13
  def find_file(scripts, folder, name):
@@ -50,3 +51,28 @@ def build(phase, scripts, feature, ticket, num, sha):
50
51
  "with exactly `REVIEW: PASS` or `REVIEW: FAIL`." % (num, feature, feature, num, sha, ticket, sha, fresh))
51
52
  intro = "The review guide follows. Follow it exactly."
52
53
  return "\n\n".join([role, "---", intro, guide, "---", task]) + "\n"
54
+
55
+
56
+ def plan(phase, scripts, feature, draft, brief, findings=""):
57
+ """The prompt for the feature-planner (phase "plan") or the plan-reviewer ("plan-review").
58
+
59
+ Both get the approved brief and the draft folder, never the conversation or each other's reasoning.
60
+ """
61
+ role = _body(find_file(scripts, "agents", ROLES[phase]))
62
+ guide = _body(find_file(scripts, "guides", GUIDES[phase]))
63
+ guide = guide.replace("<feature>", feature).replace("<draft>", draft)
64
+ if phase == "plan":
65
+ task = ("Your task: plan the feature `%s` from the approved brief below. Write the draft only inside "
66
+ "`%s`, laid out as the plan guide says. Do not write anywhere else and do not commit. Your final "
67
+ "reply must end with exactly `PLAN: READY` or `PLAN: QUESTIONS`." % (feature, draft))
68
+ intro = "The plan guide follows. Follow it exactly."
69
+ else:
70
+ task = ("Your task: review the draft plan for the feature `%s` in `%s` against the approved brief below. "
71
+ "Do not edit any file or create a commit. Your final reply must end with exactly "
72
+ "`PLAN-REVIEW: PASS` or `PLAN-REVIEW: FAIL`." % (feature, draft))
73
+ intro = "The plan review guide follows. Follow it exactly."
74
+ parts = [role, "---", intro, guide, "---", task, "## The brief", brief.strip("\n")]
75
+ if findings.strip():
76
+ parts += ["## Review findings from the last round", "The draft is already in `%s`. Fix each finding in it, "
77
+ "or say in your reply why it stays." % draft, findings.strip("\n")]
78
+ return "\n\n".join(parts) + "\n"