bin/spec-pickup.py — render a spec as a self-contained agent brief
Header
Use the pencil to edit title, status, priority, and owner. Changing status auto-prepends a changelog entry.
Why
The spec system already stores everything an executor needs — acceptance
criteria, success determiner, plan, dependencies, tasks — but in a
structured shape, not as a prose prompt. Today, queueing work for an
agent means hand-writing a self-contained brief: copy the spec content,
add ambient repo context (CLAUDE.md, conventions, recent commits), spell
out guardrails. bin/spec-pickup.py mechanizes that: one command, one
spec ID (or no ID for "what's next?"), one prompt ready to pipe intospawn_task, the chip UI, or any other agent harness.
User stories
As a maintainer, I want to run `bin/spec-pickup.py` with no args and see what's executable next so that I don't grep through 60 YAMLs to find specs whose dependencies are met.
As a maintainer queuing a batch, I want a single command that turns N spec IDs into N agent briefs in dependency order so that I can hand them off to a remote agent runner without writing prose by hand.
As a coding agent invoked with a brief, I want the spec content plus its dependencies' current status plus repo-wide context so that I can execute autonomously without round-tripping for clarification.
Acceptance criteria (EARS)
- When `bin/spec-pickup.py <id>` is invoked with a valid spec ID, the system shall print a self-contained Markdown brief to stdout including the spec's `why`, `acceptance_criteria`, `success_determiner`, `plan`, `tasks`, `out_of_scope`, dependencies (each annotated with its current status), and a working-rules section.
- When `bin/spec-pickup.py` is invoked with no arguments, the system shall print every spec whose status is `planned` AND every spec-ID dependency is `verified` or `shipped` AND `clarifications_needed` is empty, sorted by priority (`p0` first, then `p1`, `p2`, then null) then alphabetically by id.
- Where the requested spec has a non-empty `clarifications_needed` list, the system shall print the spec ID and its open questions and exit with a non-zero status — refusing to render a brief that papers over the gaps.
- When `bin/spec-pickup.py --queue <id1> <id2> ...` is invoked, the system shall topologically sort the supplied IDs by `dependencies` (ignoring file-path deps that don't reference other specs) and print one brief per ID in execution order, separated by `\n---\n` delimiters.
- When the rendered brief includes the `success_determiner`, it shall reproduce all kind-specific fields (`command`/`expect` for `bash`, `path`/`runner` for `test_file`, `query`/`expect` for `sql`, `checklist` for `manual`, `source`/`expect` for `expected_output`) so an executor can run it directly without re-reading the YAML.
- Where a spec's declared dependency is in status `draft` or `blocked` or doesn't exist, the brief shall flag the dependency at the top of the output as a blocker — the spec is not actually executable yet.
- When `bin/spec-pickup.py --help` is invoked, the system shall print one-line descriptions of all three modes plus a copy-paste example of each (e.g., `bin/spec-pickup.py embedding-worker | spawn-task`). The module-level docstring at the top of the file shall describe the tool's role in the workflow (renders agent briefs from spec YAML), the three modes, and the `clarifications_needed` gate behavior.
- When this spec flips to `shipped`, the same change shall add a one-line entry to `CLAUDE.md`'s Quick Reference section (alongside `bin/build-spec.py`, `bin/dry-run-spec.py`, `bin/sync-nav.py`) and shall add a sentence to `https://docs.usejosh.com/operations/spec-workflow/` describing it as the canonical brief-rendering step when queueing work for agents.
Success determiner
Command
set -euo pipefail
# 1. Single-spec mode renders a brief with the expected sections.
out=$(uv run python bin/spec-pickup.py embedding-worker)
for section in "## Why" "## Acceptance criteria" "## Success determiner" \
"## Plan" "## Dependencies" "## Working rules"; do
echo "$out" | grep -qF "$section" \
|| { echo "FAIL: brief missing section: $section" >&2; exit 1; }
done
# 2. No-arg mode prints something (at least one executable spec or an
# explicit "nothing executable" line).
uv run python bin/spec-pickup.py >/dev/null
# 3. clarifications_needed gate exits non-zero when invoked on a spec
# with open questions. We craft a minimal fixture by reading any
# spec that genuinely has clarifications_needed populated; if none
# do, this assertion is skipped.
blocked_id=$(uv run python -c "
import yaml, pathlib
for p in sorted(pathlib.Path('docs/spec/data').glob('*.yaml')):
d = yaml.safe_load(p.read_text())
if d and d.get('clarifications_needed'):
print(d['id']); break
")
if [ -n "$blocked_id" ]; then
if uv run python bin/spec-pickup.py "$blocked_id" >/dev/null 2>&1; then
echo "FAIL: expected non-zero exit on spec with clarifications_needed" >&2
exit 1
fi
fi
# 4. --queue mode emits delimited briefs.
uv run python bin/spec-pickup.py --queue embedding-worker embedding-jobs-schema \
| grep -qE '^---$' \
|| { echo "FAIL: --queue mode missing delimiter" >&2; exit 1; }
echo OK
Expect
Runs from repo root. Doesn't depend on a deployed substrate — only reads YAML files. Uses `embedding-worker` and `embedding-jobs-schema` as known-verified anchor specs; if those are renamed in the future, update the determiner.
Clarifications needed
None.
Out of scope
- Auto-spawning agents — the caller pipes the output to spawn_task, gh, or whatever harness fits.
- Updating spec status — this is a read-only tool; status flips happen via the in-browser editor or hand-edit + bin/build-spec.py.
- Cross-repo or remote-spec rendering.
- Caching — specs are small (~60 of them, ~3KB each); a full re-read per invocation is fast enough and avoids stale-cache bugs.
- Validating the spec schema — `bin/build-spec.py --validate` already does that. spec-pickup.py trusts that input is schema-valid.
Dependencies
None.
Plan
Single Python file at bin/spec-pickup.py. Standard library pluspyyaml (already a dev dep). ~200 lines. Three operating modes
selected by argparse:
1. No args — list executable specs.
2. Single spec ID (bin/spec-pickup.py <id>) — render one brief.
3. --queue <id1> <id2> ... — render N briefs in dependency order.
Brief structure (Markdown):
```
# <spec.title>
Spec: <id> · Status: <status> · Priority: <priority> · Category: <category>
> Blocked dependencies (if any): the spec is not actually
> executable until these flip to verified/shipped:
> - <dep-id> (status <status>)
## Why
<spec.why>
## Acceptance criteria
- <each EARS-form criterion>
## Success determiner
Kind: <kind>
<kind-specific fields rendered as fenced code where appropriate>
## Plan
<spec.plan as Markdown>
## Tasks
- [<x or ' '>] <text>
## Dependencies
- <dep-id> — <status> (✓ if verified/shipped, ⚠ otherwise)
## Out of scope
- <each entry>
## Ambient repo context
- Read CLAUDE.md for repo-wide conventions.
- Read https://docs.usejosh.com/operations/conventions/ for the conventions doc.
- Recent commits (last 5): <git log --oneline -5 piped in>
## Working rules
- Surgical changes — touch only what this spec requires.
- Commit style: short title, prose body, Co-Authored-By: Claude...
trailer (match recent commits).
- Verify with uv run poe ci after every commit. Pre-push hook also
runs it.
- Don't touch: .kamal/secrets.age.
- When done: re-run bin/dry-run-spec.py <id> --explain to confirm
the determiner aligns with the new status.
```
Executable-specs filter (no-arg mode):
````
status == 'planned'
AND every dep matching ^[a-z][a-z0-9-]*$ resolves to a spec with
status in {'verified', 'shipped'}
AND not clarifications_needed
File-path dependencies are ignored for this check (they're scaffolding
hints, not buildable predecessors).
Topological sort (--queue mode): Kahn's algorithm restricted to
the supplied IDs. Cross-cutting dependencies on specs OUTSIDE the
queue are checked for status — if any are unmet, surface as a warning
on the affected spec's brief.
Module structure inside the file:
- load_specs() → dict[str, dict] keyed by id.
- render_brief(spec, all_specs) -> str — pure function.
- executable_specs(all_specs) -> list[str] — pure function.
- topological_sort(ids, all_specs) -> list[str].
- main() — argparse + dispatch.
Self-documentation requirements:
- Module docstring at the top of the file. Three paragraphs:
1. What the tool does (renders specs as agent briefs) and where it
fits in the workflow (between spec authoring and agent spawning).
2. The three modes with one-line descriptions.
3. The clarifications_needed gate — why it exists, how to handle
it (populate the field or remove the spec from your queue).
- argparse help text covering all three modes. Each subcommand or
flag should have a help= string AND a runnable example in the
epilog= block. Example shape:
```
Usage: bin/spec-pickup.py [-h] [--queue ID [ID ...]] [SPEC_ID]
Render a spec as a self-contained agent brief.
Modes:
(no args) List specs that are ready to execute.
SPEC_ID Render one brief for the given spec.
--queue ID [ID ...] Render multiple briefs in dependency order.
Examples:
bin/spec-pickup.py
bin/spec-pickup.py embedding-worker
bin/spec-pickup.py embedding-worker | pbcopy
bin/spec-pickup.py --queue spec-pickup ci-foundation
```
- Function docstrings on every non-trivial helper (render_brief,
executable_specs, topological_sort).
- Discoverability via CLAUDE.md. When the spec flips to shipped,
add a line to the Quick Reference section. The line is one sentence,
matching the existing pattern (e.g., bin/spec-pickup.py # render a).
spec as an agent brief; --queue for a batch
- Discoverability via spec-workflow doc. https://docs.usejosh.com/operations/spec-workflow/
currently describes how a spec moves through statuses. Add a new
section or appendix called "Queueing work for an agent" that points
at spec-pickup.py as the canonical step.
Tasks
0 of 12 done.
- t1 argparse skeleton with three modes (no-arg, single-id, --queue)
- t2 load_specs() reads docs/spec/data/*.yaml into a dict keyed by id
- t3 render_brief() emits the Markdown shape described in the plan
- t4 Dependency-status annotation (✓/⚠) plus blocked-deps callout at the top
- t5 executable_specs() filter for no-arg mode (status + deps + clarifications)
- t6 topological_sort() for --queue mode (Kahn's, ignore file-path deps)
- t7 clarifications_needed gate — print + exit non-zero, no brief
- t8 Embed CLAUDE.md location + recent-commits snippet via subprocess to git
- t9 Smoke determiner exercises all three modes plus the gate
- t10 Module docstring (3 paragraphs: role / modes / gate) + argparse help with copy-paste examples
- t11 Function docstrings on render_brief, executable_specs, topological_sort
- t12 On flip to shipped: add Quick Reference line in CLAUDE.md + 'Queueing work for an agent' section in https://docs.usejosh.com/operations/spec-workflow/
Changelog
No history yet.