Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions .flowr/flows/deliver-flow.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
flow: deliver-flow
version: 1.3.0
version: 1.5.0
exits:
- delivered
states:
- id: merge
attrs:
description: >
Squash-merge the feature commits into dev with the full suite and
whole-suite stubtest green on dev.
Squash-merge the feature branch into dev with the full suite and
whole-suite stubtest green on dev, then delete the branch. Publishing
is optional and on stakeholder approval.
dispatch_to: release-engineer
git branch: dev
skills: [merge-to-dev]
Expand All @@ -20,9 +21,12 @@ states:
tests-green-on-dev: "==true"
stubtest-clean-on-dev: "==true"
next:
merged:
approved:
to: publish
when: merged
declined:
to: refresh
when: merged

- id: publish
attrs:
Expand All @@ -36,4 +40,49 @@ states:
output artifacts:
- "release"
next:
published: delivered
published: refresh

- id: refresh
attrs:
description: >
Close the cycle: regenerate the living specification (docs/state.md)
from the current tests, summarize the carry-over cache files to ~1000
words so the next cycle starts compact, regenerate docstrings for the
full source surface, then strip all .py.
dispatch_to: system-architect
git branch: dev
skills: [refresh-cycle]
input artifacts:
- "dev"
- "<package>/**/*.pyi"
- "<package>/**/*.py"
- "tests/integration/**/*_test.pyi"
- "tests/integration/**/*_test.py"
- "tests/e2e/**/*_test.pyi"
- "tests/e2e/**/*_test.py"
- "tests/cassettes/**"
- "migrations/**"
- "docs/glossary.md"
- ".cache/<session_id>/interview-notes.md"
- ".cache/<session_id>/external-contracts.md"
- ".cache/<session_id>/data-model.md"
- ".cache/<session_id>/journal.md"
output artifacts:
- "docs/state.md"
- ".cache/<session_id>/interview-notes.md"
- ".cache/<session_id>/external-contracts.md"
- ".cache/<session_id>/data-model.md"
- ".cache/<session_id>/journal.md"
- "<package>/**/*.py"
- "tests/integration/**/*_test.py"
- "tests/e2e/**/*_test.py"
conditions:
refreshed:
spec-regenerated: "==true"
cache-summarized: "==true"
docstrings-regenerated: "==true"
docstrings-stripped: "==true"
next:
refreshed:
to: delivered
when: refreshed
5 changes: 3 additions & 2 deletions .flowr/flows/pipeline-flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ states:
- id: deliver
attrs:
description: >
Squash-merge the feature commits into dev with the full suite green,
then publish (notes / PR / tag).
Squash-merge the feature branch into dev with the full suite green and
delete it; optionally publish (notes / PR / tag) on approval, then
refresh closes the cycle.
flow: deliver-flow
flow-version: "^1"
next:
Expand Down
12 changes: 6 additions & 6 deletions .flowr/flows/tdd-flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ states:
Pull the next source contract to build from the pending backlog,
respecting outside-in layer order.
dispatch_to: product-owner
git branch: feature
git branch: feature/<session_id>
skills: [select-build-target]
input artifacts:
- "<package>/**/*.pyi"
Expand All @@ -28,7 +28,7 @@ states:
Confirm the target contract's tests fail for the right reason
(ImportError for new work, assertion failure for rework).
dispatch_to: software-engineer
git branch: feature
git branch: feature/<session_id>
skills: [confirm-red-failure]
input artifacts:
- ".cache/<session_id>/build-target.md"
Expand All @@ -54,7 +54,7 @@ states:
its fixed .pyi; emit migrations/fixtures as needed.
dispatch_to: software-engineer
specialists: [data-architect, integration-engineer, ux-engineer, security-engineer]
git branch: feature
git branch: feature/<session_id>
skills: [implement-from-stub]
input artifacts:
- ".cache/<session_id>/build-target.md"
Expand All @@ -80,7 +80,7 @@ states:
Improve the source structure under green tests with the .pyi and tests
fixed.
dispatch_to: software-engineer
git branch: feature
git branch: feature/<session_id>
skills: [refactor-green]
input artifacts:
- "<package>/**/*.pyi"
Expand All @@ -98,7 +98,7 @@ states:
quality, drift, and green tests.
dispatch_to: reviewer
specialists: [security-engineer]
git branch: feature
git branch: feature/<session_id>
skills: [review-implementation]
input artifacts:
- "<package>/**/*.pyi"
Expand Down Expand Up @@ -126,7 +126,7 @@ states:
Commit the unit as one logical change with a descriptive imperative
message.
dispatch_to: software-engineer
git branch: feature
git branch: feature/<session_id>
skills: [ship-unit]
input artifacts:
- "<package>/**/*.py"
Expand Down
9 changes: 9 additions & 0 deletions .opencode/knowledge/methodology/separation-of-concerns.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ A gate `conditions` key (e.g. `stubtest-clean=true`) is EVIDENCE the dispatched

When a state exits on `reveals-gap` / `needs-capture` / `needs-elicitation`, its finding must reach the phase it re-enters. The finding rides two carriers: the discovering state **appends** it to `.cache/<session_id>/journal.md` (its `output artifacts`), and the re-dispatch prompt carries it verbatim. The receiving state **reads** `journal.md` on entry (its `input artifacts`) and re-derives the detail from the artifacts — the tests, source stubs, and cassettes that *are* the spec. The journal is a transient per-session safety net against context lost mid-escalation (e.g. a compress between the discovering state and re-entry); it is not a second spec and never duplicates what the tests express. One stable file absorbs every escalation — present and future — so new edges never spawn new artifact types.

### Rework trigger sources

The `@pytest.mark.pending` backlog is fed by two distinct trigger sources, read at `author-test-stubs` and `write-test-py`:

- **Build escalation** — a contract gap discovered mid-build is appended to `.cache/<session_id>/journal.md` (the carrier above) and re-enters plan as rework on the named contract.
- **Discovery flag** — a finding in `.cache/<session_id>/interview-notes.md` that implies *modifying* an existing block (flagged `rework — modifies existing <block>` at `interview-building-blocks`) re-enters plan as rework on that block's contracts.

Both surface as `@pytest.mark.pending` so `select-build-target` pulls them from the queue. The journal carries build-phase escalations; the interview notes carry requirements-level modification flags — two carriers, one backlog, no third artifact type. A discovery finding that adds a *new* block is new work, not rework, and authors a fresh stub without the pending marker.

### Loading model

The orchestrator reads the flow once per state and dispatches; it does not load the methodology layer up front. An agent loads when its role is dispatched; a skill loads when the state's `skills` names it; knowledge loads when a wikilink resolves. No session pays for the whole layer.
Expand Down
55 changes: 7 additions & 48 deletions .opencode/knowledge/software-craft/docstring-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,59 +1,18 @@
---
domain: software-craft
tags: [docstrings, lint, lifecycle, token-economy, format]
last-updated: 2026-07-02
tags: [docstrings, lint, lifecycle]
last-updated: 2026-07-08
---

# Docstring and Lint Lifecycle

## Key Takeaways

- Docstrings are human-language duplicates of code; during active development they cost tokens on every read and drift on every edit, so source `.py` is kept **naked** across red/green/refactor/review/ship and regenerated at deliver/merge from the stable code.
- Tests and stubs carry no docstrings, ever — tests ARE the spec (their names and bodies document behaviour) and PEP 484 stubs are signature-only (a docstring in a `.pyi` is forbidden — PYI021).
- Models add docstrings by training instinct; instruction alone does not suppress it. The `select` state enforces nakedness **mechanically** by running `scripts/strip_docstrings.py` over the target source `.py` at cycle entry, removing any docstring carried over from the last merge — the script is the backstop, not the prompt.
- Lint splits the same way: **bug-catchers run throughout** (the dev `ruff` select); restructuring lint (`SIM`, `RUF`) and `ruff format` run **at merge** via `task lint-merge`, so a dev edit is not churned by readability reordering mid-cycle.
- ruff itself cannot ban or remove docstrings (no rule flags a docstring's *presence* in `.py`; `PYI021` is `.pyi`-only), which is why the strip is a script and the docstring ban is not encoded as a ruff rule.

## Concepts

**Naked during dev, dressed at merge.** A docstring restates the code beneath it in prose. While the code is being written and rewritten — the whole of red/green/refactor/review — that prose is a stale duplicate dragging on every read (more tokens) and every edit (a code change forces a duplicate change, or the duplicate drifts). The phased answer is to keep source `.py` docstring-free for the entire build cycle and generate the docstrings once, at deliver/merge, from code that has stabilised. The duplication tax is paid once per cycle at merge, not continuously through dev. Tests and stubs are excluded: a test's documentation IS its body (editing the body edits the spec), and a stub is a PEP 484 signature (`...` only; a docstring there is a PYI021 violation).

**The instinct is real; the script is the backstop.** Models are trained on docstring-rich code and reach for them by reflex — strong enough that an instruction ("do not write docstrings during build") leaks. The mechanical defence runs at `select`: the strip script walks the target `.py`'s AST, finds every docstring node, and deletes its line range, surgically, without reformatting the surrounding code. Whatever the instinct slipped in is gone before red runs. The strip runs at cycle entry, not on every commit, because the source `.py` is re-read and re-edited through the cycle and a single entry-point strip keeps it naked for the duration.

**Lint splits dev from merge.** The same churn argument applies to lint that restructures for readability — `SIM` collapsing a nested `with`, `ruff format` reordering imports and whitespace. Mid-cycle, a format pass inflates the diff with non-semantic changes and fights the edit in progress. The dev `ruff` select is bug-catchers only (`A`, `ASYNC`, `B`, `C9`, `DTZ`, `ERA`, `F`, `G`, `LOG`, `PYI`, `S`) — `PYI` stays because it scopes to `.pyi` and enforces the naked-stub rule without touching `.py`. The restructure set (`SIM`, `RUF`) plus `ruff format` move to `task lint-merge`, run by `merge-to-dev` after the docstrings are generated, so dev stays quiet and the merged result is clean.

**Why ruff cannot do this.** ruff's `D`/pydocstyle rules either *require* docstrings (D100–D107) or police their *style* (D200+); none flags a docstring's *presence* as a violation, and ruff has no autofix that strips docstrings from `.py` (the project verified this at the start — `PYI021` strips them only in `.pyi`). So "no docstrings during dev" is not expressible as a ruff rule; the AST strip script is the substitute, and the `D` rules are not used at all (the merge step *generates* docstrings; it does not *enforce* their presence via lint).

## Content

### The lifecycle

| Phase | Source `.py` docstrings | Tests / stubs | Lint |
|---|---|---|---|
| plan (author/review/write/derive/simulate) | n/a (no `.py` yet) | naked (permanent) | dev select, `.pyi` via `PYI` |
| build (select/red/green/refactor/review/ship) | **naked** — `select` strips the target | naked | dev select only |
| deliver (merge) | **generated** for the shipped surface | naked | `task lint-merge` (add `SIM`, `RUF`, `format`) |

### The strip mechanic

`scripts/strip_docstrings.py <file>` parses the file, collects the `[lineno, end_lineno]` of every module/class/function docstring, and deletes those line ranges from the bottom up (so earlier line numbers stay valid). Every other line — formatting, comments, blanks — is preserved; the script does not reformat. It assumes a body is not docstring-only (true for real rework `.py`); it runs at `select` over the chosen target source `.py`, so red/green/refactor operate on naked code for the whole cycle.

### The merge generate step

`merge-to-dev`, after the squash-merge, regenerates docstrings for the public surface of every contract shipped this cycle (modules, classes, public functions and methods). The prose is faithful to the code, never a mechanical restatement of the signature; the *why* of an architectural decision lives in an ADR, not a docstring. Then `task lint-merge` runs (`ruff check --extend-select SIM,RUF .` plus `ruff format .`) and the result is committed on dev. The next cycle's `select` strips these docstrings from whatever file it touches next — they are a derived, regenerable view, never a carried artifact.

### The lint split

| Set | Where | Rules |
|---|---|---|
| dev (always) | plan-simulate gate; build gates; `ruff check .` | `A`, `ASYNC`, `B`, `C9`, `DTZ`, `ERA`, `F`, `G`, `LOG`, `PYI`, `S` |
| merge (deliver only) | `task lint-merge` | add `SIM`, `RUF`; then `ruff format .` |

`PYI` stays in the dev set because it applies only to `.pyi` and enforces the naked-stub rule (PYI021); it does not churn `.py`. `SIM` is deferred because it restructures (the nested-`with` collapse that once forced a mid-build test edit is the canonical case); `RUF` is mixed and deferred with it for simplicity.
- Source `.py` is kept **docstring-free** during dev; docstrings are **generated** at merge from stable code, then stripped. Source only — never tests (the body is the spec) or stubs (`PYI021`).
- Lint splits: **bug-catchers run throughout** — the dev `ruff` select is `A ASYNC B C9 DTZ ERA F G LOG PYI S`; restructure (`SIM`, `RUF`) and `ruff format` run at merge via `task lint-merge`.
- ruff cannot ban a docstring's *presence* in `.py` (no rule flags it; `PYI021` is `.pyi`-only); `scripts/strip_docstrings.py` is the mechanical substitute.

## Related

- [[software-craft/tdd]] — the red/green/refactor cycle that runs on naked code
- [[software-craft/code-review]] — review gates the cycle output; the docstring state at review is naked
- [[software-craft/source-stubs]] — stubs are signature-only (PYI021); the docstring ban there is permanent
- [[software-craft/git-conventions]] — the squash-merge at which docstrings are regenerated
- [[software-craft/tdd]] — the cycle runs on docstring-free code
- [[software-craft/git-conventions]] — the merge at which docstrings regenerate
15 changes: 8 additions & 7 deletions .opencode/knowledge/software-craft/git-conventions.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
domain: software-craft
tags: [git, commits, conventional-commits, branching, squash-merge]
last-updated: 2026-07-01
last-updated: 2026-07-08
---

# Git Conventions
Expand All @@ -11,7 +11,7 @@ last-updated: 2026-07-01
- Commits follow **Conventional Commits**: `<type>(<scope>): <imperative description>` — types `feat`, `fix`, `test`, `refactor`, `chore`, `docs`, `ci`; no `wip`, `temp`, or untyped commit (Conventional Commits 1.0.0).
- **One logical change per commit** — `ship-unit` commits the built contract's `.py` plus the structural artifacts it required as one change, and the `.pyi` is unchanged (contracts are fixed during build).
- **Refactor commits are separate from feature commits**; a structural change is never mixed with a behaviour addition, so history stays bisectable and every commit leaves the tests green.
- The branch model is **`feature` → `dev` → `release`/`main`**: the build cycle runs on `feature`, squash-merges accumulate on `dev`, and publish targets `release` or `main`. Feature branches are short-lived and deleted after their squash-merge.
- The branch model is **`feature/<session_id>` → `dev` → `release`/`main`**: the build cycle runs on a session-specific feature branch, squash-merges accumulate on `dev`, and publish targets `release` or `main`. Feature branches are short-lived and deleted after their squash-merge. The contract surface (tests + source stubs) is committed to `dev` at plan, so the feature branch carries only the source implementation — contracts consolidated in dev don't diverge across branches.
- `merge-to-dev` squash-merges the feature commits into `dev` as one commit, then verifies the whole suite and the whole-suite stubtest are clean — no pending markers remain, and no drift was smuggled in by the batch.

## Concepts
Expand All @@ -22,18 +22,18 @@ last-updated: 2026-07-01

**Refactor separate from feature.** A behaviour addition and a structural cleanup are different changes even when they land together, so they are different commits. Mixing them forces a reader (or a `git bisect`) to attribute a test failure to two unrelated changes at once; keeping them apart lets each commit stand as one defensible step, and every commit along the way leaves the tests green.

**The three-branch model.** Work flows in one direction: a `feature` branch carries a build cycle (red through ship), `dev` accumulates squash-merged contracts and is where integration is verified, and `release` or `main` is the publish target. Feature branches are short-lived — created for a contract, deleted after the squash-merge — so only `dev` and the publish branch are long-lived.
**The three-branch model.** Work flows in one direction: a session-specific `feature/<session_id>` branch carries one build cycle (red through ship), `dev` accumulates squash-merged contracts and is where integration is verified, and `release` or `main` is the publish target. The contract surface — test stubs, test bodies, and source stubs — is committed to `dev` at plan; the feature branch is cut from dev at build entry and carries only the source implementation. Keeping contracts in dev (not on feature branches) is deliberate: feature branches that hold their own contracts diverge, and reconciling them is cheaper to avoid than to do. Feature branches are short-lived — created at build entry off dev, deleted after the squash-merge — so only `dev` and the publish branch are long-lived.

**Squash-merge into dev.** The granular per-contract commits on `feature` collapse into one commit on `dev`, carrying the contract's summary in its message. The merge is the gate where the whole suite and the whole-suite stubtest run: by now every `.pyi` has its `.py`, every pending marker is gone, and any drift the per-cycle scope hid is exposed across the full set.
**Squash-merge into dev.** The granular per-contract commits on `feature/<session_id>` collapse into one commit on `dev`, carrying the contract's summary in its message. The merge is the gate where the whole suite and the whole-suite stubtest run: by now every `.pyi` has its `.py`, every pending marker is gone, and any drift the per-cycle scope hid is exposed across the full set. The branch is deleted after the merge.

## Content

### Branch model

| Branch | Lives | Carries |
|---|---|---|
| `feature` | short — one build cycle | the per-contract commits (red → green → refactor → ship) |
| `dev` | long | squash-merged contracts; integration truth |
| `feature/<session_id>` | short — one build cycle | the source implementation commits (red → green → refactor → ship) |
| `dev` | long | the contract surface (committed at plan) + squash-merged source; integration truth |
| `release` / `main` | long | the publish target |

### Commit format
Expand Down Expand Up @@ -64,9 +64,10 @@ last-updated: 2026-07-01

| Step | Check |
|---|---|
| squash `feature` → `dev` | one commit, contract summary in the message |
| squash `feature/<session_id>` → `dev` | one commit, contract summary in the message |
| run the full suite | every test green; no pending markers remain |
| run whole-suite `stubtest <package> tests` | every source and test pair agrees — no batch drift |
| delete `feature/<session_id>` | no dangling branches |

## Related

Expand Down
Loading
Loading