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
81 changes: 81 additions & 0 deletions .claude/skills/implement-a-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: implement-a-workflow
description: Implement or modify the step bodies of a workflow-workbench GraphSpec. Use whenever asked to implement a step, fill in a strategy, add an arm, or make coherence_check() pass on a design in this repo.
---

# Implementing a workflow

You are filling in **implementations**. You are not designing the workflow.

## The division of labour, and it is the point

| | owns | |
|---|---|---|
| the human | the **declaration** — `nodes`, `edges`, `VariableSpec`, `problem` | ~15 lines they can read |
| you | the **step bodies** | however long they need to be |
| `coherence_check()` | the contract between the two | run it; it is your acceptance test |

**Do not edit the declaration to make a check pass.** That is the one move that defeats the
whole arrangement. If a check is red because the design is wrong, say so and ask — do not widen
the contract until the red goes away.

## Start by reading the design, not the code

```python
spec = TheDesign()
spec.coherence_check() # what is wrong right now
spec.diagram() # the shape, as mermaid
```

Then read each `StepSpec.problem` you are implementing. **That is your brief** — it states what
makes the stage hard, deliberately not what to do. An empty `problem` means nobody wrote one; it
does **not** mean the stage is trivial.

## Your acceptance test

```python
from workflow_workbench import blocking

findings = spec.coherence_check(strategy)
blocking(findings) # empty == render() will succeed
```

**Branch on the fields, never on the sentence.** Every finding is a `CoherenceFinding`:

```python
f.check # which check produced it, e.g. 'check_bindings'
f.about # the node name; 'source->target' for an edge; '' for the whole design
f.blocking # False only for a `NOT CHECKED — …` stated gap
```

⛔ **A `NOT CHECKED` finding is not a pass.** It means a check looked and could not reach a
verdict — usually a missing return annotation. Fix the cause rather than filtering the line out.

## Rules that will save you a wasted round

- **Bind every step, including ones you did not change.** A partial strategy is refused on
purpose, so that "what varies between these arms" is answerable without reading both files.
- **A step body takes exactly one positional argument (`ctx`)** and returns the type its role
declares. `check_implementations` and `check_variable_types` both check this.
- **Annotate your return type.** Without it `check_variable_types` reports `NOT CHECKED` and
nothing verifies you produced what the role promised.
- **A step receives one value.** If you need to combine two arrivals, that is a `JoinSpec` and
it is a change to the declaration — ask.
- **A fan-out must reach a join.** Otherwise all but one result is silently discarded.

## Before you report done

```bash
uv run pytest -q
uv run python3 -m workflow_workbench.reference --check
```

Then say which findings remain and why, including the `NOT CHECKED` ones. **Do not report a
design as clean while a stated gap is open** — say what was not checked.

## When the right answer is "this should not be a node"

A stage worth declaring is one where **two competent people would implement it differently**. If
a stage is deterministic one-liner logic with a right answer you could look up, it does not need
a role, a strategy or a battle — say so rather than building scaffolding around it.
`examples/greeting.py` is deliberately below that bar; `examples/contestable.py` is above it.
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,73 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.0] — 2026-10-01

### ⛔ Breaking — `check()` is renamed to `coherence_check()`

**Step-by-step upgrade: [`docs/migration-0.3.md`](docs/migration-0.3.md).** One line:

```python
spec.check(strategy) # 0.2.0
spec.coherence_check(strategy) # 0.3.0
```

No alias. A missed call site is an `AttributeError` at the call, not a silent change of
behaviour — same choice 0.2.0 made when `NodeSpec(...)` became a `TypeError`.

**Why.** `check()` did not say what it checks, and the type it returns says `Coherence` — a word
that appeared nowhere else in the API. One concept was wearing two names, which is the thing the
house naming rule exists to prevent. `coherence_check()` grounds it.

`design_check()` was considered and rejected: `spec` *is* the design, so `spec.design_check()`
restates its own receiver.

**Unchanged:** the existing `check_*` functions, and the `check` field on a finding — both name
an individual check, which is what they still are.

**Added, and it is the twelfth:** `check_recursion`. The recursive-subgraph rule was enforced
inside `graph_spec.py`, so the generated rules table could not see it and said 11 while the code
enforced 12. The rule moved into `checks.py` and is exported; the CALL SITE did not move, because
a cycle has to stop the walk rather than be reported and walked into.

### Added — `coherence_check()` returns `CoherenceFinding`, not a bare `str`

**Backward compatible. No call site needs editing** — `CoherenceFinding` is a `str` subclass, so
`"x" in f`, `f.startswith(...)`, `"\n".join(findings)`, `f == "the message"`, sorting, hashing
and `repr()` in a printed list all behave exactly as before. Verified byte-for-byte across all 64
findings the test designs produce: nothing in the text moved.

```python
f = spec.coherence_check(strategy)[0]
f.check # 'check_bindings' — the function that produced it
f.about # 'compose' — a node name; 'source->target' for an edge; '' for the whole design
f.blocking # True — False only for a `NOT CHECKED — …` stated gap

from workflow_workbench import blocking
blocking(findings) # the filter `render()` uses; replaces startswith("NOT CHECKED")
```

**Why.** The findings were sentences, so the structure a caller needs was encoded in the prose.
`[f for f in findings if not f.startswith("NOT CHECKED")]` was load-bearing control flow in three
production call sites here and in both downstream repos — two different kinds of finding wearing
one type, told apart by a prefix match. `.claude/rules/checks.md`: *NOT CHECKED and 0 FOUND must
never render the same.* An agent using `coherence_check()` as its acceptance test could only regex it.

A frozen dataclass is tidier and costs a second breaking migration one release after `StepSpec`;
that is why the subclass wins. `blocking` is a bool rather than a severity enum — two states, and
no third has been observed.

- `CoherenceFinding`, `blocking()` and `NOT_CHECKED` are exported from the package root.
- `coherence_check()` and every `check_*` function are now annotated `list[CoherenceFinding]`.

### Upgrading

**The `check()` → `coherence_check()` rename above is breaking** — see the step above, not this
paragraph. What needs nothing is the FINDING REPRESENTATION: `CoherenceFinding` is a `str`
subclass, so every existing `f.startswith(...)`, `"\n".join(findings)` and `f == msg` keeps
working untouched. `uv lock --upgrade-package workflow-workbench` when you want the fields;
until then a pinned consumer is unaffected by either change.

## [0.2.0] — 2026-09-30

### ⛔ Breaking — `NodeSpec` is renamed to `StepSpec`
Expand Down
Loading
Loading