From 8495094826ee55848f9fe2847e6bd1c07fac8f9d Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sat, 28 Mar 2026 23:48:25 -0700 Subject: [PATCH 01/25] docs(planning): add trycycle refinement convergence plan - add a complete implementation plan for converging Atelier planning with trycycle mechanics and prose - define refinement contracts, invariants, file-level decomposition, and task-by-task execution/test strategy - lock user decisions on opt-in refinement, viral lineage, approval gates, and default round budgets --- ...6-03-28-trycycle-refinement-convergence.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/plans/2026-03-28-trycycle-refinement-convergence.md diff --git a/docs/plans/2026-03-28-trycycle-refinement-convergence.md b/docs/plans/2026-03-28-trycycle-refinement-convergence.md new file mode 100644 index 00000000..d9fc2bda --- /dev/null +++ b/docs/plans/2026-03-28-trycycle-refinement-convergence.md @@ -0,0 +1,634 @@ +# Trycycle Refinement Convergence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Converge Atelier planning with trycycle by extracting both trycycle's +iterative refinement mechanics and its planning prose/tone into Atelier-native +`planning` + `refine-plan` skills, with persisted refinement evidence, +lineage-based propagation, and fail-closed worker claim gates. + +**Architecture:** Build a first-class planning doctrine skill (`planning`) from +Atelier planner guidance plus trycycle planning language, then layer a separate +`refine-plan` orchestration skill that adapts trycycle's stateless plan-edit +loop into Atelier's bead persistence model. Treat refinement as explicit policy +(`project` or `per-item`), persist it as structured bead-note artifacts, and +enforce it at worker-claim boundaries so workers cannot execute refined work +without approval and a converged plan verdict. + +**Tech Stack:** Python 3.11+, Typer CLI/runtime modules, Atelier store/Beads +APIs, projected skill runtime scripts, pytest, markdown docs and templates. + +--- + +## Locked requirements from user decisions + +1. Refinement is opt-in per epic/changeset and may be enabled at any lifecycle + point. +1. Default budgets match trycycle (`plan-edit rounds=5`, + `post-implementation review/fix rounds=8`). +1. Refinement is viral by lineage. Descendants of refined work are refined. +1. Worker overscope breakdown uses refinement iff the source lineage is refined. +1. Refinement requires explicit approval, either from project policy or + per-item request. +1. Convergence must include trycycle planning prose/style, not just mechanics. + +## User-visible behavior after cutover + +1. Planner has a reusable `planning` skill that defines planning doctrine for + all agents, not only planner template prose. +1. When operator asks for a "refined" or "refinement" plan, planner runs + `refine-plan` iterative subagent evaluation before promotion/dispatch. +1. Refined work beads carry auditable refinement artifacts in notes with round + counts, verdicts, approval evidence, and artifact pointers. +1. Worker claim fails closed for refined work when required approval or + converged verdict evidence is missing. +1. Split changesets created from refined lineage inherit refinement + requirements automatically. +1. Unrefined work behavior remains unchanged. + +## Contracts and invariants + +### Refinement metadata contract (bead notes) + +Refinement evidence is stored as note artifacts with deterministic parsing. + +```text +planning_refinement.: +authoritative: true +1) mode: requested|inherited|project_policy +2) required: true|false +3) lineage_root: +4) approval: +- status: approved|missing +- source: project_policy|operator +- approved_by: +- approved_at: +5) budgets: +- plan_edit_rounds_max: 5 +- post_impl_review_rounds_max: 8 +6) rounds_executed: +- plan_edit_rounds_used: +7) latest_verdict: READY|REVISED|USER_DECISION_REQUIRED +8) artifacts: +- initial_plan_path: +- latest_plan_path: +- round_log_dir: +``` + +Parser rules mirror north-star gate semantics: +- latest `authoritative: true` block wins, else latest block wins. +- malformed refined artifacts fail closed only when refinement is marked + required. +- unrefined items (no refinement markers) are unaffected. + +### Claim gate invariant + +A top-level epic is not claimable when refinement is required and either: +- approval status is not `approved`, or +- latest verdict is not `READY`. + +### Lineage invariant + +If parent executable work has `required: true`, new child changesets must be +created with refinement `mode=inherited`, `required=true`, and copied budgets. + +## Tricky boundaries and risk controls + +1. **Template vs skill source of truth:** Move planning doctrine into + `planning` skill. Planner template becomes orchestration/routing only. +1. **Trycycle extraction fidelity:** Vendor/adapt trycycle orchestration helpers + with provenance comments (`adapted from ...`) and focused API surface. +1. **Backwards compatibility:** Provide `plan-refined-deliberation` alias skill + delegating to `refine-plan` so older references do not break. +1. **False-positive claim blocks:** Fail closed only when refined markers are + present; keep legacy unrefined flows unchanged. +1. **Single-cutover safety:** Land parser + metadata writes + claim gate in one + PR slice so no intermediate state can produce silent bypass. + +## File structure (locked decomposition) + +### New files + +- `docs/plans/2026-03-28-trycycle-refinement-convergence.md` + - This execution plan. +- `docs/trycycle-planning-convergence.md` + - Extraction map for trycycle mechanics + prose/tone and Atelier mapping. +- `src/atelier/planning_refinement.py` + - Typed parser/renderer/validation for `planning_refinement.*` artifacts. +- `src/atelier/skills/planning/SKILL.md` + - Base planning doctrine (Atelier + trycycle merged, iterative-agnostic). +- `src/atelier/skills/planning/references/planning-doctrine.md` + - Detailed doctrine language and quality rubric. +- `src/atelier/skills/refine-plan/SKILL.md` + - Iterative refinement wrapper skill. +- `src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md` + - Atelier-specific initial planning subagent prompt. +- `src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md` + - Atelier-specific stateless plan-edit subagent prompt. +- `src/atelier/skills/refine-plan/scripts/run_refinement.py` + - Main refinement loop runner (Atelier-adapted trycycle flow). +- `src/atelier/skills/refine-plan/scripts/prompt_builder/*.py` + - Adapted prompt builder internals from trycycle. +- `src/atelier/skills/plan-refined-deliberation/SKILL.md` + - Compatibility alias/deprecation shim to `refine-plan`. +- `src/atelier/skills/plan-split-tasks/scripts/split_tasks.py` + - Deterministic split script that propagates refinement lineage. +- `tests/atelier/test_planning_refinement.py` + - Unit tests for refinement artifact parsing and gate predicates. +- `tests/atelier/skills/test_refine_plan_script.py` + - Script-level tests for refinement orchestration behavior. +- `tests/atelier/skills/test_plan_split_tasks_script.py` + - Split lineage propagation tests. + +### Modified files + +- `src/atelier/models.py` + - Add planning/refinement user config section and defaults. +- `src/atelier/config.py` + - Resolve planning refinement defaults and budgets. +- `src/atelier/lifecycle.py` + - Integrate refinement-required claim gate reasons. +- `src/atelier/worker/selection.py` + - Pass issue payload details into claimability evaluation. +- `src/atelier/templates/AGENTS.planner.md.tmpl` + - Route planning through `planning`; refined trigger routes to `refine-plan`. +- `src/atelier/templates/AGENTS.worker.md.tmpl` + - Overscope split rule references lineage-aware split behavior. +- `src/atelier/skills/plan-create-epic/SKILL.md` + - Add refinement mode inputs and authoring expectations. +- `src/atelier/skills/plan-create-epic/scripts/create_epic.py` + - Accept/write refinement contract fields. +- `src/atelier/skills/plan-changesets/SKILL.md` + - Add refinement inheritance/viral rules. +- `src/atelier/skills/plan-changesets/scripts/create_changeset.py` + - Inherit/write refinement fields from parent. +- `src/atelier/skills/plan-split-tasks/SKILL.md` + - Replace raw `bd` calls with deterministic script flow. +- `src/atelier/skills/plan-promote-epic/SKILL.md` + - Include refinement approval preview checks. +- `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` + - Surface refinement contract readiness in preview. +- `src/atelier/skills/plan-changeset-guardrails/SKILL.md` + - Add refinement contract verification checks. +- `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + - Validate refinement metadata completeness. +- `src/atelier/skills.py` + - Package new skills/directories. +- `docs/behavior.md` + - Document planning/refinement mode semantics. +- `tests/atelier/test_planner_agents_template.py` + - Assert new planner routing language. +- `tests/atelier/test_skills.py` + - Assert new packaged skills and scripts. +- `tests/atelier/test_models.py` + - Config normalization/validation tests for planning refinement settings. +- `tests/atelier/test_lifecycle.py` + - Claimability tests for refinement gate. +- `tests/atelier/skills/test_plan_create_epic_script.py` + - Refinement metadata write tests. +- `tests/atelier/skills/test_plan_changesets_script.py` + - Refinement inheritance tests. +- `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + - Refinement guardrail tests. +- `tests/atelier/skills/test_plan_promote_epic_script.py` + - Promotion preview includes refinement readiness. + +## Strategy gate decisions + +1. Keep #719 gate semantics but anchor them in explicit refinement artifacts, + not prose-only checks. +1. Use note-based metadata artifacts (existing Beads-compatible medium) instead + of introducing a new persistence backend. +1. Vendor/adapt trycycle orchestration code into Atelier skills so behavior is + reproducible and versioned with Atelier. +1. Route all planning doctrine through `planning`; `refine-plan` is a wrapper, + never the global planning doctrine. + +### Task 1: Author trycycle convergence contract and mapping + +**Files:** +- Create: `docs/trycycle-planning-convergence.md` +- Modify: `docs/behavior.md` +- Test: `tests/atelier/test_dogfood_doc.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add/extend a doc contract test to require explicit coverage of: +- trycycle prose extraction, +- trycycle iterative loop extraction, +- Atelier mapping for persistence and gating. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/atelier/test_dogfood_doc.py -k convergence -v` +Expected: FAIL because new convergence doc/anchors are missing. + +- [ ] **Step 3: Write minimal implementation** + +Write `docs/trycycle-planning-convergence.md` with: +- extracted trycycle sources and rationale, +- prose/tone mapping into base `planning` doctrine, +- iterative mechanics mapping into `refine-plan`, +- explicit non-goals (no one-shot replacement of Atelier persistence). + +Update `docs/behavior.md` with refined planning mode semantics and lineage +rules. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/atelier/test_dogfood_doc.py -k convergence -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Tighten wording, links, and inline reference definitions. + +Run: `uv run pytest tests/atelier/test_dogfood_doc.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add docs/trycycle-planning-convergence.md docs/behavior.md tests/atelier/test_dogfood_doc.py +git commit -m "docs(planning): map trycycle convergence contract" -m "- Add a source-backed convergence map for trycycle prose and loop mechanics. +- Document Atelier refined planning mode, lineage propagation, and gating boundaries. +- Add regression checks so convergence intent cannot drift in future edits." +``` + +### Task 2: Create base `planning` skill and reframe planner template + +**Files:** +- Create: `src/atelier/skills/planning/SKILL.md` +- Create: `src/atelier/skills/planning/references/planning-doctrine.md` +- Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` +- Modify: `tests/atelier/test_planner_agents_template.py` +- Modify: `tests/atelier/test_skills.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests asserting: +- packaged skills include `planning`, +- planner template routes all planning through `planning`, +- `refine-plan` is invoked for refined/refinement requests. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -v` +Expected: FAIL on missing skill/template assertions. + +- [ ] **Step 3: Write minimal implementation** + +Create `planning` skill by merging: +- Atelier planner contract sections (intent/rationale/non-goals/etc), and +- trycycle planning doctrine style (explicit decomposition, strategy gate, + low bar for replan, high bar for user interruption, bite-sized executable + steps). + +Update planner template so `planning` is primary doctrine and template language +is orchestration-focused. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Ensure markdown style and frontmatter validation remain compliant. + +Run: `uv run pytest tests/atelier/test_skill_frontmatter_validation.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/planning src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py +git commit -m "feat(planning): add base planning doctrine skill" -m "- Introduce reusable planning doctrine from Atelier + trycycle prose. +- Reframe planner template to use skills for doctrine and routing. +- Add tests for packaged skill discovery and planner template invariants." +``` + +### Task 3: Add refinement artifact contract and config policy + +**Files:** +- Create: `src/atelier/planning_refinement.py` +- Modify: `src/atelier/models.py` +- Modify: `src/atelier/config.py` +- Create: `tests/atelier/test_planning_refinement.py` +- Modify: `tests/atelier/test_models.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests for: +- parsing/selecting authoritative `planning_refinement.*` artifacts, +- default budget values 5/8, +- policy defaults and validation (`project-level required`, `per-item opt-in`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -k refinement -v` +Expected: FAIL because contract parser and config models do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Implement typed refinement contract utilities: +- parse note blocks, +- select authoritative/latest block, +- compute gate readiness (`required`, `approval`, `latest_verdict`). + +Add config models/resolvers for planning refinement defaults and budgets. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -k refinement -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Refactor parser helpers for deterministic failure diagnostics. + +Run: +`uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/planning_refinement.py src/atelier/models.py src/atelier/config.py tests/atelier/test_planning_refinement.py tests/atelier/test_models.py +git commit -m "feat(refinement): add planning refinement contract and config" -m "- Add authoritative note artifact parser/selector for planning refinement evidence. +- Add project/user refinement policy defaults and trycycle-aligned round budgets. +- Add regression tests for parser behavior and config normalization." +``` + +### Task 4: Build `refine-plan` skill by adapting trycycle orchestration + +**Files:** +- Create: `src/atelier/skills/refine-plan/SKILL.md` +- Create: `src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md` +- Create: `src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md` +- Create: `src/atelier/skills/refine-plan/scripts/run_refinement.py` +- Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/build.py` +- Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py` +- Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py` +- Create: `tests/atelier/skills/test_refine_plan_script.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add orchestration tests for: +- bounded round loop (`max=5` default), +- verdict protocol (`READY`, `REVISED`, `USER DECISION REQUIRED`), +- artifact emission for each round, +- deterministic failure when no convergence. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/atelier/skills/test_refine_plan_script.py -v` +Expected: FAIL because refine-plan runner does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Adapt trycycle `run_phase` and prompt-builder mechanics into +`refine-plan/scripts` and wire them to Atelier placeholders and bead paths. + +Add provenance comments in adapted files referencing trycycle source paths and +commit baseline. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/atelier/skills/test_refine_plan_script.py -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Refactor runner interfaces to typed request/result dataclasses and deterministic +JSON outputs for downstream scripts. + +Run: +`uv run pytest tests/atelier/skills/test_refine_plan_script.py tests/atelier/test_skill_frontmatter_validation.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/refine-plan tests/atelier/skills/test_refine_plan_script.py +git commit -m "feat(refine-plan): adapt trycycle planning loop for atelier" -m "- Add refine-plan skill with stateless initial/edit planning rounds. +- Adapt trycycle prompt-builder orchestration for Atelier artifact persistence. +- Add loop/verdict tests for convergence and non-convergence behavior." +``` + +### Task 5: Wire planner authoring scripts for refinement, lineage, and approval + +**Files:** +- Modify: `src/atelier/skills/plan-create-epic/SKILL.md` +- Modify: `src/atelier/skills/plan-create-epic/scripts/create_epic.py` +- Modify: `src/atelier/skills/plan-changesets/SKILL.md` +- Modify: `src/atelier/skills/plan-changesets/scripts/create_changeset.py` +- Modify: `src/atelier/skills/plan-split-tasks/SKILL.md` +- Create: `src/atelier/skills/plan-split-tasks/scripts/split_tasks.py` +- Modify: `src/atelier/skills/plan-promote-epic/SKILL.md` +- Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` +- Modify: `src/atelier/skills/plan-changeset-guardrails/SKILL.md` +- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` +- Modify: `tests/atelier/skills/test_plan_create_epic_script.py` +- Modify: `tests/atelier/skills/test_plan_changesets_script.py` +- Create: `tests/atelier/skills/test_plan_split_tasks_script.py` +- Modify: `tests/atelier/skills/test_plan_promote_epic_script.py` +- Modify: `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests for: +- per-item refinement request flags, +- lineage inheritance on child/split creation, +- promotion preview requiring refinement readiness for refined work, +- guardrails reporting missing refinement approvals/verdicts. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py tests/atelier/skills/test_plan_promote_epic_script.py tests/atelier/skills/test_plan_changeset_guardrails_script.py -k refinement -v` +Expected: FAIL due missing refinement wiring. + +- [ ] **Step 3: Write minimal implementation** + +Update scripts to write/read `planning_refinement.*` artifacts and enforce: +- opt-in at any time, +- viral inheritance, +- explicit approval requirement before open-promotion for refined scope. + +Replace raw split instructions with deterministic split script using +store-backed create flows. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py tests/atelier/skills/test_plan_split_tasks_script.py tests/atelier/skills/test_plan_promote_epic_script.py tests/atelier/skills/test_plan_changeset_guardrails_script.py -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Refactor duplicate refinement note-writing logic into shared helper usage. + +Run: +`uv run pytest tests/atelier/skills/test_plan_* -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/plan-create-epic src/atelier/skills/plan-changesets src/atelier/skills/plan-split-tasks src/atelier/skills/plan-promote-epic src/atelier/skills/plan-changeset-guardrails tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py tests/atelier/skills/test_plan_split_tasks_script.py tests/atelier/skills/test_plan_promote_epic_script.py tests/atelier/skills/test_plan_changeset_guardrails_script.py +git commit -m "feat(planning): propagate refinement lineage through authoring flows" -m "- Add refinement-aware create/split/promote/guardrail script behavior. +- Enforce viral lineage and explicit approval evidence for refined work promotion. +- Add script-level tests for inheritance, readiness previews, and guardrails." +``` + +### Task 6: Enforce worker claim gate and overscope behavior + +**Files:** +- Modify: `src/atelier/lifecycle.py` +- Modify: `src/atelier/worker/selection.py` +- Modify: `src/atelier/templates/AGENTS.worker.md.tmpl` +- Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` +- Modify: `tests/atelier/test_lifecycle.py` +- Modify: `tests/atelier/worker/test_selection.py` +- Modify: `tests/atelier/worker/test_session_startup.py` +- Modify: `tests/atelier/test_worker_agents_template.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests asserting claim rejection reason when refined work lacks approval or +`READY` verdict, and template guidance for overscope split lineage behavior. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py tests/atelier/test_worker_agents_template.py -k refinement -v` +Expected: FAIL due missing gate and template updates. + +- [ ] **Step 3: Write minimal implementation** + +Integrate refinement gate checks into epic claimability path and selection +filtering. Update worker/planner templates to codify lineage-based split rules. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py tests/atelier/test_worker_agents_template.py -k refinement -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Ensure rejection reasons are stable machine-readable strings for retry logic. + +Run: +`uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/lifecycle.py src/atelier/worker/selection.py src/atelier/templates/AGENTS.worker.md.tmpl src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py tests/atelier/test_worker_agents_template.py +git commit -m "feat(worker): fail closed on refined-work claim gate" -m "- Enforce refined-work approval and READY verdict at claimability boundaries. +- Keep unrefined claim behavior unchanged. +- Add template and selection tests for lineage-aware overscope handling." +``` + +### Task 7: Add compatibility alias and package the new skills + +**Files:** +- Create: `src/atelier/skills/plan-refined-deliberation/SKILL.md` +- Modify: `src/atelier/skills.py` +- Modify: `tests/atelier/test_skills.py` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests asserting packaged skill discovery includes: +- `planning`, +- `refine-plan`, +- `plan-refined-deliberation` alias. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/atelier/test_skills.py -k planning -v` +Expected: FAIL on missing skills. + +- [ ] **Step 3: Write minimal implementation** + +Add alias skill that explicitly routes operators to `refine-plan` and documents +deprecation scope. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/atelier/test_skills.py -k planning -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Ensure frontmatter, naming, and package sync behavior are stable. + +Run: +`uv run pytest tests/atelier/test_skills.py tests/atelier/test_skill_frontmatter_validation.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/plan-refined-deliberation src/atelier/skills.py tests/atelier/test_skills.py +git commit -m "feat(skills): add refine-plan compatibility alias" -m "- Add plan-refined-deliberation alias that delegates to refine-plan. +- Ensure packaged skill discovery includes new planning/refinement skills. +- Add tests to prevent future skill-packaging drift." +``` + +### Task 8: Final verification and repo gates + +**Files:** +- Modify: all changed files from Tasks 1-7 as needed. + +- [ ] **Step 1: Identify or write the failing test** + +No new tests. Use full-suite verification as the gate. + +- [ ] **Step 2: Run test to verify it fails (if anything regressed)** + +Run: `just test` +Expected: Either PASS or actionable failing tests that must be fixed. + +- [ ] **Step 3: Write minimal implementation** + +Fix any regressions found by full-suite checks without weakening valid tests. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `just test` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run: `just format` +Run: `just lint` +Expected: PASS for both. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(planning): converge atelier planning with trycycle refinement" -m "- Introduce planning/refine-plan skill architecture with trycycle-derived mechanics and doctrine. +- Persist refinement artifacts, enforce lineage propagation, and fail-closed claim gates. +- Update planner/worker templates, scripts, docs, and tests for a single-cutover rollout." +``` + +## Completion checklist for this implementation + +- [ ] `planning` skill is the primary doctrine for planning quality. +- [ ] `refine-plan` runs bounded stateless plan-edit rounds and persists evidence. +- [ ] refinement trigger semantics are explicit and documented (`refined`/`refinement`). +- [ ] refinement is opt-in, viral by lineage, and approval-gated. +- [ ] worker claim blocks refined work without approval + `READY` evidence. +- [ ] trycycle prose/style has been merged into baseline planning doctrine. +- [ ] all tests, formatting, and lint gates pass. From 688cb58fe2bfbdee8fe81cb1058136a68a347b96 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sat, 28 Mar 2026 23:56:42 -0700 Subject: [PATCH 02/25] docs(planning): rewrite trycycle refinement convergence plan - Replace the plan with a fail-closed, executable architecture that separates baseline planning doctrine from iterative refinement mechanics. - Add explicit any-time refinement activation, approval-evidence, lineage propagation, and canonical verdict contracts. - Strengthen convergence quality gates with source-backed trycycle doctrine/mechanics mapping and coverage-driven test additions. --- ...6-03-28-trycycle-refinement-convergence.md | 574 ++++++++++-------- 1 file changed, 316 insertions(+), 258 deletions(-) diff --git a/docs/plans/2026-03-28-trycycle-refinement-convergence.md b/docs/plans/2026-03-28-trycycle-refinement-convergence.md index d9fc2bda..8ebbf39b 100644 --- a/docs/plans/2026-03-28-trycycle-refinement-convergence.md +++ b/docs/plans/2026-03-28-trycycle-refinement-convergence.md @@ -5,314 +5,345 @@ > tracking. **Goal:** Converge Atelier planning with trycycle by extracting both trycycle's -iterative refinement mechanics and its planning prose/tone into Atelier-native -`planning` + `refine-plan` skills, with persisted refinement evidence, -lineage-based propagation, and fail-closed worker claim gates. +planning doctrine and its iterative refinement loop into Atelier-native +`planning` + `refine-plan` skills, with explicit activation, approval evidence, +lineage propagation, and fail-closed worker claim gates. -**Architecture:** Build a first-class planning doctrine skill (`planning`) from -Atelier planner guidance plus trycycle planning language, then layer a separate -`refine-plan` orchestration skill that adapts trycycle's stateless plan-edit -loop into Atelier's bead persistence model. Treat refinement as explicit policy -(`project` or `per-item`), persist it as structured bead-note artifacts, and -enforce it at worker-claim boundaries so workers cannot execute refined work -without approval and a converged plan verdict. +**Architecture:** Build `planning` as the single planning doctrine skill for all +planning flows (iterative and non-iterative), sourced from both Atelier planner +contract language and trycycle planning prose. Build `refine-plan` as an +opt-in wrapper that runs trycycle-style stateless planning rounds with explicit +verdicts and bounded retries, then persists authoritative refinement evidence in +bead notes. Add a dedicated refinement mutation path (`plan-set-refinement`) so +refinement can be enabled on any epic/changeset at any lifecycle point. -**Tech Stack:** Python 3.11+, Typer CLI/runtime modules, Atelier store/Beads -APIs, projected skill runtime scripts, pytest, markdown docs and templates. +**Tech Stack:** Python 3.11+, Typer/runtime modules, Atelier store/Beads APIs, +projected skill scripts, pytest, markdown templates/docs. --- ## Locked requirements from user decisions -1. Refinement is opt-in per epic/changeset and may be enabled at any lifecycle - point. -1. Default budgets match trycycle (`plan-edit rounds=5`, - `post-implementation review/fix rounds=8`). -1. Refinement is viral by lineage. Descendants of refined work are refined. -1. Worker overscope breakdown uses refinement iff the source lineage is refined. -1. Refinement requires explicit approval, either from project policy or - per-item request. -1. Convergence must include trycycle planning prose/style, not just mechanics. +1. Refinement is opt-in per epic/changeset and can be enabled later at any + lifecycle point. +1. Default budgets match trycycle (`plan_edit_rounds=5`, + `post_impl_review_rounds=8`) with future project-level configurability. +1. Refinement is viral by lineage. Descendants of refined work remain refined. +1. Worker overscope breakdown uses refinement iff source lineage is refined. +1. Refinement requires explicit approval evidence from project policy or an + explicit per-item operator request. +1. Convergence includes trycycle planning tone/style and emphasis, not only + loop mechanics. ## User-visible behavior after cutover -1. Planner has a reusable `planning` skill that defines planning doctrine for - all agents, not only planner template prose. -1. When operator asks for a "refined" or "refinement" plan, planner runs - `refine-plan` iterative subagent evaluation before promotion/dispatch. -1. Refined work beads carry auditable refinement artifacts in notes with round - counts, verdicts, approval evidence, and artifact pointers. -1. Worker claim fails closed for refined work when required approval or - converged verdict evidence is missing. -1. Split changesets created from refined lineage inherit refinement - requirements automatically. -1. Unrefined work behavior remains unchanged. +1. `planning` is the primary planning doctrine skill across planner workflows. +1. Requests containing `refined` or `refinement` trigger `refine-plan` before + promotion/dispatch. +1. Planner can enable refinement on an existing epic/changeset at any time via + `plan-set-refinement`. +1. Refined beads persist auditable evidence: mode, approval source, budgets, + rounds used, verdict, and artifact links. +1. Worker claim fails closed when refined work lacks approval evidence or a + `READY` verdict. +1. Child/split changesets inherit required refinement from refined lineage. +1. Unrefined flows remain behaviorally unchanged. ## Contracts and invariants -### Refinement metadata contract (bead notes) +### Trycycle convergence contract -Refinement evidence is stored as note artifacts with deterministic parsing. +The implementation must persist a source-backed mapping doc that includes: + +1. A complete inventory of trycycle planning inputs used for convergence + (planning prompts, planning/edit loop rules, strategy gate language, + decomposition guidance, convergence semantics). +1. For each source, explicit mapping to: + - baseline `planning` doctrine (iterative-agnostic), or + - `refine-plan` orchestration mechanics (iterative). +1. Rationale for each adaptation to Atelier persistence boundaries. +1. Non-goals proving Atelier persistence model is preserved. + +### Refinement artifact contract (authoritative note block) + +Refinement evidence is stored as append-only note blocks, parsed by +`planning_refinement.py`. ```text -planning_refinement.: +planning_refinement.v1 authoritative: true -1) mode: requested|inherited|project_policy -2) required: true|false -3) lineage_root: -4) approval: -- status: approved|missing -- source: project_policy|operator -- approved_by: -- approved_at: -5) budgets: -- plan_edit_rounds_max: 5 -- post_impl_review_rounds_max: 8 -6) rounds_executed: -- plan_edit_rounds_used: -7) latest_verdict: READY|REVISED|USER_DECISION_REQUIRED -8) artifacts: -- initial_plan_path: -- latest_plan_path: -- round_log_dir: +mode: requested|inherited|project_policy +required: true|false +lineage_root: +approval_status: approved|missing +approval_source: project_policy|operator +approved_by: +approved_at: +plan_edit_rounds_max: 5 +post_impl_review_rounds_max: 8 +plan_edit_rounds_used: +latest_verdict: READY|REVISED|USER_DECISION_REQUIRED +initial_plan_path: +latest_plan_path: +round_log_dir: ``` -Parser rules mirror north-star gate semantics: -- latest `authoritative: true` block wins, else latest block wins. -- malformed refined artifacts fail closed only when refinement is marked - required. -- unrefined items (no refinement markers) are unaffected. +Parser rules: +- newest `authoritative: true` block wins; +- if none are authoritative, newest valid block wins; +- malformed blocks fail closed only when `required=true` is asserted by any + winning block in scope; +- unrefined work (no winning refinement block) remains unaffected. + +### Activation and approval invariant + +Refinement activation must be explicit and durable: + +1. `plan-set-refinement` can mark any epic/changeset as refined at any + lifecycle stage (`deferred|open|in_progress|blocked`). +1. `required=true` demands approval evidence (`approval_status=approved` and + source/principal/timestamp). +1. `project_policy` mode can satisfy approval automatically only when policy is + configured and recorded in note evidence. +1. Inherited descendant records copy budgets and mark `mode=inherited`. ### Claim gate invariant -A top-level epic is not claimable when refinement is required and either: -- approval status is not `approved`, or +Top-level executable work is not claimable when refinement is required and +either: +- approval evidence is missing, or - latest verdict is not `READY`. ### Lineage invariant -If parent executable work has `required: true`, new child changesets must be -created with refinement `mode=inherited`, `required=true`, and copied budgets. +If a parent executable item has required refinement, any newly created child or +split changeset must carry required inherited refinement. ## Tricky boundaries and risk controls -1. **Template vs skill source of truth:** Move planning doctrine into - `planning` skill. Planner template becomes orchestration/routing only. -1. **Trycycle extraction fidelity:** Vendor/adapt trycycle orchestration helpers - with provenance comments (`adapted from ...`) and focused API surface. -1. **Backwards compatibility:** Provide `plan-refined-deliberation` alias skill - delegating to `refine-plan` so older references do not break. -1. **False-positive claim blocks:** Fail closed only when refined markers are - present; keep legacy unrefined flows unchanged. -1. **Single-cutover safety:** Land parser + metadata writes + claim gate in one - PR slice so no intermediate state can produce silent bypass. +1. **Doctrine drift risk:** Add contract tests for planning doctrine coverage so + trycycle tone/style extraction cannot regress to superficial wording. +1. **"Any time" activation gap:** Add explicit mutation skill/script instead of + relying only on create-time flags. +1. **Approval ambiguity:** Persist machine-readable approval evidence; do not + infer approval from prose. +1. **Verdict token drift:** Canonical verdicts are + `READY|REVISED|USER_DECISION_REQUIRED` everywhere. +1. **Backwards compatibility:** Keep `plan-refined-deliberation` as an alias to + `refine-plan` during migration. +1. **Fail-closed scope:** Only refined markers activate claim blocking. ## File structure (locked decomposition) ### New files -- `docs/plans/2026-03-28-trycycle-refinement-convergence.md` - - This execution plan. - `docs/trycycle-planning-convergence.md` - - Extraction map for trycycle mechanics + prose/tone and Atelier mapping. + - Source inventory and adaptation map (mechanics + doctrine). - `src/atelier/planning_refinement.py` - - Typed parser/renderer/validation for `planning_refinement.*` artifacts. + - Typed parsing/selection/evaluation of refinement note artifacts. - `src/atelier/skills/planning/SKILL.md` - - Base planning doctrine (Atelier + trycycle merged, iterative-agnostic). + - Baseline planning doctrine (Atelier + trycycle merged). - `src/atelier/skills/planning/references/planning-doctrine.md` - - Detailed doctrine language and quality rubric. + - Detailed quality rubric and doctrine excerpts. - `src/atelier/skills/refine-plan/SKILL.md` - - Iterative refinement wrapper skill. + - Iterative wrapper around `planning`. - `src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md` - - Atelier-specific initial planning subagent prompt. + - Initial subagent prompt adapted from trycycle planning flow. - `src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md` - - Atelier-specific stateless plan-edit subagent prompt. + - Stateless edit-round prompt with verdict contract. - `src/atelier/skills/refine-plan/scripts/run_refinement.py` - - Main refinement loop runner (Atelier-adapted trycycle flow). -- `src/atelier/skills/refine-plan/scripts/prompt_builder/*.py` - - Adapted prompt builder internals from trycycle. + - Bounded iterative refinement runner. +- `src/atelier/skills/refine-plan/scripts/prompt_builder/build.py` + - Prompt assembly helpers adapted from trycycle. +- `src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py` + - Prompt template parser/AST helpers. +- `src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py` + - Rendered prompt validation checks. +- `src/atelier/skills/plan-set-refinement/SKILL.md` + - Explicit activation/update skill for refinement on existing work. +- `src/atelier/skills/plan-set-refinement/scripts/set_refinement.py` + - Append authoritative refinement artifact notes. - `src/atelier/skills/plan-refined-deliberation/SKILL.md` - - Compatibility alias/deprecation shim to `refine-plan`. + - Compatibility alias to `refine-plan`. - `src/atelier/skills/plan-split-tasks/scripts/split_tasks.py` - - Deterministic split script that propagates refinement lineage. + - Split helper that propagates refinement lineage. - `tests/atelier/test_planning_refinement.py` - - Unit tests for refinement artifact parsing and gate predicates. + - Parser, winning-block, and gate predicate tests. +- `tests/atelier/test_trycycle_planning_convergence.py` + - Convergence coverage tests for doctrine/mechanics mapping. +- `tests/atelier/skills/test_planning_skill_contract.py` + - Guards for required baseline planning doctrine sections. - `tests/atelier/skills/test_refine_plan_script.py` - - Script-level tests for refinement orchestration behavior. + - Round-loop and verdict contract tests. +- `tests/atelier/skills/test_plan_set_refinement_script.py` + - Any-time activation and approval evidence tests. - `tests/atelier/skills/test_plan_split_tasks_script.py` - - Split lineage propagation tests. + - Inherited refinement propagation tests. ### Modified files - `src/atelier/models.py` - - Add planning/refinement user config section and defaults. + - Add planning refinement policy/budget config models. - `src/atelier/config.py` - - Resolve planning refinement defaults and budgets. + - Resolve default refinement policy and budgets. - `src/atelier/lifecycle.py` - - Integrate refinement-required claim gate reasons. + - Integrate refinement claim gate evaluation. - `src/atelier/worker/selection.py` - - Pass issue payload details into claimability evaluation. + - Feed issue payload into refinement-aware claimability checks. - `src/atelier/templates/AGENTS.planner.md.tmpl` - - Route planning through `planning`; refined trigger routes to `refine-plan`. + - Make `planning` primary doctrine and route `refined/refinement` to + `refine-plan`; document `plan-set-refinement` usage. - `src/atelier/templates/AGENTS.worker.md.tmpl` - - Overscope split rule references lineage-aware split behavior. + - Codify lineage-preserving overscope split behavior. - `src/atelier/skills/plan-create-epic/SKILL.md` - - Add refinement mode inputs and authoring expectations. + - Document create-time refinement options and approval evidence expectations. - `src/atelier/skills/plan-create-epic/scripts/create_epic.py` - - Accept/write refinement contract fields. + - Optional create-time refinement note emission. - `src/atelier/skills/plan-changesets/SKILL.md` - - Add refinement inheritance/viral rules. + - Add refinement inheritance rules. - `src/atelier/skills/plan-changesets/scripts/create_changeset.py` - Inherit/write refinement fields from parent. - `src/atelier/skills/plan-split-tasks/SKILL.md` - - Replace raw `bd` calls with deterministic script flow. + - Switch to deterministic split script contract. - `src/atelier/skills/plan-promote-epic/SKILL.md` - - Include refinement approval preview checks. + - Include refinement readiness in preview/approval checks. - `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` - - Surface refinement contract readiness in preview. + - Surface refinement readiness diagnostics in preview output. - `src/atelier/skills/plan-changeset-guardrails/SKILL.md` - - Add refinement contract verification checks. + - Add refinement completeness checks. - `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` - - Validate refinement metadata completeness. -- `src/atelier/skills.py` - - Package new skills/directories. + - Validate refinement contract completeness. - `docs/behavior.md` - - Document planning/refinement mode semantics. + - Document planning/refinement mode semantics and lineage behavior. - `tests/atelier/test_planner_agents_template.py` - - Assert new planner routing language. + - Assert planner routing and refinement activation guidance. - `tests/atelier/test_skills.py` - - Assert new packaged skills and scripts. + - Assert packaged skills/scripts for planning/refinement features. - `tests/atelier/test_models.py` - - Config normalization/validation tests for planning refinement settings. + - Config defaults/validation tests for refinement policy. - `tests/atelier/test_lifecycle.py` - Claimability tests for refinement gate. +- `tests/atelier/worker/test_selection.py` + - Startup selection tests for refined claim filtering. +- `tests/atelier/worker/test_session_startup.py` + - Session behavior tests around claim gate reasons. +- `tests/atelier/test_worker_agents_template.py` + - Worker template tests for lineage-aware split behavior. - `tests/atelier/skills/test_plan_create_epic_script.py` - - Refinement metadata write tests. + - Create-time refinement metadata tests. - `tests/atelier/skills/test_plan_changesets_script.py` - - Refinement inheritance tests. -- `tests/atelier/skills/test_plan_changeset_guardrails_script.py` - - Refinement guardrail tests. + - Inheritance tests for child creation. - `tests/atelier/skills/test_plan_promote_epic_script.py` - - Promotion preview includes refinement readiness. + - Promotion preview/refinement readiness tests. +- `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + - Guardrail checks for refinement contract validity. ## Strategy gate decisions -1. Keep #719 gate semantics but anchor them in explicit refinement artifacts, - not prose-only checks. -1. Use note-based metadata artifacts (existing Beads-compatible medium) instead - of introducing a new persistence backend. -1. Vendor/adapt trycycle orchestration code into Atelier skills so behavior is - reproducible and versioned with Atelier. -1. Route all planning doctrine through `planning`; `refine-plan` is a wrapper, - never the global planning doctrine. +1. Keep #719 fail-closed gate intent, but ground it in structured refinement + evidence instead of prose-only checks. +1. Preserve Atelier persistence model (beads + notes) and adapt trycycle + strategy to that contract. +1. Split doctrine from mechanism: `planning` owns all planning intent; + `refine-plan` adds iterative evaluation. +1. Add explicit activation path so "refine later" is first-class behavior. -### Task 1: Author trycycle convergence contract and mapping +### Task 1: Produce source-backed trycycle convergence map and tests **Files:** - Create: `docs/trycycle-planning-convergence.md` +- Create: `tests/atelier/test_trycycle_planning_convergence.py` - Modify: `docs/behavior.md` -- Test: `tests/atelier/test_dogfood_doc.py` - [ ] **Step 1: Identify or write the failing test** -Add/extend a doc contract test to require explicit coverage of: -- trycycle prose extraction, -- trycycle iterative loop extraction, -- Atelier mapping for persistence and gating. +Add tests that require the convergence doc to include: +- trycycle source inventory, +- doctrine-vs-mechanics mapping, +- Atelier adaptation rationale, +- explicit non-goals. - [ ] **Step 2: Run test to verify it fails** -Run: `uv run pytest tests/atelier/test_dogfood_doc.py -k convergence -v` -Expected: FAIL because new convergence doc/anchors are missing. +Run: `uv run pytest tests/atelier/test_trycycle_planning_convergence.py -v` +Expected: FAIL because the convergence doc/anchors do not exist yet. - [ ] **Step 3: Write minimal implementation** -Write `docs/trycycle-planning-convergence.md` with: -- extracted trycycle sources and rationale, -- prose/tone mapping into base `planning` doctrine, -- iterative mechanics mapping into `refine-plan`, -- explicit non-goals (no one-shot replacement of Atelier persistence). - -Update `docs/behavior.md` with refined planning mode semantics and lineage -rules. +Author the convergence doc with source-backed mappings and update +`docs/behavior.md` with refined planning semantics and lineage policy. - [ ] **Step 4: Run test to verify it passes** -Run: `uv run pytest tests/atelier/test_dogfood_doc.py -k convergence -v` +Run: `uv run pytest tests/atelier/test_trycycle_planning_convergence.py -v` Expected: PASS. - [ ] **Step 5: Refactor and verify** -Tighten wording, links, and inline reference definitions. +Polish wording and link definitions. -Run: `uv run pytest tests/atelier/test_dogfood_doc.py -v` +Run: `uv run pytest tests/atelier/test_trycycle_planning_convergence.py -v` Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add docs/trycycle-planning-convergence.md docs/behavior.md tests/atelier/test_dogfood_doc.py -git commit -m "docs(planning): map trycycle convergence contract" -m "- Add a source-backed convergence map for trycycle prose and loop mechanics. -- Document Atelier refined planning mode, lineage propagation, and gating boundaries. -- Add regression checks so convergence intent cannot drift in future edits." +git add docs/trycycle-planning-convergence.md docs/behavior.md tests/atelier/test_trycycle_planning_convergence.py +git commit -m "docs(planning): add trycycle convergence source map" -m "- Add a source-backed mapping of trycycle doctrine and iterative mechanics. +- Document Atelier adaptation boundaries and non-goals. +- Add regression tests that prevent shallow convergence drift." ``` -### Task 2: Create base `planning` skill and reframe planner template +### Task 2: Create baseline `planning` doctrine skill **Files:** - Create: `src/atelier/skills/planning/SKILL.md` - Create: `src/atelier/skills/planning/references/planning-doctrine.md` +- Create: `tests/atelier/skills/test_planning_skill_contract.py` - Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` - Modify: `tests/atelier/test_planner_agents_template.py` - Modify: `tests/atelier/test_skills.py` - [ ] **Step 1: Identify or write the failing test** -Add tests asserting: -- packaged skills include `planning`, -- planner template routes all planning through `planning`, -- `refine-plan` is invoked for refined/refinement requests. +Add contract tests asserting the baseline planning doctrine includes: +- explicit intent/rationale/non-goals framing, +- strategy gate language, +- low bar for replan / high bar for user interruption, +- bite-sized execution-oriented decomposition guidance. - [ ] **Step 2: Run test to verify it fails** Run: -`uv run pytest tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -v` -Expected: FAIL on missing skill/template assertions. +`uv run pytest tests/atelier/skills/test_planning_skill_contract.py tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py -k planning -v` +Expected: FAIL on missing skill/routing/contract assertions. - [ ] **Step 3: Write minimal implementation** -Create `planning` skill by merging: -- Atelier planner contract sections (intent/rationale/non-goals/etc), and -- trycycle planning doctrine style (explicit decomposition, strategy gate, - low bar for replan, high bar for user interruption, bite-sized executable - steps). - -Update planner template so `planning` is primary doctrine and template language -is orchestration-focused. +Create `planning` as the primary doctrine skill and update planner template so +planning doctrine is skill-owned while template text stays orchestration-only. - [ ] **Step 4: Run test to verify it passes** Run: -`uv run pytest tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -v` +`uv run pytest tests/atelier/skills/test_planning_skill_contract.py tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py -k planning -v` Expected: PASS. - [ ] **Step 5: Refactor and verify** -Ensure markdown style and frontmatter validation remain compliant. - Run: `uv run pytest tests/atelier/test_skill_frontmatter_validation.py -v` Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add src/atelier/skills/planning src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py -git commit -m "feat(planning): add base planning doctrine skill" -m "- Introduce reusable planning doctrine from Atelier + trycycle prose. -- Reframe planner template to use skills for doctrine and routing. -- Add tests for packaged skill discovery and planner template invariants." +git add src/atelier/skills/planning src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/skills/test_planning_skill_contract.py tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py +git commit -m "feat(planning): add baseline doctrine skill" -m "- Introduce a reusable planning doctrine skill from Atelier + trycycle guidance. +- Route planner behavior through the doctrine skill instead of template-only prose. +- Add contract tests to prevent doctrine drift." ``` -### Task 3: Add refinement artifact contract and config policy +### Task 3: Add refinement artifact parser and policy config **Files:** - Create: `src/atelier/planning_refinement.py` @@ -324,24 +355,21 @@ git commit -m "feat(planning): add base planning doctrine skill" -m "- Introduce - [ ] **Step 1: Identify or write the failing test** Add tests for: -- parsing/selecting authoritative `planning_refinement.*` artifacts, -- default budget values 5/8, -- policy defaults and validation (`project-level required`, `per-item opt-in`). +- artifact parse and winning-block selection, +- canonical verdict tokens, +- default 5/8 budgets, +- project policy defaults and validation. - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -k refinement -v` -Expected: FAIL because contract parser and config models do not exist yet. +Expected: FAIL because parser/config contract is missing. - [ ] **Step 3: Write minimal implementation** -Implement typed refinement contract utilities: -- parse note blocks, -- select authoritative/latest block, -- compute gate readiness (`required`, `approval`, `latest_verdict`). - -Add config models/resolvers for planning refinement defaults and budgets. +Implement typed parser/evaluator helpers and config defaults for refinement +policy and budgets. - [ ] **Step 4: Run test to verify it passes** @@ -351,8 +379,6 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** -Refactor parser helpers for deterministic failure diagnostics. - Run: `uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -v` Expected: PASS. @@ -361,12 +387,59 @@ Expected: PASS. ```bash git add src/atelier/planning_refinement.py src/atelier/models.py src/atelier/config.py tests/atelier/test_planning_refinement.py tests/atelier/test_models.py -git commit -m "feat(refinement): add planning refinement contract and config" -m "- Add authoritative note artifact parser/selector for planning refinement evidence. -- Add project/user refinement policy defaults and trycycle-aligned round budgets. -- Add regression tests for parser behavior and config normalization." +git commit -m "feat(refinement): add artifact contract and policy defaults" -m "- Add typed refinement artifact parsing and gate-evaluation helpers. +- Add project/user policy defaults with trycycle-aligned budgets. +- Add tests for parsing, verdicts, and config validation." +``` + +### Task 4: Add any-time activation skill for refinement + +**Files:** +- Create: `src/atelier/skills/plan-set-refinement/SKILL.md` +- Create: `src/atelier/skills/plan-set-refinement/scripts/set_refinement.py` +- Create: `tests/atelier/skills/test_plan_set_refinement_script.py` +- Modify: `tests/atelier/test_skills.py` +- Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` + +- [ ] **Step 1: Identify or write the failing test** + +Add tests for: +- enabling refinement on existing epic/changeset regardless of lifecycle state, +- requiring approval evidence when `required=true`, +- inheriting refinement metadata for lineage-derived activation. + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -k refinement -v` +Expected: FAIL because activation skill/script is missing. + +- [ ] **Step 3: Write minimal implementation** + +Implement `plan-set-refinement` and route planner guidance to use it whenever +refinement is requested after initial bead creation. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py -k refinement -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Run: `uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/plan-set-refinement src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/skills/test_plan_set_refinement_script.py tests/atelier/test_skills.py tests/atelier/test_planner_agents_template.py +git commit -m "feat(planning): add any-time refinement activation skill" -m "- Add plan-set-refinement for explicit refinement activation on existing work. +- Enforce approval evidence requirements for required refinement mode. +- Update planner guidance and tests for activation routing." ``` -### Task 4: Build `refine-plan` skill by adapting trycycle orchestration +### Task 5: Build `refine-plan` iterative wrapper from trycycle mechanics **Files:** - Create: `src/atelier/skills/refine-plan/SKILL.md` @@ -380,24 +453,21 @@ git commit -m "feat(refinement): add planning refinement contract and config" -m - [ ] **Step 1: Identify or write the failing test** -Add orchestration tests for: -- bounded round loop (`max=5` default), -- verdict protocol (`READY`, `REVISED`, `USER DECISION REQUIRED`), -- artifact emission for each round, -- deterministic failure when no convergence. +Add tests for: +- bounded loop defaults (`max_rounds=5`), +- verdict parsing (`READY|REVISED|USER_DECISION_REQUIRED`), +- per-round artifact emission, +- fail-closed non-convergence behavior. - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/atelier/skills/test_refine_plan_script.py -v` -Expected: FAIL because refine-plan runner does not exist. +Expected: FAIL because runner and prompts do not exist. - [ ] **Step 3: Write minimal implementation** -Adapt trycycle `run_phase` and prompt-builder mechanics into -`refine-plan/scripts` and wire them to Atelier placeholders and bead paths. - -Add provenance comments in adapted files referencing trycycle source paths and -commit baseline. +Adapt trycycle planning-loop helpers and prompts into `refine-plan` scripts, +with provenance comments that identify source files and commit baseline. - [ ] **Step 4: Run test to verify it passes** @@ -406,9 +476,6 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** -Refactor runner interfaces to typed request/result dataclasses and deterministic -JSON outputs for downstream scripts. - Run: `uv run pytest tests/atelier/skills/test_refine_plan_script.py tests/atelier/test_skill_frontmatter_validation.py -v` Expected: PASS. @@ -417,12 +484,12 @@ Expected: PASS. ```bash git add src/atelier/skills/refine-plan tests/atelier/skills/test_refine_plan_script.py -git commit -m "feat(refine-plan): adapt trycycle planning loop for atelier" -m "- Add refine-plan skill with stateless initial/edit planning rounds. -- Adapt trycycle prompt-builder orchestration for Atelier artifact persistence. -- Add loop/verdict tests for convergence and non-convergence behavior." +git commit -m "feat(refine-plan): add trycycle-style iterative planning loop" -m "- Add bounded stateless planning rounds with explicit verdict protocol. +- Adapt trycycle prompt-builder mechanics with provenance comments. +- Add convergence and non-convergence regression tests." ``` -### Task 5: Wire planner authoring scripts for refinement, lineage, and approval +### Task 6: Wire create/split/promote/guardrail flows to refinement contract **Files:** - Modify: `src/atelier/skills/plan-create-epic/SKILL.md` @@ -444,26 +511,21 @@ git commit -m "feat(refine-plan): adapt trycycle planning loop for atelier" -m " - [ ] **Step 1: Identify or write the failing test** Add tests for: -- per-item refinement request flags, -- lineage inheritance on child/split creation, -- promotion preview requiring refinement readiness for refined work, -- guardrails reporting missing refinement approvals/verdicts. +- create-time refinement metadata writes, +- lineage inheritance on create/split, +- promotion preview exposing refinement readiness, +- guardrails reporting missing approval/verdict evidence. - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py tests/atelier/skills/test_plan_promote_epic_script.py tests/atelier/skills/test_plan_changeset_guardrails_script.py -k refinement -v` -Expected: FAIL due missing refinement wiring. +Expected: FAIL because flows are not refinement-aware yet. - [ ] **Step 3: Write minimal implementation** -Update scripts to write/read `planning_refinement.*` artifacts and enforce: -- opt-in at any time, -- viral inheritance, -- explicit approval requirement before open-promotion for refined scope. - -Replace raw split instructions with deterministic split script using -store-backed create flows. +Implement refinement-aware behavior across planner authoring scripts, including +lineage inheritance and deterministic split behavior. - [ ] **Step 4: Run test to verify it passes** @@ -473,22 +535,19 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** -Refactor duplicate refinement note-writing logic into shared helper usage. - -Run: -`uv run pytest tests/atelier/skills/test_plan_* -v` +Run: `uv run pytest tests/atelier/skills/test_plan_* -v` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/atelier/skills/plan-create-epic src/atelier/skills/plan-changesets src/atelier/skills/plan-split-tasks src/atelier/skills/plan-promote-epic src/atelier/skills/plan-changeset-guardrails tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py tests/atelier/skills/test_plan_split_tasks_script.py tests/atelier/skills/test_plan_promote_epic_script.py tests/atelier/skills/test_plan_changeset_guardrails_script.py -git commit -m "feat(planning): propagate refinement lineage through authoring flows" -m "- Add refinement-aware create/split/promote/guardrail script behavior. -- Enforce viral lineage and explicit approval evidence for refined work promotion. -- Add script-level tests for inheritance, readiness previews, and guardrails." +git commit -m "feat(planning): propagate refinement contract through authoring" -m "- Make create/split/promote/guardrail scripts refinement-aware. +- Enforce lineage inheritance and approval/verdict readiness diagnostics. +- Add script-level regression coverage for refinement flows." ``` -### Task 6: Enforce worker claim gate and overscope behavior +### Task 7: Enforce worker claim gate and overscope lineage rules **Files:** - Modify: `src/atelier/lifecycle.py` @@ -502,19 +561,21 @@ git commit -m "feat(planning): propagate refinement lineage through authoring fl - [ ] **Step 1: Identify or write the failing test** -Add tests asserting claim rejection reason when refined work lacks approval or -`READY` verdict, and template guidance for overscope split lineage behavior. +Add tests asserting: +- refined work claim rejection without approval or `READY`, +- stable rejection reason tokens, +- overscope split guidance preserves refinement lineage. - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py tests/atelier/test_worker_agents_template.py -k refinement -v` -Expected: FAIL due missing gate and template updates. +Expected: FAIL due missing gate wiring/template language. - [ ] **Step 3: Write minimal implementation** -Integrate refinement gate checks into epic claimability path and selection -filtering. Update worker/planner templates to codify lineage-based split rules. +Integrate gate checks in lifecycle/selection and update templates for +lineage-preserving overscope behavior. - [ ] **Step 4: Run test to verify it passes** @@ -524,8 +585,6 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** -Ensure rejection reasons are stable machine-readable strings for retry logic. - Run: `uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py -v` Expected: PASS. @@ -534,44 +593,41 @@ Expected: PASS. ```bash git add src/atelier/lifecycle.py src/atelier/worker/selection.py src/atelier/templates/AGENTS.worker.md.tmpl src/atelier/templates/AGENTS.planner.md.tmpl tests/atelier/test_lifecycle.py tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py tests/atelier/test_worker_agents_template.py -git commit -m "feat(worker): fail closed on refined-work claim gate" -m "- Enforce refined-work approval and READY verdict at claimability boundaries. +git commit -m "feat(worker): fail closed on refined claim requirements" -m "- Reject refined work claims without approval evidence and READY verdict. - Keep unrefined claim behavior unchanged. -- Add template and selection tests for lineage-aware overscope handling." +- Add selection/template tests for lineage-aware overscope handling." ``` -### Task 7: Add compatibility alias and package the new skills +### Task 8: Add compatibility alias and package-level assertions **Files:** - Create: `src/atelier/skills/plan-refined-deliberation/SKILL.md` -- Modify: `src/atelier/skills.py` - Modify: `tests/atelier/test_skills.py` - [ ] **Step 1: Identify or write the failing test** -Add tests asserting packaged skill discovery includes: +Add tests asserting packaged skills include: - `planning`, - `refine-plan`, +- `plan-set-refinement`, - `plan-refined-deliberation` alias. - [ ] **Step 2: Run test to verify it fails** -Run: `uv run pytest tests/atelier/test_skills.py -k planning -v` +Run: `uv run pytest tests/atelier/test_skills.py -k "planning or refinement" -v` Expected: FAIL on missing skills. - [ ] **Step 3: Write minimal implementation** -Add alias skill that explicitly routes operators to `refine-plan` and documents -deprecation scope. +Add alias skill that delegates to `refine-plan` and marks deprecation scope. - [ ] **Step 4: Run test to verify it passes** -Run: `uv run pytest tests/atelier/test_skills.py -k planning -v` +Run: `uv run pytest tests/atelier/test_skills.py -k "planning or refinement" -v` Expected: PASS. - [ ] **Step 5: Refactor and verify** -Ensure frontmatter, naming, and package sync behavior are stable. - Run: `uv run pytest tests/atelier/test_skills.py tests/atelier/test_skill_frontmatter_validation.py -v` Expected: PASS. @@ -579,29 +635,29 @@ Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add src/atelier/skills/plan-refined-deliberation src/atelier/skills.py tests/atelier/test_skills.py -git commit -m "feat(skills): add refine-plan compatibility alias" -m "- Add plan-refined-deliberation alias that delegates to refine-plan. -- Ensure packaged skill discovery includes new planning/refinement skills. -- Add tests to prevent future skill-packaging drift." +git add src/atelier/skills/plan-refined-deliberation tests/atelier/test_skills.py +git commit -m "feat(skills): add refinement compatibility alias" -m "- Add plan-refined-deliberation alias to preserve older entry points. +- Keep refine-plan as the canonical iterative refinement skill. +- Extend packaging tests for new planning/refinement skills." ``` -### Task 8: Final verification and repo gates +### Task 9: Final verification and quality gates **Files:** -- Modify: all changed files from Tasks 1-7 as needed. +- Modify: changed files from Tasks 1-8 only when fixes are required. - [ ] **Step 1: Identify or write the failing test** -No new tests. Use full-suite verification as the gate. +No new tests; run full project gates. -- [ ] **Step 2: Run test to verify it fails (if anything regressed)** +- [ ] **Step 2: Run test to verify it fails (if regressions exist)** Run: `just test` -Expected: Either PASS or actionable failing tests that must be fixed. +Expected: PASS or actionable failures to fix. - [ ] **Step 3: Write minimal implementation** -Fix any regressions found by full-suite checks without weakening valid tests. +Fix regressions without weakening valid tests. - [ ] **Step 4: Run test to verify it passes** @@ -616,19 +672,21 @@ Expected: PASS for both. - [ ] **Step 6: Commit** +Commit only if Task 9 produced code/doc changes: + ```bash git add -A -git commit -m "feat(planning): converge atelier planning with trycycle refinement" -m "- Introduce planning/refine-plan skill architecture with trycycle-derived mechanics and doctrine. -- Persist refinement artifacts, enforce lineage propagation, and fail-closed claim gates. -- Update planner/worker templates, scripts, docs, and tests for a single-cutover rollout." +git commit -m "chore(planning): reconcile final verification fixes" -m "- Resolve full-suite regressions discovered during final gates. +- Keep changes scoped to quality-gate fixes only." ``` ## Completion checklist for this implementation -- [ ] `planning` skill is the primary doctrine for planning quality. -- [ ] `refine-plan` runs bounded stateless plan-edit rounds and persists evidence. -- [ ] refinement trigger semantics are explicit and documented (`refined`/`refinement`). +- [ ] `planning` is the primary doctrine for all planning quality. +- [ ] doctrine convergence includes trycycle tone/style, not only loop mechanics. +- [ ] `refine-plan` runs bounded stateless rounds with canonical verdict tokens. +- [ ] `plan-set-refinement` enables refinement on existing items at any stage. - [ ] refinement is opt-in, viral by lineage, and approval-gated. - [ ] worker claim blocks refined work without approval + `READY` evidence. -- [ ] trycycle prose/style has been merged into baseline planning doctrine. -- [ ] all tests, formatting, and lint gates pass. +- [ ] planner/worker templates codify refined trigger and lineage behavior. +- [ ] all tests and repo gates (`just test`, `just format`, `just lint`) pass. From ae10f2b387e650594196fa13ae54935a3d00f690 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:12:52 -0700 Subject: [PATCH 03/25] test(planning): add trycycle refinement convergence test plan - add a prioritized, user-visible test plan for trycycle refinement convergence - define required harnesses and dependency ordering before test implementation - cover refinement activation, lineage propagation, claim gating, doctrine parity, and differential checks against trycycle references - document explicit exclusions and residual risks in a coverage summary --- ...ycycle-refinement-convergence-test-plan.md | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md diff --git a/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md b/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md new file mode 100644 index 00000000..43eca18f --- /dev/null +++ b/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md @@ -0,0 +1,319 @@ +# Trycycle Refinement Convergence Test Plan + +Strategy reconciliation: +- The agreed strategy still holds after reading the implementation plan. +- No cost/scope changes are required for approval. +- The only adjustment is to add a repository-local trycycle reference fixture so + differential tests do not depend on external filesystem paths. + +## Harness requirements + +1. `H1: Planner Script + Store Integration Harness` (`extend`) +- What it does: Runs planning skill scripts against a real Atelier store built + on the in-memory Beads backend (and subprocess transport parity where useful). +- What it exposes: Script CLI invocation, stdout/stderr assertions, and post-run + issue graph/notes inspection via `atelier.store`. +- Estimated complexity: Medium. +- Tests depending on it: 5, 6, 7, 8, 9, 10. + +2. `H2: Refinement Artifact Fixture/Oracle Harness` (`new`) +- What it does: Generates canonical and malformed + `planning_refinement.v1` note blocks and expected winning/evaluation states. +- What it exposes: Programmatic artifact builders, parser inputs, and stable + expected gate outcomes. +- Estimated complexity: Low. +- Tests depending on it: 13, 14, 15. + +3. `H3: Trycycle Reference Snapshot Harness` (`new`) +- What it does: Stores repository-local snapshots of the trycycle planning + subskill and planning loop prompt/mechanics used as differential references. +- What it exposes: Versioned fixture text and anchor map for doctrine and loop + parity assertions. +- Estimated complexity: Medium. +- Tests depending on it: 1, 11, 12. + +4. `H4: Worker Startup Contract Service Harness` (`extend`) +- What it does: Exercises worker startup selection/claimability behavior through + `run_startup_contract_service` with typed fake service dependencies. +- What it exposes: User-visible startup result reasons and emitted diagnostics + without mocking internal lifecycle logic. +- Estimated complexity: Low. +- Tests depending on it: 3, 4, 14. + +5. `H5: Skill Packaging + Template Contract Harness` (`extend`) +- What it does: Validates packaged skill presence/frontmatter and planner/worker + template contract text. +- What it exposes: Projected skill inventory and AGENTS template assertions. +- Estimated complexity: Low. +- Tests depending on it: 2, 12, 16. + +Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. + +## Test plan + +1. **Name**: Convergence map captures full trycycle source inventory and + doctrine/mechanics mapping +- **Type**: regression +- **Disposition**: new +- **Harness**: `H3` +- **Preconditions**: `docs/trycycle-planning-convergence.md` exists and + trycycle reference snapshots are present in repo fixtures. +- **Actions**: Run + `uv run pytest tests/atelier/test_trycycle_planning_convergence.py -k "inventory or mapping" -v`. +- **Expected outcome**: Test passes only when the convergence doc includes each + required source input, explicit doctrine-vs-mechanics mapping, adaptation + rationale, and non-goals. + Source of truth: implementation plan "Trycycle convergence contract" and user + approved requirement for deep extraction. +- **Interactions**: `docs/trycycle-planning-convergence.md`, + `docs/behavior.md`, planning/refinement skill docs. + +2. **Name**: Planner guidance routes default planning to `planning` and refined + requests to `refine-plan` +- **Type**: scenario +- **Disposition**: extend +- **Harness**: `H5` +- **Preconditions**: Planner template and new skill docs are present. +- **Actions**: Run + `uv run pytest tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py -k "planning or refinement" -v`. +- **Expected outcome**: Template and packaged skill assertions confirm + `planning` is the doctrine default, `refined/refinement` routes to + `refine-plan`, and `plan-refined-deliberation` is compatibility alias only. + Source of truth: user-approved trigger semantics and implementation plan + user-visible behavior. +- **Interactions**: `AGENTS.planner.md.tmpl`, skill packaging/projection. + +3. **Name**: Worker startup rejects refined work that lacks approval evidence + or READY verdict +- **Type**: scenario +- **Disposition**: extend +- **Harness**: `H4` +- **Preconditions**: Refined epic/changeset metadata exists with + `required=true` and missing/invalid readiness evidence. +- **Actions**: Run + `uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_session_startup.py -k "refinement and claim" -v`. +- **Expected outcome**: Startup exits without claim and emits stable rejection + reasons for missing approval or non-`READY` verdict. + Source of truth: implementation plan "Claim gate invariant". +- **Interactions**: `lifecycle.py`, `worker/selection.py`, + `worker/session/startup.py`. + +4. **Name**: Unrefined startup/claim selection behavior remains unchanged +- **Type**: regression +- **Disposition**: extend +- **Harness**: `H4` +- **Preconditions**: Non-refined epic and ready changeset scenarios from current + startup suite remain available. +- **Actions**: Run + `uv run pytest tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py -k "selected_auto or selected_ready_changeset" -v`. +- **Expected outcome**: Existing unrefined paths still resolve actionable epics + and ready fallback exactly as before. + Source of truth: implementation plan "Unrefined flows remain behaviorally + unchanged". +- **Interactions**: Worker startup selector pipeline and ready-changeset + fallback logic. + +5. **Name**: Planner can enable refinement on existing work at any lifecycle + point +- **Type**: integration +- **Disposition**: new +- **Harness**: `H1` +- **Preconditions**: Existing epic/changeset records in `deferred`, `open`, + `in_progress`, and `blocked` states. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py -k "lifecycle" -v`. +- **Expected outcome**: `plan-set-refinement` appends authoritative + refinement metadata with mode/source/budgets and succeeds across allowed + lifecycle states. + Source of truth: locked user requirement + implementation plan "Activation and + approval invariant". +- **Interactions**: `plan-set-refinement` script, store note append and readback. + +6. **Name**: Required refinement cannot be enabled without explicit approval + evidence +- **Type**: boundary +- **Disposition**: new +- **Harness**: `H1` +- **Preconditions**: Existing work item, refinement request with + `required=true`, missing approval fields. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py -k "approval" -v`. +- **Expected outcome**: Script fails closed with deterministic error output and + no persisted authoritative block. + Source of truth: locked user requirement "refinement requires explicit + approval". +- **Interactions**: refinement activation validation and note persistence. + +7. **Name**: Changeset creation inherits refinement lineage from refined parent +- **Type**: integration +- **Disposition**: extend +- **Harness**: `H1` +- **Preconditions**: Parent epic contains authoritative required refinement + block; sibling unrefined parent exists for control case. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py -k "refinement or inherited" -v`. +- **Expected outcome**: New child changesets under refined lineage carry + inherited required refinement metadata and budgets; unrefined lineage does not. + Source of truth: locked user requirement "refinement is viral by lineage". +- **Interactions**: create-epic/create-changeset scripts and store create APIs. + +8. **Name**: Splitting overscoped work preserves refinement lineage in all + descendants +- **Type**: scenario +- **Disposition**: new +- **Harness**: `H1` +- **Preconditions**: Parent changeset exists in refined and unrefined variants. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_split_tasks_script.py -v`. +- **Expected outcome**: `plan-split-tasks` marks all descendants as inherited + required refinement when parent lineage is refined and leaves unrefined trees + unmarked. + Source of truth: locked user requirement on overscope split behavior. +- **Interactions**: split script, graph parent/child writes, refinement metadata. + +9. **Name**: Guardrail checks report missing refinement contract evidence +- **Type**: integration +- **Disposition**: extend +- **Harness**: `H1` +- **Preconditions**: Refined changeset exists with incomplete approval/verdict + fields. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -k "refinement" -v`. +- **Expected outcome**: Guardrail report flags missing refinement completeness + details; fully populated refined records pass. + Source of truth: implementation plan action item for refinement completeness + checks. +- **Interactions**: `check_guardrails.py`, planner authoring contract checks. + +10. **Name**: Promotion preview blocks non-ready refined execution paths and + surfaces missing sections +- **Type**: scenario +- **Disposition**: extend +- **Harness**: `H1` +- **Preconditions**: Deferred epic with child refined changeset lacking approval + or `READY` verdict. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -k "refinement or missing detail" -v`. +- **Expected outcome**: Preview output includes explicit missing refinement + sections; promotion does not apply lifecycle transition until requirements are + satisfied. + Source of truth: implementation plan user-visible promotion/readiness behavior. +- **Interactions**: promote script preview renderer and lifecycle transitions. + +11. **Name**: Refine-plan loop honors trycycle verdict protocol and bounded + rounds +- **Type**: differential +- **Disposition**: new +- **Harness**: `H3` +- **Preconditions**: `refine-plan` runner and prompt-builder modules exist; + trycycle loop reference snapshot is committed. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_refine_plan_script.py -k "verdict or max_rounds or non_convergence" -v`. +- **Expected outcome**: Defaults and behavior match reference strategy: + planning-edit cap `5`, canonical verdict tokens + `READY|REVISED|USER_DECISION_REQUIRED`, fail-closed non-convergence. + Source of truth: trycycle `SKILL.md`, `subagents/prompt-planning-edit.md`, + and `orchestrator/run_phase.py`. +- **Interactions**: `refine-plan` loop engine, prompt assembly, result parsing. + +12. **Name**: Planning doctrine preserves trycycle planning tone/emphasis, not + just mechanics +- **Type**: differential +- **Disposition**: new +- **Harness**: `H3`, `H5` +- **Preconditions**: `planning` skill and doctrine reference file exist. +- **Actions**: Run + `uv run pytest tests/atelier/skills/test_planning_skill_contract.py -v`. +- **Expected outcome**: Doctrine includes required emphasis categories from + reference planning prose (strategy gate, low bar to replan, high bar to user + interruption, bite-sized tasking, explicit invariants/contracts). + Source of truth: user-approved direction and trycycle + `subskills/trycycle-planning/SKILL.md`. +- **Interactions**: planning skill docs, planner template contract tests. + +13. **Name**: Refinement artifact parser deterministically selects the winning + authoritative block +- **Type**: invariant +- **Disposition**: new +- **Harness**: `H2` +- **Preconditions**: Parser module exists and fixture builder can emit multiple + ordered note blocks. +- **Actions**: Run + `uv run pytest tests/atelier/test_planning_refinement.py -k "authoritative or newest" -v`. +- **Expected outcome**: Newest authoritative valid block wins; otherwise newest + valid block wins; deterministic across permutations. + Source of truth: implementation plan parser rules. +- **Interactions**: `planning_refinement.py` parse/select functions. + +14. **Name**: Malformed or unknown verdict refinement evidence fails closed only + when refinement is required +- **Type**: boundary +- **Disposition**: new +- **Harness**: `H2`, `H4` +- **Preconditions**: One refined required item and one unrefined control item. +- **Actions**: Run + `uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/worker/test_session_startup.py -k "malformed or verdict" -v`. +- **Expected outcome**: Required refined work is blocked with stable reason; + unrefined work remains eligible despite malformed optional metadata. + Source of truth: parser fail-closed scope in implementation plan. +- **Interactions**: parser + startup claimability evaluation. + +15. **Name**: Refinement parser performance avoids catastrophic startup + regressions +- **Type**: boundary +- **Disposition**: new +- **Harness**: `H2` +- **Preconditions**: Synthetic note payload generator available. +- **Actions**: Run + `uv run pytest tests/atelier/test_planning_refinement.py -k "performance" -v`. +- **Expected outcome**: Parsing a 1,000-block note payload completes below a + generous catastrophic-regression threshold (for example `<1s` on CI class + hardware). + Source of truth: worker startup repeatedly evaluates claimability and must + remain practical. +- **Interactions**: startup-critical parsing path. + +16. **Name**: New planning/refinement skills ship and validate in projected + workspaces +- **Type**: regression +- **Disposition**: extend +- **Harness**: `H5` +- **Preconditions**: New skills and alias are added under `src/atelier/skills`. +- **Actions**: Run + `uv run pytest tests/atelier/test_skills.py tests/atelier/test_skill_frontmatter_validation.py -k "planning or refinement" -v`. +- **Expected outcome**: Packaged skill inventory includes `planning`, + `refine-plan`, `plan-set-refinement`, and `plan-refined-deliberation`; all + frontmatter validation passes. + Source of truth: implementation plan file structure and migration + compatibility requirement. +- **Interactions**: skill packaging, sync/install, frontmatter validator. + +## Coverage summary + +Covered action space: +- Planner-facing refinement actions: + `plan-set-refinement`, `plan-create-epic`, `plan-changesets`, + `plan-split-tasks`, `plan-changeset-guardrails`, `plan-promote-epic`. +- Planning doctrine and trigger behavior: + planner template routing, packaged planning/refinement skills, doctrine + content parity with trycycle references. +- Refine-plan orchestration behavior: + verdict protocol, bounded loop semantics, fail-closed non-convergence, + prompt/reference parity. +- Worker-facing claim behavior: + startup claim rejection for incomplete required refinement and unchanged + behavior for unrefined work. +- Data contract behavior: + refinement artifact parsing, winning block selection, malformed input handling, + and performance envelope. + +Explicit exclusions per strategy: +- Real network/API integration with GitHub or external providers is excluded; + tests stay local and deterministic. + Risk: provider-specific failures could still appear in production. +- Full live multi-subagent orchestration (real Codex/Kimi/Claude sessions) is + excluded; tests validate Atelier-side contracts and scripts only. + Risk: runner integration edge cases may require follow-up in integration envs. +- Visual/manual prompt quality review is excluded; doctrine/tone checks are + enforced via reproducible text assertions and differential fixtures. + Risk: subtle prose quality regressions not represented by assertions may slip. From fe15582a2d4ce00fde95df295f3305d01e9dee36 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:18:27 -0700 Subject: [PATCH 04/25] docs(planning): add trycycle convergence source map - Add a source-backed mapping of trycycle doctrine and iterative mechanics. - Document Atelier adaptation boundaries and non-goals. - Add regression tests and local trycycle reference fixtures for convergence drift. --- docs/behavior.md | 12 +++ docs/trycycle-planning-convergence.md | 84 +++++++++++++++++++ .../reference_anchors.json | 27 ++++++ .../trycycle-planning-loop.snapshot.md | 16 ++++ .../trycycle-planning-skill.snapshot.md | 13 +++ .../test_trycycle_planning_convergence.py | 59 +++++++++++++ 6 files changed, 211 insertions(+) create mode 100644 docs/trycycle-planning-convergence.md create mode 100644 tests/atelier/fixtures/trycycle_refinement/reference_anchors.json create mode 100644 tests/atelier/fixtures/trycycle_refinement/trycycle-planning-loop.snapshot.md create mode 100644 tests/atelier/fixtures/trycycle_refinement/trycycle-planning-skill.snapshot.md create mode 100644 tests/atelier/test_trycycle_planning_convergence.py diff --git a/docs/behavior.md b/docs/behavior.md index 61b30fbb..d9cc2390 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -55,6 +55,18 @@ side effects inside the user's repo. - **External tickets**: Linked via `external_tickets` in bead descriptions with provider labels like `ext:github`. +## Planning and refinement + +- `planning` is the default planning doctrine across planner workflows. +- `refine-plan` runs bounded iterative refinement with canonical verdicts: + `READY`, `REVISED`, and `USER_DECISION_REQUIRED`. +- `plan-set-refinement` can enable refinement on an existing epic or changeset + in lifecycle states `deferred`, `open`, `in_progress`, and `blocked`. +- Refinement metadata is inherited by lineage descendants so child/split work + stays refinement-aware once a lineage root is required. +- Worker claim fails closed when required refinement evidence is missing + approval details or a latest `READY` verdict. + ## Filesystem layout Project directory (under the Atelier data dir): diff --git a/docs/trycycle-planning-convergence.md b/docs/trycycle-planning-convergence.md new file mode 100644 index 00000000..a3830c7e --- /dev/null +++ b/docs/trycycle-planning-convergence.md @@ -0,0 +1,84 @@ +# Trycycle Planning Convergence + +This document records how trycycle planning behavior converges into Atelier's +native `planning` and `refine-plan` skills while preserving Atelier persistence +contracts. + +## Source inventory + +The convergence references these trycycle sources. + +- `subskills/trycycle-planning/SKILL.md` +- `subagents/prompt-planning-initial.md` +- `subagents/prompt-planning-edit.md` +- `orchestrator/run_phase.py` +- `orchestrator/prompt_builder/build.py` +- `orchestrator/prompt_builder/template_ast.py` +- `orchestrator/prompt_builder/validate_rendered.py` + +## Doctrine mapping + +The baseline planning doctrine is extracted into Atelier `planning`. + +- `subskills/trycycle-planning/SKILL.md`: Mapped to `planning` + - Anchor: `Strategy Gate (before task breakdown)` + - Anchor: `Low bar for changing direction.` + - Anchor: `High bar for stopping to ask the user.` + - Anchor: `Bite-Sized Task Granularity` + - Anchor: `Completion Standard` +- `subagents/prompt-planning-initial.md`: Mapped to `planning` + - Anchor: request framing and planning ownership language. +- `subagents/prompt-planning-edit.md`: Mapped to `planning` + - Anchor: judgment and proportional edit/rewrite expectations. + +## Mechanics mapping + +The iterative loop mechanics are extracted into `refine-plan`. + +- `subagents/prompt-planning-initial.md`: Mapped to `refine-plan` + - Anchor: + + ```text + Task: + - Review the `trycycle-planning` skill + ``` + + - Anchor: `## Plan verdict` +- `subagents/prompt-planning-edit.md`: Mapped to `refine-plan` + - Anchor: `REVISED` + - Anchor: `READY` +- `orchestrator/run_phase.py`: Mapped to `refine-plan` + - Anchor: `_prepare_phase` + - Anchor: `_command_run` + - Anchor: `prompt_builder` +- `orchestrator/prompt_builder/build.py`: Mapped to `refine-plan` + - Anchor: render and placeholder-binding contract. +- `orchestrator/prompt_builder/template_ast.py`: Mapped to `refine-plan` + - Anchor: prompt-template parsing contract. +- `orchestrator/prompt_builder/validate_rendered.py`: Mapped to `refine-plan` + - Anchor: fail-closed rendered prompt validation contract. + +## Atelier adaptation rationale + +- Atelier stores authoritative refinement state in bead notes rather than + ephemeral process memory. +- Doctrine (`planning`) and mechanism (`refine-plan`) are split to keep + non-iterative planning clear and reusable. +- Worker claimability gates derive from persisted verdict and approval evidence, + not transient planner output. +- Convergence keeps trycycle's strategic tone while adapting boundary contracts + to Atelier's Beads-backed persistence model. + +## Non-goals + +- Replacing Atelier's Beads store or introducing a separate planner state + service. +- Auto-enabling refinement for all work items without explicit activation. +- Inferring approval from prose-only notes. +- Coupling unrefined claimability behavior to refinement-only guardrails. + +See [Atelier Behavior and Design Notes] for the user-visible behavior contract. + + + +[atelier behavior and design notes]: ./behavior.md diff --git a/tests/atelier/fixtures/trycycle_refinement/reference_anchors.json b/tests/atelier/fixtures/trycycle_refinement/reference_anchors.json new file mode 100644 index 00000000..a1f26f6d --- /dev/null +++ b/tests/atelier/fixtures/trycycle_refinement/reference_anchors.json @@ -0,0 +1,27 @@ +{ + "inventory_sources": [ + "subskills/trycycle-planning/SKILL.md", + "subagents/prompt-planning-initial.md", + "subagents/prompt-planning-edit.md", + "orchestrator/run_phase.py", + "orchestrator/prompt_builder/build.py", + "orchestrator/prompt_builder/template_ast.py", + "orchestrator/prompt_builder/validate_rendered.py" + ], + "doctrine_anchors": [ + "Strategy Gate (before task breakdown)", + "Low bar for changing direction.", + "High bar for stopping to ask the user.", + "Bite-Sized Task Granularity", + "Completion Standard" + ], + "mechanics_anchors": [ + "Review the `trycycle-planning` skill", + "## Plan verdict", + "REVISED", + "READY", + "_prepare_phase", + "_command_run", + "prompt_builder" + ] +} diff --git a/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-loop.snapshot.md b/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-loop.snapshot.md new file mode 100644 index 00000000..b1e4bc06 --- /dev/null +++ b/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-loop.snapshot.md @@ -0,0 +1,16 @@ +# prompt-planning-initial.md + +- Review the `trycycle-planning` skill. +- Use the `trycycle-planning` skill to produce a complete implementation plan. +- Return `## Plan verdict` with `CREATED`. + +# prompt-planning-edit.md + +- Diagnose the plan completely. +- Return `## Plan verdict` with `REVISED` or `READY`. + +# orchestrator/run_phase.py + +- \_prepare_phase +- \_command_run +- prompt_builder diff --git a/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-skill.snapshot.md b/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-skill.snapshot.md new file mode 100644 index 00000000..0519301f --- /dev/null +++ b/tests/atelier/fixtures/trycycle_refinement/trycycle-planning-skill.snapshot.md @@ -0,0 +1,13 @@ +# Writing Plans + +## Strategy Gate (before task breakdown) + +**Low bar for changing direction.** Big rewrites, architecture resets, and fresh +replans are always acceptable when they produce a better answer. + +**High bar for stopping to ask the user.** Use best judgment and keep going +unless there is genuinely no safe path forward without a user decision. + +## Bite-Sized Task Granularity + +## Completion Standard diff --git a/tests/atelier/test_trycycle_planning_convergence.py b/tests/atelier/test_trycycle_planning_convergence.py new file mode 100644 index 00000000..1a9cb5f4 --- /dev/null +++ b/tests/atelier/test_trycycle_planning_convergence.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CONVERGENCE_DOC = _REPO_ROOT / "docs" / "trycycle-planning-convergence.md" +_BEHAVIOR_DOC = _REPO_ROOT / "docs" / "behavior.md" +_ANCHOR_FIXTURE = ( + _REPO_ROOT / "tests" / "atelier" / "fixtures" / "trycycle_refinement" / "reference_anchors.json" +) + + +def _read_anchor_fixture() -> dict[str, list[str]]: + return json.loads(_ANCHOR_FIXTURE.read_text(encoding="utf-8")) + + +def _assert_contains_all(content: str, expected: list[str], *, section: str) -> None: + missing = [token for token in expected if token not in content] + assert not missing, f"missing {section} content: {missing}" + + +def test_convergence_doc_has_required_inventory_and_mapping_contract() -> None: + fixture = _read_anchor_fixture() + content = _CONVERGENCE_DOC.read_text(encoding="utf-8") + + _assert_contains_all( + content, + [ + "# Trycycle Planning Convergence", + "## Source inventory", + "## Doctrine mapping", + "## Mechanics mapping", + "## Atelier adaptation rationale", + "## Non-goals", + "Mapped to `planning`", + "Mapped to `refine-plan`", + ], + section="required headings", + ) + _assert_contains_all(content, fixture["inventory_sources"], section="source inventory") + _assert_contains_all(content, fixture["doctrine_anchors"], section="doctrine anchors") + _assert_contains_all(content, fixture["mechanics_anchors"], section="mechanics anchors") + + +def test_behavior_doc_documents_refinement_activation_lineage_and_claim_gate() -> None: + content = _BEHAVIOR_DOC.read_text(encoding="utf-8") + _assert_contains_all( + content, + [ + "Planning and refinement", + "`planning` is the default planning doctrine", + "`refine-plan` runs bounded iterative refinement", + "`plan-set-refinement` can enable refinement", + "Refinement metadata is inherited by lineage descendants", + "Worker claim fails closed when required refinement evidence", + ], + section="behavior refinements", + ) From cc70d1585a8cdaa88df8332fbbe6c97513375b70 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:22:10 -0700 Subject: [PATCH 05/25] feat(planning): add baseline doctrine skill - Introduce a reusable planning doctrine skill from Atelier + trycycle guidance. - Route planner behavior through the doctrine skill instead of template-only prose. - Add contract tests to prevent doctrine drift. --- src/atelier/skills/planning/SKILL.md | 31 ++++++++++ .../planning/references/planning-doctrine.md | 43 ++++++++++++++ src/atelier/templates/AGENTS.planner.md.tmpl | 7 +++ .../skills/test_planning_skill_contract.py | 56 +++++++++++++++++++ tests/atelier/test_planner_agents_template.py | 4 ++ tests/atelier/test_skills.py | 9 +++ 6 files changed, 150 insertions(+) create mode 100644 src/atelier/skills/planning/SKILL.md create mode 100644 src/atelier/skills/planning/references/planning-doctrine.md create mode 100644 tests/atelier/skills/test_planning_skill_contract.py diff --git a/src/atelier/skills/planning/SKILL.md b/src/atelier/skills/planning/SKILL.md new file mode 100644 index 00000000..64465e14 --- /dev/null +++ b/src/atelier/skills/planning/SKILL.md @@ -0,0 +1,31 @@ +--- +name: planning +description: >- + Default planning doctrine for Atelier planner sessions, including strategy + gates and execution-ready decomposition standards. +--- + +# Planning + +Use this skill as the baseline doctrine for all planning flows. + +## Doctrine source + +- Primary reference: `references/planning-doctrine.md` +- Capture executable intent with explicit `intent, rationale, non-goals`, + constraints, edge cases, and done definition fields. + +## Planning contract + +1. Run a strategy gate before decomposition. +1. Keep a low bar for replanning when a better architecture appears. +1. Keep a high bar for user interruption; only interrupt on genuine blockers. +1. Shape bite-sized, execution-oriented tasks with explicit red/green/refactor + checks. +1. Preserve deterministic, test-first behavior and frequent commit cadence. + +## Refinement handoff + +- Standard planning requests stay in `planning` doctrine. +- Refined requests route to `refine-plan` for iterative verdict rounds. +- Use `plan-set-refinement` when refinement must be enabled on existing work. diff --git a/src/atelier/skills/planning/references/planning-doctrine.md b/src/atelier/skills/planning/references/planning-doctrine.md new file mode 100644 index 00000000..eebe0673 --- /dev/null +++ b/src/atelier/skills/planning/references/planning-doctrine.md @@ -0,0 +1,43 @@ +# Planning Doctrine + +This reference defines the baseline planning doctrine used by Atelier. + +## Intent framing + +Record clear intent so a worker understands why the work exists. + +## Rationale capture + +Explain the chosen approach and why alternatives were not selected. + +## Non-goals + +State explicit non-goals to keep scope bounded and avoid drift. + +## Strategy gate + +Before task decomposition, challenge the framing and architecture. + +- Strategy Gate (before task breakdown) +- Low bar for changing direction. +- High bar for stopping to ask the user. + +## Low bar for replanning + +Replan whenever a materially better implementation path appears. + +## High bar for user interruption + +Interrupt only for genuine blockers with no safe autonomous path. + +## Bite-sized decomposition + +Break work into small, independently testable execution units. + +- Bite-Sized Task Granularity + +## Execution-first task shaping + +Each task should follow red/green/refactor and end with required checks. + +- Completion Standard diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index df5044ad..f4683ee1 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -47,6 +47,13 @@ Atelier works only when intent is explicit and written down. Your job is to turn intent into durable, actionable beads. Do not wait for confirmation before recording a plan. Ask only when a decision is required. +## Planning Doctrine Routing + +- `planning` is the default planning doctrine for all planning requests. +- If the user request includes `refined` or `refinement`, run `refine-plan` before dispatch or promotion. +- Use `plan-set-refinement` when refinement must be enabled on existing epic or + changeset beads after initial creation. + ## Your Role: Planning Agent ({{ agent_id }}) You define intent, shape executable work into human-sized changesets, and diff --git a/tests/atelier/skills/test_planning_skill_contract.py b/tests/atelier/skills/test_planning_skill_contract.py new file mode 100644 index 00000000..25690070 --- /dev/null +++ b/tests/atelier/skills/test_planning_skill_contract.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_PLANNING_SKILL = _REPO_ROOT / "src" / "atelier" / "skills" / "planning" / "SKILL.md" +_DOCTRINE_REFERENCE = ( + _REPO_ROOT / "src" / "atelier" / "skills" / "planning" / "references" / "planning-doctrine.md" +) +_TRYCYCLE_FIXTURE = ( + _REPO_ROOT / "tests" / "atelier" / "fixtures" / "trycycle_refinement" / "reference_anchors.json" +) + + +def _assert_contains_all(content: str, expected: list[str], *, label: str) -> None: + missing = [token for token in expected if token not in content] + assert not missing, f"missing {label}: {missing}" + + +def test_planning_skill_contract_mentions_doctrine_and_refinement_handoff() -> None: + content = _PLANNING_SKILL.read_text(encoding="utf-8") + _assert_contains_all( + content, + [ + "# Planning", + "references/planning-doctrine.md", + "intent, rationale, non-goals", + "strategy gate", + "low bar for replanning", + "high bar for user interruption", + "bite-sized", + "execution-oriented", + ], + label="planning skill contract", + ) + + +def test_planning_doctrine_preserves_trycycle_planning_emphasis() -> None: + fixture = json.loads(_TRYCYCLE_FIXTURE.read_text(encoding="utf-8")) + content = _DOCTRINE_REFERENCE.read_text(encoding="utf-8") + _assert_contains_all(content, fixture["doctrine_anchors"], label="trycycle doctrine anchors") + _assert_contains_all( + content, + [ + "Intent framing", + "Rationale capture", + "Non-goals", + "Strategy gate", + "Low bar for replanning", + "High bar for user interruption", + "Bite-sized decomposition", + "Execution-first task shaping", + ], + label="planning doctrine sections", + ) diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index e3376825..a6f41410 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -60,3 +60,7 @@ def test_planner_agents_template_contains_core_sections() -> None: assert "concrete issue, create or update a deferred bead immediately" in content assert "Create or update deferred beads immediately" in content assert "Capture first, then ask only for decisions" in content + assert "`planning` is the default planning doctrine" in content + assert "If the user request includes `refined` or `refinement`" in content + assert "run `refine-plan` before dispatch or promotion" in content + assert "Use `plan-set-refinement`" in content diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 230d316f..16f2a4ab 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -43,6 +43,7 @@ def test_packaged_skills_include_core_set() -> None: "plan-changeset-guardrails", "plan-promote-epic", "planner-startup-check", + "planning", }.issubset(names) assert all("_" not in name for name in names) @@ -95,6 +96,7 @@ def test_install_workspace_skills_writes_skill_docs() -> None: "plan-changeset-guardrails", "plan-promote-epic", "planner-startup-check", + "planning", ): assert (workspace_dir / "skills" / name / "SKILL.md").exists() @@ -353,6 +355,13 @@ def test_planner_startup_check_skill_captures_drafts_without_approval() -> None: assert "Do not wait for approval to capture deferred work." in text +def test_planning_skill_includes_doctrine_reference() -> None: + skill = skills.load_packaged_skills()["planning"] + text = skill.files["SKILL.md"].decode("utf-8") + assert "references/planning-doctrine.md" in text + assert "intent, rationale, non-goals" in text + + def test_workspace_skill_state_accepts_legacy_underscore_metadata_keys() -> None: with tempfile.TemporaryDirectory() as tmp: project_dir = Path(tmp) From 72bf973fbbec0d0e0eff0f8c2382ab16e85c8960 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:27:36 -0700 Subject: [PATCH 06/25] feat(refinement): add artifact contract and policy defaults - Add typed refinement artifact parsing and gate-evaluation helpers. - Add project/user policy defaults with trycycle-aligned budgets. - Add tests for parsing, verdicts, and config validation. --- src/atelier/config.py | 42 ++- src/atelier/models.py | 51 +++- src/atelier/planning_refinement.py | 349 ++++++++++++++++++++++ tests/atelier/test_models.py | 30 ++ tests/atelier/test_planning_refinement.py | 90 ++++++ 5 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 src/atelier/planning_refinement.py create mode 100644 tests/atelier/test_planning_refinement.py diff --git a/src/atelier/config.py b/src/atelier/config.py index c8ba6827..985e0139 100644 --- a/src/atelier/config.py +++ b/src/atelier/config.py @@ -34,6 +34,8 @@ AtelierUserSection, BranchConfig, EditorConfig, + PlanningRefinementConfig, + PlanningSection, ProjectConfig, ProjectSection, ProjectSystemConfig, @@ -120,7 +122,7 @@ def _backup_legacy_config(path: Path) -> None: def _split_project_payload(payload: dict) -> tuple[dict, dict]: user_payload: dict = {} - for key in ("branch", "worker", "agent", "editor", "git"): + for key in ("branch", "worker", "planning", "agent", "editor", "git"): if key in payload: user_payload[key] = payload.get(key) project_payload = payload.get("project") @@ -136,7 +138,7 @@ def _split_project_payload(payload: dict) -> tuple[dict, dict]: if "atelier" in payload and "upgrade" in payload.get("atelier", {}): user_payload["atelier"] = {"upgrade": upgrade} system_payload = dict(payload) - for key in ("branch", "worker", "agent", "editor", "git"): + for key in ("branch", "worker", "planning", "agent", "editor", "git"): system_payload.pop(key, None) project_system = dict(system_payload.get("project", {}) or {}) for key in ("provider", "provider_url", "owner"): @@ -225,7 +227,7 @@ def merge_project_configs( system_payload = system_config.model_dump() user_payload = (user_config or ProjectUserConfig()).model_dump() merged = dict(system_payload) - for key in ("branch", "worker", "agent", "editor", "git"): + for key in ("branch", "worker", "planning", "agent", "editor", "git"): merged[key] = user_payload.get(key, {}) system_project = system_payload.get("project", {}) if system_payload else {} user_project = user_payload.get("project", {}) if user_payload else {} @@ -350,6 +352,40 @@ def resolve_branch_config(config: ProjectConfig | dict) -> BranchConfig: die(f"invalid branch config:\n{exc}") +def resolve_planning_config( + config: ProjectConfig | ProjectUserConfig | dict | None, +) -> PlanningSection: + """Resolve planning configuration from project config payloads. + + Args: + config: Project configuration model or raw payload. + + Returns: + Parsed planning section with defaults. + """ + if isinstance(config, (ProjectConfig, ProjectUserConfig)): + return config.planning + planning_payload = config.get("planning") if isinstance(config, dict) else None + try: + return PlanningSection.model_validate(planning_payload or {}) + except ValidationError as exc: + die(f"invalid planning config:\n{exc}") + + +def resolve_refinement_policy( + config: ProjectConfig | ProjectUserConfig | dict | None, +) -> PlanningRefinementConfig: + """Resolve planning refinement policy and budgets from config payloads. + + Args: + config: Project configuration model or raw payload. + + Returns: + Parsed refinement policy with default trycycle budgets. + """ + return resolve_planning_config(config).refinement + + def resolve_branch_pr(branch_config: BranchConfig) -> bool: """Return whether pull requests are expected for the branch config. diff --git a/src/atelier/models.py b/src/atelier/models.py index 51d2c799..89fdac37 100644 --- a/src/atelier/models.py +++ b/src/atelier/models.py @@ -5,7 +5,7 @@ import re from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from . import agents @@ -26,6 +26,8 @@ BEADS_RUNTIME_MODE_VALUES = ("dolt-server",) BeadsRuntimeMode = Literal["dolt-server"] BEADS_PREFIX_PATTERN = re.compile(r"^[a-z][a-z0-9]{0,15}$") +DEFAULT_REFINEMENT_PLAN_EDIT_ROUNDS_MAX = 5 +DEFAULT_REFINEMENT_POST_IMPL_REVIEW_ROUNDS_MAX = 8 class BranchConfig(BaseModel): @@ -153,6 +155,51 @@ def normalize_select(cls, value: object) -> object: raise ValueError("select must be one of: " + ", ".join(WORKER_SELECT_VALUES)) +class PlanningRefinementConfig(BaseModel): + """Planning refinement policy and round budgets. + + Attributes: + required_by_default: Whether project policy marks new work refined by + default. + plan_edit_rounds_max: Maximum planning-edit rounds for refinement. + post_impl_review_rounds_max: Maximum post-implementation review rounds. + """ + + model_config = ConfigDict(extra="allow") + + required_by_default: bool = False + plan_edit_rounds_max: int = DEFAULT_REFINEMENT_PLAN_EDIT_ROUNDS_MAX + post_impl_review_rounds_max: int = DEFAULT_REFINEMENT_POST_IMPL_REVIEW_ROUNDS_MAX + + @field_validator( + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + mode="before", + ) + @classmethod + def normalize_round_limits(cls, value: object) -> object: + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + return value + + @field_validator("plan_edit_rounds_max", "post_impl_review_rounds_max") + @classmethod + def validate_round_limits(cls, value: int, info: ValidationInfo) -> int: + if value < 1 or value > 64: + raise ValueError(f"{info.field_name} must be between 1 and 64") + return value + + +class PlanningSection(BaseModel): + """Planning-specific configuration for a project.""" + + model_config = ConfigDict(extra="allow") + + refinement: PlanningRefinementConfig = Field(default_factory=PlanningRefinementConfig) + + class GitSection(BaseModel): """Git configuration for a project. @@ -627,6 +674,7 @@ class ProjectConfig(BaseModel): git: GitSection = Field(default_factory=GitSection) branch: BranchConfig = Field(default_factory=BranchConfig) worker: WorkerConfig = Field(default_factory=WorkerConfig) + planning: PlanningSection = Field(default_factory=PlanningSection) agent: AgentConfig = Field(default_factory=AgentConfig) editor: EditorConfig = Field(default_factory=EditorConfig) beads: BeadsSection = Field(default_factory=BeadsSection) @@ -652,6 +700,7 @@ class ProjectUserConfig(BaseModel): git: GitSection = Field(default_factory=GitSection) branch: BranchConfig = Field(default_factory=BranchConfig) worker: WorkerConfig = Field(default_factory=WorkerConfig) + planning: PlanningSection = Field(default_factory=PlanningSection) agent: AgentConfig = Field(default_factory=AgentConfig) editor: EditorConfig = Field(default_factory=EditorConfig) atelier: AtelierUserSection = Field(default_factory=AtelierUserSection) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py new file mode 100644 index 00000000..f666fad6 --- /dev/null +++ b/src/atelier/planning_refinement.py @@ -0,0 +1,349 @@ +"""Utilities for planning refinement note artifacts. + +This module parses ``planning_refinement.v1`` note blocks, selects the winning +artifact according to authoritative precedence rules, and evaluates claim-gate +readiness for refined work. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError, field_validator + +DEFAULT_PLAN_EDIT_ROUNDS_MAX: Final[int] = 5 +DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX: Final[int] = 8 +_REFINEMENT_MARKER: Final[str] = "planning_refinement.v1" +_TRUE_TOKENS: Final[frozenset[str]] = frozenset({"true", "1", "yes"}) +_FALSE_TOKENS: Final[frozenset[str]] = frozenset({"false", "0", "no"}) + +RefinementMode = Literal["requested", "inherited", "project_policy"] +ApprovalStatus = Literal["approved", "missing"] +ApprovalSource = Literal["project_policy", "operator"] +RefinementVerdict = Literal["READY", "REVISED", "USER_DECISION_REQUIRED"] + + +class PlanningRefinementRecord(BaseModel): + """Structured ``planning_refinement.v1`` note payload. + + Attributes: + authoritative: Whether this block is authoritative. + mode: How refinement was activated. + required: Whether refinement is required for claimability. + lineage_root: Origin work item id for inherited lineage. + approval_status: Approval state for required refinement. + approval_source: Approval source when approved. + approved_by: Principal id that approved refinement. + approved_at: Approval timestamp. + plan_edit_rounds_max: Maximum refinement edit rounds. + post_impl_review_rounds_max: Maximum post-implementation review rounds. + plan_edit_rounds_used: Refinement rounds already consumed. + latest_verdict: Most recent refinement verdict. + initial_plan_path: First plan artifact path. + latest_plan_path: Latest plan artifact path. + round_log_dir: Round artifact directory path. + """ + + model_config = ConfigDict(extra="forbid") + + authoritative: bool = False + mode: RefinementMode = "requested" + required: bool = False + lineage_root: str | None = None + approval_status: ApprovalStatus = "missing" + approval_source: ApprovalSource | None = None + approved_by: str | None = None + approved_at: str | None = None + plan_edit_rounds_max: int = DEFAULT_PLAN_EDIT_ROUNDS_MAX + post_impl_review_rounds_max: int = DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX + plan_edit_rounds_used: int | None = None + latest_verdict: RefinementVerdict | None = None + initial_plan_path: str | None = None + latest_plan_path: str | None = None + round_log_dir: str | None = None + + @field_validator("authoritative", "required", mode="before") + @classmethod + def _normalize_bool_fields(cls, value: object) -> object: + parsed = _parse_bool_token(value) + if parsed is None: + return value + return parsed + + @field_validator("mode", mode="before") + @classmethod + def _normalize_mode(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + + @field_validator("approval_status", mode="before") + @classmethod + def _normalize_approval_status(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + + @field_validator("approval_source", mode="before") + @classmethod + def _normalize_approval_source(cls, value: object) -> object: + if isinstance(value, str): + normalized = value.strip().lower() + return normalized or None + return value + + @field_validator("latest_verdict", mode="before") + @classmethod + def _normalize_latest_verdict(cls, value: object) -> object: + if isinstance(value, str): + normalized = value.strip().upper() + return normalized or None + return value + + @field_validator( + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + mode="before", + ) + @classmethod + def _normalize_ints(cls, value: object) -> object: + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + return value + + @field_validator("plan_edit_rounds_max", "post_impl_review_rounds_max") + @classmethod + def _validate_round_limits(cls, value: int) -> int: + if value < 1 or value > 64: + raise ValueError("round limits must be between 1 and 64") + return value + + @field_validator("plan_edit_rounds_used") + @classmethod + def _validate_rounds_used(cls, value: int | None) -> int | None: + if value is None: + return None + if value < 0: + raise ValueError("plan_edit_rounds_used must be >= 0") + return value + + +@dataclass(frozen=True) +class ParsedRefinementBlock: + """One parsed refinement block extracted from notes. + + Attributes: + ordinal: 0-based block index in appearance order. + raw_text: Original block text. + field_map: Parsed key/value map from the block. + record: Validated record when parsing succeeded. + errors: Validation or syntax errors for malformed blocks. + authoritative_hint: Parsed authoritative flag when present. + required_hint: Parsed required flag when present. + """ + + ordinal: int + raw_text: str + field_map: dict[str, str] + record: PlanningRefinementRecord | None + errors: tuple[str, ...] + authoritative_hint: bool + required_hint: bool + + @property + def is_valid(self) -> bool: + """Return whether this block parsed into a valid record.""" + return self.record is not None + + +@dataclass(frozen=True) +class RefinementClaimGateDecision: + """Claim-gate evaluation outcome for refinement requirements. + + Attributes: + required: Whether refinement requirements are active in scope. + claimable: Whether the work item is claimable. + reason: Deterministic rejection reason when unclaimable. + selected: Winning refinement record when available. + """ + + required: bool + claimable: bool + reason: str | None + selected: PlanningRefinementRecord | None + + +def parse_refinement_blocks(notes: str | None) -> tuple[ParsedRefinementBlock, ...]: + """Parse all ``planning_refinement.v1`` blocks from note text. + + Args: + notes: Notes text containing zero or more refinement blocks. + + Returns: + Parsed blocks in source order. + """ + if not notes: + return tuple() + + lines = notes.splitlines() + blocks: list[ParsedRefinementBlock] = [] + index = 0 + ordinal = 0 + while index < len(lines): + if lines[index].strip() != _REFINEMENT_MARKER: + index += 1 + continue + start = index + index += 1 + while index < len(lines) and lines[index].strip() != _REFINEMENT_MARKER: + index += 1 + raw_lines = lines[start:index] + raw_text = "\n".join(raw_lines) + field_map, syntax_errors = _parse_field_map(raw_lines[1:]) + authoritative_hint = _parse_bool_token(field_map.get("authoritative")) is True + required_hint = _parse_bool_token(field_map.get("required")) is True + record: PlanningRefinementRecord | None = None + errors = list(syntax_errors) + if not errors: + try: + record = PlanningRefinementRecord.model_validate(field_map) + except ValidationError as exc: + errors.append(str(exc)) + blocks.append( + ParsedRefinementBlock( + ordinal=ordinal, + raw_text=raw_text, + field_map=field_map, + record=record, + errors=tuple(errors), + authoritative_hint=authoritative_hint, + required_hint=required_hint, + ) + ) + ordinal += 1 + return tuple(blocks) + + +def select_winning_refinement( + blocks: tuple[ParsedRefinementBlock, ...] | list[ParsedRefinementBlock], +) -> PlanningRefinementRecord | None: + """Select the winning refinement record from parsed blocks. + + Selection is newest authoritative valid block when authoritative blocks + exist; otherwise newest valid block across all parsed blocks. + + Args: + blocks: Parsed refinement blocks. + + Returns: + Winning valid refinement record, or ``None`` when no valid winner + exists. + """ + scope = _select_scope(tuple(blocks)) + for block in reversed(scope): + if block.record is not None: + return block.record + return None + + +def evaluate_refinement_claim_gate(notes: str | None) -> RefinementClaimGateDecision: + """Evaluate whether refinement requirements permit worker claim. + + Args: + notes: Work-item notes text. + + Returns: + Claim-gate decision with deterministic reason tokens when unclaimable. + """ + blocks = parse_refinement_blocks(notes) + scope = _select_scope(blocks) + required = any(block.required_hint for block in scope) + selected = select_winning_refinement(blocks) + if not required: + return RefinementClaimGateDecision( + required=False, + claimable=True, + reason=None, + selected=selected, + ) + if selected is None: + return RefinementClaimGateDecision( + required=True, + claimable=False, + reason="refinement_metadata_missing_or_malformed", + selected=None, + ) + if selected.approval_status != "approved": + return RefinementClaimGateDecision( + required=True, + claimable=False, + reason="refinement_approval_missing", + selected=selected, + ) + if selected.latest_verdict != "READY": + return RefinementClaimGateDecision( + required=True, + claimable=False, + reason="refinement_not_ready", + selected=selected, + ) + return RefinementClaimGateDecision( + required=True, + claimable=True, + reason=None, + selected=selected, + ) + + +def _select_scope(blocks: tuple[ParsedRefinementBlock, ...]) -> tuple[ParsedRefinementBlock, ...]: + authoritative = tuple(block for block in blocks if block.authoritative_hint) + if authoritative: + return authoritative + return blocks + + +def _parse_field_map(lines: list[str]) -> tuple[dict[str, str], tuple[str, ...]]: + field_map: dict[str, str] = {} + errors: list[str] = [] + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + if ":" not in line: + errors.append(f"invalid line without key/value separator: {raw_line!r}") + continue + key, value = line.split(":", 1) + field_map[key.strip()] = value.strip() + return field_map, tuple(errors) + + +def _parse_bool_token(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _TRUE_TOKENS: + return True + if normalized in _FALSE_TOKENS: + return False + return None + + +__all__ = [ + "DEFAULT_PLAN_EDIT_ROUNDS_MAX", + "DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX", + "ApprovalSource", + "ApprovalStatus", + "ParsedRefinementBlock", + "PlanningRefinementRecord", + "RefinementClaimGateDecision", + "RefinementMode", + "RefinementVerdict", + "evaluate_refinement_claim_gate", + "parse_refinement_blocks", + "select_winning_refinement", +] diff --git a/tests/atelier/test_models.py b/tests/atelier/test_models.py index 416b3307..d15c78c2 100644 --- a/tests/atelier/test_models.py +++ b/tests/atelier/test_models.py @@ -1,6 +1,9 @@ import tempfile from pathlib import Path +import pytest + +from atelier import config as config_module from atelier.models import BeadsSection, BranchConfig, ProjectUserConfig @@ -48,3 +51,30 @@ def test_beads_section_normalizes_server_runtime_aliases() -> None: def test_beads_section_migrates_legacy_mode_key() -> None: parsed = BeadsSection.model_validate({"mode": "dolt_server"}) assert parsed.runtime_mode == "dolt-server" + + +def test_project_user_config_refinement_defaults_match_trycycle_budgets() -> None: + parsed = ProjectUserConfig.model_validate({}) + assert parsed.planning.refinement.required_by_default is False + assert parsed.planning.refinement.plan_edit_rounds_max == 5 + assert parsed.planning.refinement.post_impl_review_rounds_max == 8 + + +def test_project_user_config_rejects_invalid_refinement_budget_values() -> None: + with pytest.raises(ValueError, match="plan_edit_rounds_max must be between"): + ProjectUserConfig.model_validate( + { + "planning": { + "refinement": { + "plan_edit_rounds_max": 0, + } + } + } + ) + + +def test_resolve_refinement_policy_uses_defaults_for_missing_payload() -> None: + policy = config_module.resolve_refinement_policy({}) + assert policy.required_by_default is False + assert policy.plan_edit_rounds_max == 5 + assert policy.post_impl_review_rounds_max == 8 diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py new file mode 100644 index 00000000..8927555b --- /dev/null +++ b/tests/atelier/test_planning_refinement.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from atelier import planning_refinement + + +def _block(**fields: str) -> str: + lines = ["planning_refinement.v1"] + for key, value in fields.items(): + lines.append(f"{key}: {value}") + return "\n".join(lines) + + +def test_refinement_selects_newest_authoritative_valid_block() -> None: + notes = "\n\n".join( + ( + _block( + authoritative="true", + required="true", + approval_status="approved", + latest_verdict="READY", + plan_edit_rounds_max="5", + post_impl_review_rounds_max="8", + ), + _block( + authoritative="true", + required="true", + approval_status="approved", + latest_verdict="REVISED", + plan_edit_rounds_max="3", + post_impl_review_rounds_max="4", + ), + ) + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + + assert selected is not None + assert selected.latest_verdict == "REVISED" + assert selected.plan_edit_rounds_max == 3 + assert selected.post_impl_review_rounds_max == 4 + + +def test_refinement_uses_newest_valid_block_when_no_authoritative() -> None: + notes = "\n\n".join( + ( + _block(required="true", approval_status="approved", latest_verdict="READY"), + _block(required="false", approval_status="missing", latest_verdict="REVISED"), + ) + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + + assert selected is not None + assert selected.required is False + assert selected.latest_verdict == "REVISED" + + +def test_refinement_rejects_unknown_verdict_token() -> None: + notes = _block( + authoritative="true", + required="true", + approval_status="approved", + latest_verdict="NOT_READY", + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is None + assert gate.claimable is False + assert gate.reason == "refinement_metadata_missing_or_malformed" + + +def test_refinement_parser_handles_large_note_payload_performance() -> None: + notes = "\n\n".join( + _block( + authoritative="true" if index % 10 == 0 else "false", + required="true" if index % 2 == 0 else "false", + approval_status="approved", + latest_verdict="READY", + ) + for index in range(1000) + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + + assert len(blocks) == 1000 From d41f281ecec8aa37706f28dbdd20e7a0d8705901 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:32:55 -0700 Subject: [PATCH 07/25] feat(planning): add any-time refinement activation skill - Add plan-set-refinement for explicit refinement activation on existing work. - Enforce approval evidence requirements for required refinement mode. - Update planner guidance and tests for activation routing. --- .../skills/plan-set-refinement/SKILL.md | 36 +++ .../scripts/set_refinement.py | 267 ++++++++++++++++++ .../skills/test_plan_set_refinement_script.py | 154 ++++++++++ tests/atelier/test_planner_agents_template.py | 16 +- tests/atelier/test_skills.py | 9 + 5 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/atelier/skills/plan-set-refinement/SKILL.md create mode 100644 src/atelier/skills/plan-set-refinement/scripts/set_refinement.py create mode 100644 tests/atelier/skills/test_plan_set_refinement_script.py diff --git a/src/atelier/skills/plan-set-refinement/SKILL.md b/src/atelier/skills/plan-set-refinement/SKILL.md new file mode 100644 index 00000000..af1c3954 --- /dev/null +++ b/src/atelier/skills/plan-set-refinement/SKILL.md @@ -0,0 +1,36 @@ +--- +name: plan-set-refinement +description: >- + Enable or update planning refinement requirements on an existing epic or + changeset by appending an authoritative refinement artifact block. +--- + +# Plan set refinement + +Use this skill to enable refinement at any lifecycle point for existing work. + +## Inputs + +- issue_id: Epic or changeset id to mutate. +- mode: `requested`, `inherited`, or `project_policy`. +- required: Set `required=true` when claim gating must enforce refinement. +- lineage_root: Required when mode is `inherited`. +- approval fields: Required when `required=true`. +- plan_edit_rounds_max: Refinement planning round budget (default 5). +- post_impl_review_rounds_max: Post-implementation review budget (default 8). + +## Steps + +1. Run: + - `python skills/plan-set-refinement/scripts/set_refinement.py --issue-id "" [--mode ] [--required] [--lineage-root ] [--approval-source project_policy|operator --approved-by --approved-at ] [--plan-edit-rounds-max ] [--post-impl-review-rounds-max ]` +1. Confirm lifecycle is one of `deferred`, `open`, `in_progress`, or `blocked`. +1. Confirm the script appended an authoritative `planning_refinement.v1` note. + +## Verification + +- The target issue includes a new note block starting with + `planning_refinement.v1`. +- Required refinement (`required=true`) includes explicit approval evidence. +- Inherited mode records `mode: inherited` and `lineage_root: `. +- Budgets are persisted as `plan_edit_rounds_max` and + `post_impl_review_rounds_max`. diff --git a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py new file mode 100644 index 00000000..1bc613ea --- /dev/null +++ b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Append authoritative planning refinement metadata to an existing work item.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path +from typing import cast + +_SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" +if str(_SHARED_SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(_SHARED_SCRIPTS_ROOT)) + +from projected_bootstrap import ( # noqa: E402 # pyright: ignore[reportMissingImports] + bootstrap_projected_atelier_script, +) + +bootstrap_projected_atelier_script( + script_path=Path(__file__).resolve(), + argv=sys.argv[1:], + require_runtime_health=__name__ == "__main__", +) + +from atelier.beads_context import ( # noqa: E402 + resolve_runtime_repo_dir_hint, + resolve_skill_beads_context, +) +from atelier.planning_refinement import ( # noqa: E402 + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ApprovalSource, + ApprovalStatus, + PlanningRefinementRecord, + RefinementVerdict, +) +from atelier.store import AppendNotesRequest # noqa: E402 + +_ALLOWED_LIFECYCLES = {"deferred", "open", "in_progress", "blocked"} + + +def _build_store(*, beads_root: Path, repo_root: Path): + from atelier.lib.beads import SubprocessBeadsClient + from atelier.store import build_atelier_store + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + return build_atelier_store(beads=client) + + +def _resolve_context( + *, + beads_dir: str | None, + repo_dir: str | None, +) -> tuple[Path, Path, str | None]: + repo_hint, runtime_warning = resolve_runtime_repo_dir_hint(repo_dir=repo_dir) + context = resolve_skill_beads_context( + beads_dir=beads_dir, + repo_dir=repo_hint, + ) + return context.beads_root, context.repo_root, runtime_warning or context.override_warning + + +def _clean(value: object) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def _lifecycle_token(value: object) -> str: + if hasattr(value, "value") and isinstance(getattr(value, "value"), str): + return str(getattr(value, "value")).strip().lower() + return str(value).strip().lower() + + +async def _resolve_work_item(store, issue_id: str): + try: + return await store.get_epic(issue_id) + except LookupError: + pass + try: + return await store.get_changeset(issue_id) + except LookupError as exc: + raise RuntimeError(f"issue not found or not executable work: {issue_id}") from exc + + +def _render_note(record: PlanningRefinementRecord) -> str: + payload = record.model_dump(exclude_none=True) + ordered_keys = ( + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + ) + lines = ["planning_refinement.v1"] + for key in ordered_keys: + if key not in payload: + continue + value = payload[key] + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + +def _validate_approval_fields( + args: argparse.Namespace, +) -> tuple[ApprovalStatus, ApprovalSource | None, str | None, str | None]: + approval_source = _clean(args.approval_source) + approved_by = _clean(args.approved_by) + approved_at = _clean(args.approved_at) + approval_status = _clean(args.approval_status) + + if args.required: + if approval_status not in {None, "approved"}: + raise ValueError("required refinement must set approval_status=approved") + if not approval_source or not approved_by or not approved_at: + raise ValueError( + "required refinement must include approval evidence: " + "approval_source, approved_by, and approved_at" + ) + return "approved", cast(ApprovalSource, approval_source), approved_by, approved_at + + if approval_status is None: + approval_status = "missing" + if approval_status == "approved" and ( + not approval_source or not approved_by or not approved_at + ): + raise ValueError( + "approved refinement status requires approval evidence: " + "approval_source, approved_by, and approved_at" + ) + return ( + cast(ApprovalStatus, approval_status), + cast(ApprovalSource | None, approval_source), + approved_by, + approved_at, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--issue-id", required=True, help="Epic or changeset issue id") + parser.add_argument( + "--mode", + choices=("requested", "inherited", "project_policy"), + default="requested", + help="Refinement activation mode", + ) + parser.add_argument( + "--required", + action="store_true", + help="Mark refinement as required for claim-gate enforcement", + ) + parser.add_argument("--lineage-root", default="", help="Lineage root id for inherited mode") + parser.add_argument( + "--approval-status", + choices=("approved", "missing"), + default="", + help="Approval status override (defaults by required flag)", + ) + parser.add_argument( + "--approval-source", + choices=("project_policy", "operator"), + default="", + help="Approval source when approved", + ) + parser.add_argument("--approved-by", default="", help="Approver principal id") + parser.add_argument("--approved-at", default="", help="Approval timestamp") + parser.add_argument( + "--plan-edit-rounds-max", + type=int, + default=DEFAULT_PLAN_EDIT_ROUNDS_MAX, + help="Maximum planning edit rounds", + ) + parser.add_argument( + "--post-impl-review-rounds-max", + type=int, + default=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + help="Maximum post-implementation review rounds", + ) + parser.add_argument( + "--latest-verdict", + choices=("READY", "REVISED", "USER_DECISION_REQUIRED"), + default="", + help="Latest refinement verdict", + ) + parser.add_argument("--initial-plan-path", default="", help="Initial plan artifact path") + parser.add_argument("--latest-plan-path", default="", help="Latest plan artifact path") + parser.add_argument("--round-log-dir", default="", help="Round log directory path") + parser.add_argument("--beads-dir", default="", help="Beads directory override") + parser.add_argument("--repo-dir", default="", help="Repo root override") + args = parser.parse_args() + + try: + if args.mode == "inherited" and not _clean(args.lineage_root): + raise ValueError("inherited mode requires --lineage-root") + + approval_status, approval_source, approved_by, approved_at = _validate_approval_fields(args) + + beads_root, repo_root, runtime_warning = _resolve_context( + beads_dir=_clean(args.beads_dir), + repo_dir=_clean(args.repo_dir), + ) + if runtime_warning: + print(runtime_warning, file=sys.stderr) + + store = _build_store(beads_root=beads_root, repo_root=repo_root) + issue_id = args.issue_id.strip() + work_item = asyncio.run(_resolve_work_item(store, issue_id)) + lifecycle = _lifecycle_token(getattr(work_item, "lifecycle", "")) + if lifecycle not in _ALLOWED_LIFECYCLES: + raise RuntimeError( + "refinement can only be set on deferred/open/in_progress/blocked " + f"items; got {lifecycle!r}" + ) + + latest_verdict = cast(RefinementVerdict | None, _clean(args.latest_verdict)) + + record = PlanningRefinementRecord( + authoritative=True, + mode=args.mode, + required=bool(args.required), + lineage_root=_clean(args.lineage_root), + approval_status=approval_status, + approval_source=approval_source, + approved_by=approved_by, + approved_at=approved_at, + plan_edit_rounds_max=args.plan_edit_rounds_max, + post_impl_review_rounds_max=args.post_impl_review_rounds_max, + latest_verdict=latest_verdict, + initial_plan_path=_clean(args.initial_plan_path), + latest_plan_path=_clean(args.latest_plan_path), + round_log_dir=_clean(args.round_log_dir), + ) + note = _render_note(record) + asyncio.run(store.append_notes(AppendNotesRequest(issue_id=issue_id, notes=(note,)))) + + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + print(issue_id) + print(f"refinement_mode: {args.mode}") + print(f"required: {'true' if args.required else 'false'}") + + +if __name__ == "__main__": + main() diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py new file mode 100644 index 00000000..4301fd24 --- /dev/null +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _load_script_module(): + script_path = ( + Path(__file__).resolve().parents[3] + / "src" + / "atelier" + / "skills" + / "plan-set-refinement" + / "scripts" + / "set_refinement.py" + ) + spec = importlib.util.spec_from_file_location("set_refinement_script", script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("lifecycle", ["deferred", "open", "in_progress", "blocked"]) +def test_set_refinement_accepts_any_active_lifecycle_state( + monkeypatch: pytest.MonkeyPatch, + lifecycle: str, +) -> None: + module = _load_script_module() + captured: list[tuple[str, tuple[str, ...]]] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle=lifecycle) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + captured.append((request.issue_id, request.notes)) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr(sys, "argv", ["set_refinement.py", "--issue-id", "at-123"]) + + module.main() + + assert captured + issue_id, notes = captured[0] + assert issue_id == "at-123" + assert len(notes) == 1 + assert notes[0].startswith("planning_refinement.v1") + + +def test_set_refinement_requires_approval_evidence_when_required( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_script_module() + appended = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended.append(request) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--required", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "approval evidence" in captured.err + assert appended == [] + + +def test_set_refinement_records_inherited_lineage_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script_module() + captured: list[str] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + del issue_id + raise LookupError("not an epic") + + async def get_changeset(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="in_progress") + + async def append_notes(self, request): + captured.extend(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-456", + "--mode", + "inherited", + "--lineage-root", + "at-123", + "--plan-edit-rounds-max", + "7", + "--post-impl-review-rounds-max", + "9", + ], + ) + + module.main() + + assert captured + note = captured[0] + assert "mode: inherited" in note + assert "lineage_root: at-123" in note + assert "plan_edit_rounds_max: 7" in note + assert "post_impl_review_rounds_max: 9" in note diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index a6f41410..4ca4156d 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -3,7 +3,7 @@ from pathlib import Path -def test_planner_agents_template_contains_core_sections() -> None: +def _planner_template_text() -> str: template_path = ( Path(__file__).resolve().parents[2] / "src" @@ -11,7 +11,11 @@ def test_planner_agents_template_contains_core_sections() -> None: / "templates" / "AGENTS.planner.md.tmpl" ) - content = template_path.read_text(encoding="utf-8") + return template_path.read_text(encoding="utf-8") + + +def test_planner_agents_template_contains_core_sections() -> None: + content = _planner_template_text() assert "No Approval Step" in content assert "Skill Precedence" in content assert "Startup Behavior" in content @@ -64,3 +68,11 @@ def test_planner_agents_template_contains_core_sections() -> None: assert "If the user request includes `refined` or `refinement`" in content assert "run `refine-plan` before dispatch or promotion" in content assert "Use `plan-set-refinement`" in content + + +def test_planner_agents_template_refinement_routing_contract() -> None: + content = _planner_template_text() + assert "`planning` is the default planning doctrine" in content + assert "If the user request includes `refined` or `refinement`" in content + assert "run `refine-plan` before dispatch or promotion" in content + assert "Use `plan-set-refinement` when refinement must be enabled" in content diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 16f2a4ab..bee4b367 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -44,6 +44,7 @@ def test_packaged_skills_include_core_set() -> None: "plan-promote-epic", "planner-startup-check", "planning", + "plan-set-refinement", }.issubset(names) assert all("_" not in name for name in names) @@ -97,6 +98,7 @@ def test_install_workspace_skills_writes_skill_docs() -> None: "plan-promote-epic", "planner-startup-check", "planning", + "plan-set-refinement", ): assert (workspace_dir / "skills" / name / "SKILL.md").exists() @@ -362,6 +364,13 @@ def test_planning_skill_includes_doctrine_reference() -> None: assert "intent, rationale, non-goals" in text +def test_plan_set_refinement_skill_mentions_activation_script() -> None: + skill = skills.load_packaged_skills()["plan-set-refinement"] + text = skill.files["SKILL.md"].decode("utf-8") + assert "scripts/set_refinement.py" in text + assert "required=true" in text + + def test_workspace_skill_state_accepts_legacy_underscore_metadata_keys() -> None: with tempfile.TemporaryDirectory() as tmp: project_dir = Path(tmp) From 504fa7eb7f74644d771e0e7fb095ec1f56b21063 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:37:23 -0700 Subject: [PATCH 08/25] feat(refine-plan): add trycycle-style iterative planning loop - Add bounded stateless planning rounds with explicit verdict protocol. - Adapt trycycle prompt-builder mechanics with provenance comments. - Add convergence and non-convergence regression tests. --- src/atelier/skills/refine-plan/SKILL.md | 35 +++ .../scripts/prompt_builder/build.py | 105 +++++++++ .../scripts/prompt_builder/template_ast.py | 138 +++++++++++ .../prompt_builder/validate_rendered.py | 65 ++++++ .../refine-plan/scripts/run_refinement.py | 220 ++++++++++++++++++ .../subagents/prompt-planning-edit.md | 12 + .../subagents/prompt-planning-initial.md | 12 + .../atelier/skills/test_refine_plan_script.py | 108 +++++++++ 8 files changed, 695 insertions(+) create mode 100644 src/atelier/skills/refine-plan/SKILL.md create mode 100644 src/atelier/skills/refine-plan/scripts/prompt_builder/build.py create mode 100644 src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py create mode 100644 src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py create mode 100644 src/atelier/skills/refine-plan/scripts/run_refinement.py create mode 100644 src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md create mode 100644 src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md create mode 100644 tests/atelier/skills/test_refine_plan_script.py diff --git a/src/atelier/skills/refine-plan/SKILL.md b/src/atelier/skills/refine-plan/SKILL.md new file mode 100644 index 00000000..beefc3ec --- /dev/null +++ b/src/atelier/skills/refine-plan/SKILL.md @@ -0,0 +1,35 @@ +--- +name: refine-plan +description: >- + Run bounded iterative planning refinement rounds and persist round artifacts + with canonical verdicts. +--- + +# Refine plan + +Use this skill when planning requests explicitly ask for refined/refinement +behavior before dispatch or promotion. + +## Inputs + +- initial_plan_path: Absolute path to the current implementation plan. +- output_dir: Directory for round artifacts and latest plan output. +- max_rounds: Optional bounded round cap (default 5). + +## Steps + +1. Run refinement loop: + - `python skills/refine-plan/scripts/run_refinement.py --initial-plan-path "" --output-dir "" [--max-rounds 5]` +1. Use prompt templates: + - `subagents/prompt-planning-initial.md` for initial-quality framing. + - `subagents/prompt-planning-edit.md` for iterative edit rounds. +1. Require canonical verdict tokens from each round: + - `READY`, `REVISED`, `USER_DECISION_REQUIRED`. +1. Fail closed if convergence is not reached before `max_rounds`. + +## Verification + +- Round artifacts exist under `/rounds/round-XX.json`. +- `latest-plan.md` is written to ``. +- Result status is `ready` only when verdict is `READY`. +- Non-converged runs return `non_converged` and must not be treated as ready. diff --git a/src/atelier/skills/refine-plan/scripts/prompt_builder/build.py b/src/atelier/skills/refine-plan/scripts/prompt_builder/build.py new file mode 100644 index 00000000..513caa85 --- /dev/null +++ b/src/atelier/skills/refine-plan/scripts/prompt_builder/build.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Render refine-plan prompt templates with validation. + +Provenance: +- Adapted from trycycle `orchestrator/prompt_builder/build.py` +- Baseline import reference: trycycle base commit `8ea3981`. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from template_ast import ( # pyright: ignore[reportMissingImports] + TemplateError, + parse_template_text, + render_nodes, +) +from validate_rendered import ( # pyright: ignore[reportMissingImports] + ValidationError, + validate_rendered_prompt, +) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--template", type=Path, required=True) + parser.add_argument("--output", type=Path) + parser.add_argument("--set", action="append", default=[], metavar="NAME=VALUE") + parser.add_argument("--set-file", action="append", default=[], metavar="NAME=PATH") + parser.add_argument("--require-nonempty-tag", action="append", default=[]) + parser.add_argument("--ignore-tag-for-placeholders", action="append", default=[]) + return parser.parse_args() + + +def _parse_binding(raw: str) -> tuple[str, str]: + if "=" not in raw: + raise TemplateError(f"binding must be NAME=VALUE, got: {raw!r}") + name, value = raw.split("=", 1) + if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name): + raise TemplateError(f"invalid placeholder name: {name!r}") + return name, value + + +def _load_bindings(args: argparse.Namespace) -> dict[str, str]: + bindings: dict[str, str] = {} + + def bind(name: str, value: str) -> None: + if name in bindings: + raise TemplateError(f"duplicate binding for {name}") + bindings[name] = value + + for raw in args.set: + name, value = _parse_binding(raw) + bind(name, value) + + for raw in args.set_file: + name, path = _parse_binding(raw) + try: + value = Path(path).read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise TemplateError(f"could not read binding file for {name}: {path}") from exc + bind(name, value) + + return bindings + + +def _write_output(text: str, output_path: Path | None) -> None: + if output_path is None: + sys.stdout.write(text) + return + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(text, encoding="utf-8") + + +def main() -> int: + args = _parse_args() + template_text = args.template.read_text(encoding="utf-8") + nodes = parse_template_text(template_text) + rendered = render_nodes(nodes, _load_bindings(args)) + try: + validate_rendered_prompt( + rendered, + required_nonempty_tags=args.require_nonempty_tag, + ignore_tags_for_placeholders=args.ignore_tag_for_placeholders, + ) + except ValidationError as exc: + raise TemplateError(str(exc)) from exc + + _write_output(rendered, args.output) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except TemplateError as exc: + print(f"prompt builder error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py b/src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py new file mode 100644 index 00000000..ebee3a04 --- /dev/null +++ b/src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py @@ -0,0 +1,138 @@ +"""Template parser and renderer for refine-plan prompt assembly. + +Provenance: +- Adapted from trycycle `orchestrator/prompt_builder/template_ast.py` +- Baseline import reference: trycycle base commit `8ea3981`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, TypeAlias + +TOKEN_RE = re.compile(r"{{#if (?P[A-Z][A-Z0-9_]*)}}|{{(?Pelse)}}|{{(?P/if)}}") +PLACEHOLDER_RE = re.compile(r"\{([A-Z][A-Z0-9_]*)\}") + + +@dataclass(frozen=True) +class TextNode: + text: str + + +@dataclass(frozen=True) +class IfNode: + name: str + truthy: list["Node"] + falsy: list["Node"] + + +Node: TypeAlias = TextNode | IfNode +MissingHandler: TypeAlias = Callable[[str], str] + + +class TemplateError(RuntimeError): + """Prompt template parsing or rendering failure.""" + + +def tokenize(template: str) -> list[tuple[str, str]]: + """Tokenize template text into text and conditional markers.""" + tokens: list[tuple[str, str]] = [] + cursor = 0 + for match in TOKEN_RE.finditer(template): + if match.start() > cursor: + tokens.append(("text", template[cursor : match.start()])) + if match.group("if"): + tokens.append(("if", match.group("if"))) + elif match.group("else"): + tokens.append(("else", "")) + else: + tokens.append(("endif", "")) + cursor = match.end() + if cursor < len(template): + tokens.append(("text", template[cursor:])) + return tokens + + +def parse_template_text(template_text: str) -> list[Node]: + """Parse template text into AST nodes.""" + nodes, index = _parse_nodes(tokenize(template_text), index=0, stop=None) + if index != len(tokenize(template_text)): + raise TemplateError("template parsing stopped before token stream end") + return nodes + + +def render_nodes( + nodes: list[Node], + bindings: dict[str, str], + on_missing: MissingHandler | None = None, +) -> str: + """Render AST nodes with placeholder bindings.""" + rendered: list[str] = [] + for node in nodes: + if isinstance(node, TextNode): + rendered.append(_render_text(node.text, bindings, on_missing=on_missing)) + continue + branch = node.truthy if bindings.get(node.name, "") else node.falsy + rendered.append(render_nodes(branch, bindings, on_missing=on_missing)) + return "".join(rendered) + + +def _parse_nodes( + tokens: list[tuple[str, str]], + *, + index: int, + stop: set[str] | None, +) -> tuple[list[Node], int]: + nodes: list[Node] = [] + stop_set = stop or set() + + while index < len(tokens): + kind, value = tokens[index] + if kind in stop_set: + return nodes, index + + if kind == "text": + nodes.append(TextNode(value)) + index += 1 + continue + + if kind == "if": + truthy, index = _parse_nodes(tokens, index=index + 1, stop={"else", "endif"}) + falsy: list[Node] = [] + if index >= len(tokens): + raise TemplateError(f"unclosed conditional block for {value}") + end_kind, _ = tokens[index] + if end_kind == "else": + falsy, index = _parse_nodes(tokens, index=index + 1, stop={"endif"}) + if index >= len(tokens) or tokens[index][0] != "endif": + raise TemplateError(f"conditional block for {value} is missing {{/if}}") + elif end_kind != "endif": + raise TemplateError(f"unexpected token {end_kind!r} for {value}") + nodes.append(IfNode(name=value, truthy=truthy, falsy=falsy)) + index += 1 + continue + + raise TemplateError(f"unexpected template token: {kind}") + + if stop_set: + expected = " or ".join(sorted(stop_set)) + raise TemplateError(f"expected {expected} before end of template") + return nodes, index + + +def _render_text( + text: str, + bindings: dict[str, str], + *, + on_missing: MissingHandler | None, +) -> str: + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name in bindings: + return bindings[name] + if on_missing is not None: + return on_missing(name) + raise TemplateError(f"missing placeholder value for {name}") + + return PLACEHOLDER_RE.sub(replace, text) diff --git a/src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py b/src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py new file mode 100644 index 00000000..d08320ca --- /dev/null +++ b/src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py @@ -0,0 +1,65 @@ +"""Rendered prompt validation for refine-plan prompt assembly. + +Provenance: +- Adapted from trycycle `orchestrator/prompt_builder/validate_rendered.py` +- Baseline import reference: trycycle base commit `8ea3981`. +""" + +from __future__ import annotations + +import re + +PLACEHOLDER_RE = re.compile(r"\{([A-Z][A-Z0-9_]*)\}") +_TAG_RE_TEMPLATE = r"<{tag}>(?P.*?)" + + +class ValidationError(RuntimeError): + """Raised when a rendered prompt fails contract validation.""" + + +def validate_rendered_prompt( + prompt_text: str, + *, + required_nonempty_tags: list[str] | None = None, + ignore_tags_for_placeholders: list[str] | None = None, +) -> None: + """Validate rendered prompt completeness constraints.""" + placeholder_scan_text = _strip_tag_bodies( + prompt_text, + tags=ignore_tags_for_placeholders or [], + ) + _validate_no_placeholders(placeholder_scan_text) + for tag in required_nonempty_tags or []: + _validate_nonempty_tag(prompt_text, tag) + + +def _strip_tag_bodies(prompt_text: str, *, tags: list[str]) -> str: + stripped = prompt_text + for tag in tags: + _validate_tag_name(tag) + pattern = re.compile(_TAG_RE_TEMPLATE.format(tag=re.escape(tag)), re.DOTALL) + stripped = pattern.sub(f"<{tag}>", stripped) + return stripped + + +def _validate_no_placeholders(prompt_text: str) -> None: + matches = sorted(set(PLACEHOLDER_RE.findall(prompt_text))) + if matches: + raise ValidationError( + "rendered prompt still contains unsubstituted placeholders: " + ", ".join(matches) + ) + + +def _validate_nonempty_tag(prompt_text: str, tag: str) -> None: + _validate_tag_name(tag) + pattern = re.compile(_TAG_RE_TEMPLATE.format(tag=re.escape(tag)), re.DOTALL) + match = pattern.search(prompt_text) + if not match: + raise ValidationError(f"rendered prompt is missing required <{tag}> block") + if not match.group("body").strip(): + raise ValidationError(f"rendered prompt has empty <{tag}> block") + + +def _validate_tag_name(tag: str) -> None: + if not re.fullmatch(r"[a-z][a-z0-9_-]*", tag): + raise ValidationError(f"invalid tag name: {tag!r}") diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py new file mode 100644 index 00000000..cbac41c7 --- /dev/null +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Run bounded iterative refinement rounds for an implementation plan. + +Provenance: +- Adapted from trycycle planning loop mechanics: + - `orchestrator/run_phase.py` + - `subagents/prompt-planning-initial.md` + - `subagents/prompt-planning-edit.md` +- Baseline import reference: trycycle base commit `8ea3981`. +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Literal + +REFINEMENT_MAX_ROUNDS_DEFAULT: Final[int] = 5 +RefinementVerdict = Literal["READY", "REVISED", "USER_DECISION_REQUIRED"] + + +@dataclass(frozen=True) +class RoundResult: + """One refinement round output. + + Attributes: + verdict: Round verdict token. + plan_text: Full revised plan text. + summary: Optional short round summary. + """ + + verdict: str + plan_text: str + summary: str | None = None + + +@dataclass(frozen=True) +class RefinementRunResult: + """Overall refinement loop outcome.""" + + status: Literal["ready", "non_converged"] + max_rounds: int + rounds_used: int + latest_verdict: RefinementVerdict + output_dir: Path + + +RoundExecutor = Callable[[int, str], RoundResult] + + +def parse_verdict(raw: str) -> RefinementVerdict: + """Parse and validate a refinement verdict token. + + Args: + raw: Raw verdict string. + + Returns: + Canonical refinement verdict token. + + Raises: + ValueError: If the token is not canonical. + """ + normalized = raw.strip().upper() + if normalized == "READY": + return "READY" + if normalized == "REVISED": + return "REVISED" + if normalized == "USER_DECISION_REQUIRED": + return "USER_DECISION_REQUIRED" + raise ValueError(f"unknown refinement verdict: {raw!r}") + + +def run_refinement( + *, + initial_plan_path: Path, + output_dir: Path, + round_executor: RoundExecutor, + max_rounds: int = REFINEMENT_MAX_ROUNDS_DEFAULT, +) -> RefinementRunResult: + """Execute bounded refinement rounds. + + Args: + initial_plan_path: Path to initial plan markdown. + output_dir: Artifact output directory. + round_executor: Callable that returns one round result. + max_rounds: Maximum refinement rounds. + + Returns: + Refinement run result. + """ + if max_rounds < 1: + raise ValueError("max_rounds must be >= 1") + + plan_text = initial_plan_path.read_text(encoding="utf-8") + rounds_dir = output_dir / "rounds" + rounds_dir.mkdir(parents=True, exist_ok=True) + + latest_verdict: RefinementVerdict = "REVISED" + rounds_used = 0 + for round_number in range(1, max_rounds + 1): + round_result = round_executor(round_number, plan_text) + verdict = parse_verdict(round_result.verdict) + latest_verdict = verdict + plan_text = round_result.plan_text + rounds_used = round_number + + round_plan_path = rounds_dir / f"round-{round_number:02d}-plan.md" + round_plan_path.write_text(plan_text, encoding="utf-8") + round_json_path = rounds_dir / f"round-{round_number:02d}.json" + round_json_path.write_text( + json.dumps( + { + "round": round_number, + "verdict": verdict, + "summary": round_result.summary, + "plan_path": str(round_plan_path), + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + if verdict == "READY": + (output_dir / "latest-plan.md").write_text(plan_text, encoding="utf-8") + result = RefinementRunResult( + status="ready", + max_rounds=max_rounds, + rounds_used=rounds_used, + latest_verdict=verdict, + output_dir=output_dir, + ) + _write_result(output_dir=output_dir, result=result) + return result + + (output_dir / "latest-plan.md").write_text(plan_text, encoding="utf-8") + result = RefinementRunResult( + status="non_converged", + max_rounds=max_rounds, + rounds_used=rounds_used, + latest_verdict=latest_verdict, + output_dir=output_dir, + ) + _write_result(output_dir=output_dir, result=result) + return result + + +def _default_round_executor(_round_number: int, _plan_text: str) -> RoundResult: + raise RuntimeError("round execution backend not configured") + + +def _write_result(*, output_dir: Path, result: RefinementRunResult) -> None: + payload = { + "status": result.status, + "max_rounds": result.max_rounds, + "rounds_used": result.rounds_used, + "latest_verdict": result.latest_verdict, + "output_dir": str(result.output_dir), + } + (output_dir / "result.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _simulate_round_executor(verdicts: list[str]) -> RoundExecutor: + normalized_verdicts = [parse_verdict(verdict) for verdict in verdicts] + + def executor(round_number: int, plan_text: str) -> RoundResult: + verdict_index = min(round_number - 1, len(normalized_verdicts) - 1) + verdict = normalized_verdicts[verdict_index] + return RoundResult( + verdict=verdict, + plan_text=f"{plan_text}\n# refinement round {round_number}\n", + summary=f"simulated round {round_number}", + ) + + return executor + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--initial-plan-path", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--max-rounds", type=int, default=REFINEMENT_MAX_ROUNDS_DEFAULT) + parser.add_argument( + "--simulate-verdicts", + default="", + help="Comma-separated verdict sequence for local simulation", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if args.simulate_verdicts: + verdicts = [item.strip() for item in args.simulate_verdicts.split(",") if item.strip()] + round_executor = _simulate_round_executor(verdicts) + else: + round_executor = _default_round_executor + + result = run_refinement( + initial_plan_path=args.initial_plan_path.resolve(), + output_dir=output_dir, + round_executor=round_executor, + max_rounds=args.max_rounds, + ) + + print(json.dumps({"status": result.status, "latest_verdict": result.latest_verdict})) + return 0 if result.status == "ready" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md b/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md new file mode 100644 index 00000000..2e1ca1e3 --- /dev/null +++ b/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md @@ -0,0 +1,12 @@ +IMPORTANT: Use only planning-related skills for this phase (`planning` and +`refine-plan`). + +You are the refinement planner for iterative edit rounds. + +Task: + +- Diagnose any remaining execution risk in the current plan. +- Keep edits proportional: only revise what is necessary for successful + execution. +- Return one verdict token: `READY`, `REVISED`, or `USER_DECISION_REQUIRED`. +- If revised, return the full updated plan. diff --git a/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md b/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md new file mode 100644 index 00000000..e872034c --- /dev/null +++ b/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md @@ -0,0 +1,12 @@ +IMPORTANT: Use only planning-related skills for this phase (`planning` and +`refine-plan`). + +You are the refinement planner. Own the initial refinement pass. + +Task: + +- Read the current implementation plan and the user request. +- Apply the baseline planning doctrine from `planning`. +- Focus on architecture, contracts/invariants, boundaries, and cutover risk. +- Return one verdict token: `READY`, `REVISED`, or `USER_DECISION_REQUIRED`. +- If revised, return a full updated plan ready for execution. diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py new file mode 100644 index 00000000..f2deb81b --- /dev/null +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + + +def _load_script_module(): + script_path = ( + Path(__file__).resolve().parents[3] + / "src" + / "atelier" + / "skills" + / "refine-plan" + / "scripts" + / "run_refinement.py" + ) + spec = importlib.util.spec_from_file_location("refine_plan_script", script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_refine_plan_verdict_parser_accepts_only_canonical_tokens() -> None: + module = _load_script_module() + + assert module.parse_verdict("ready") == "READY" + assert module.parse_verdict("revised") == "REVISED" + assert module.parse_verdict("USER_DECISION_REQUIRED") == "USER_DECISION_REQUIRED" + with pytest.raises(ValueError, match="unknown refinement verdict"): + module.parse_verdict("NOT_READY") + + +def test_refine_plan_loop_defaults_to_max_rounds_five(tmp_path: Path) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + initial_plan_path.write_text("initial\n", encoding="utf-8") + + def fake_round_executor(round_number: int, plan_text: str): + del round_number + return module.RoundResult(verdict="READY", plan_text=plan_text + "done\n") + + result = module.run_refinement( + initial_plan_path=initial_plan_path, + output_dir=tmp_path / "artifacts", + round_executor=fake_round_executor, + ) + + assert result.max_rounds == 5 + assert result.rounds_used == 1 + assert result.latest_verdict == "READY" + + +def test_refine_plan_emits_round_artifacts_per_iteration(tmp_path: Path) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + initial_plan_path.write_text("initial\n", encoding="utf-8") + + verdicts = iter(("REVISED", "READY")) + + def fake_round_executor(round_number: int, plan_text: str): + verdict = next(verdicts) + return module.RoundResult( + verdict=verdict, + plan_text=f"{plan_text}round-{round_number}\n", + summary=f"summary-{round_number}", + ) + + output_dir = tmp_path / "artifacts" + result = module.run_refinement( + initial_plan_path=initial_plan_path, + output_dir=output_dir, + round_executor=fake_round_executor, + ) + + assert result.rounds_used == 2 + round_one = json.loads((output_dir / "rounds" / "round-01.json").read_text(encoding="utf-8")) + round_two = json.loads((output_dir / "rounds" / "round-02.json").read_text(encoding="utf-8")) + assert round_one["verdict"] == "REVISED" + assert round_two["verdict"] == "READY" + + +def test_refine_plan_fails_closed_on_non_convergence(tmp_path: Path) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + initial_plan_path.write_text("initial\n", encoding="utf-8") + + def fake_round_executor(round_number: int, plan_text: str): + return module.RoundResult( + verdict="REVISED", + plan_text=f"{plan_text}round-{round_number}\n", + ) + + result = module.run_refinement( + initial_plan_path=initial_plan_path, + output_dir=tmp_path / "artifacts", + round_executor=fake_round_executor, + max_rounds=3, + ) + + assert result.status == "non_converged" + assert result.latest_verdict == "REVISED" + assert result.rounds_used == 3 From 4a03997e5bb341d740c15cc47b7facc1ca1e2358 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Sun, 29 Mar 2026 00:54:31 -0700 Subject: [PATCH 09/25] feat(planning): propagate refinement contract through authoring - Make create/split/promote/guardrail scripts refinement-aware. - Enforce lineage inheritance and approval/verdict readiness diagnostics. - Add script-level regression coverage for refinement flows. --- .../skills/plan-changeset-guardrails/SKILL.md | 5 + .../scripts/check_guardrails.py | 5 + src/atelier/skills/plan-changesets/SKILL.md | 6 + .../scripts/create_changeset.py | 106 +++++++ src/atelier/skills/plan-create-epic/SKILL.md | 11 +- .../plan-create-epic/scripts/create_epic.py | 86 ++++++ src/atelier/skills/plan-promote-epic/SKILL.md | 8 + .../plan-promote-epic/scripts/promote_epic.py | 6 + src/atelier/skills/plan-split-tasks/SKILL.md | 26 +- .../plan-split-tasks/scripts/split_tasks.py | 280 ++++++++++++++++++ .../test_plan_changeset_guardrails_script.py | 56 ++++ .../skills/test_plan_changesets_script.py | 81 +++++ .../skills/test_plan_create_epic_script.py | 67 +++++ .../skills/test_plan_promote_epic_script.py | 69 +++++ .../skills/test_plan_split_tasks_script.py | 162 ++++++++++ 15 files changed, 961 insertions(+), 13 deletions(-) create mode 100644 src/atelier/skills/plan-split-tasks/scripts/split_tasks.py create mode 100644 tests/atelier/skills/test_plan_split_tasks_script.py diff --git a/src/atelier/skills/plan-changeset-guardrails/SKILL.md b/src/atelier/skills/plan-changeset-guardrails/SKILL.md index 663e89a7..8eefec64 100644 --- a/src/atelier/skills/plan-changeset-guardrails/SKILL.md +++ b/src/atelier/skills/plan-changeset-guardrails/SKILL.md @@ -35,6 +35,8 @@ description: >- changeset or stack extension) when thresholds or new domains appear. - Require explicit guidance that review-feedback scope growth is captured immediately as deferred follow-on work or stack extension. +- When `planning_refinement.v1` marks refinement as required, require complete + approval evidence and `latest_verdict=READY`. ## Steps @@ -50,6 +52,8 @@ description: >- epic context. - Look for a LOC estimate (e.g., `loc`, `LOC`, `estimate`). - If a large estimate is found (>800), ensure approval is recorded. + - If required refinement metadata exists, validate approval/verdict + completeness and report deterministic reason tokens for missing evidence. - When lifecycle/contract invariant terms are present, verify the invariant impact map and re-split guidance fields are present. 1. If an epic has exactly one child changeset, require explicit decomposition @@ -66,6 +70,7 @@ description: >- - One-child anti-pattern warnings are reported when rationale is missing. - Cross-cutting invariant violations identify missing impact map coverage, decomposition expectations, and re-split handling requirements. +- Required refinement gaps are reported as explicit contract violations. - No beads are blocked or re-labeled automatically. ## Example (Cross-cutting lifecycle bug) diff --git a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py index 8641f5d0..88b63edc 100644 --- a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py +++ b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py @@ -30,6 +30,7 @@ from atelier.bd_invocation import with_bd_mode # noqa: E402 from atelier.beads_context import resolve_runtime_repo_dir_hint # noqa: E402 from atelier.planner_contract import validate_authoring_contract # noqa: E402 +from atelier.planning_refinement import evaluate_refinement_claim_gate # noqa: E402 _LOC_TRIGGER = re.compile(r"\b(?:loc|estimate)\b", re.IGNORECASE) _NUMBER = re.compile(r"\b\d{2,5}\b") @@ -296,6 +297,10 @@ def _evaluate_guardrails( "`done_definition:` to the executable path." ) text = _text_blob(issue) + refinement_gate = evaluate_refinement_claim_gate(_normalize_text(issue.get("notes"))) + if refinement_gate.required and not refinement_gate.claimable: + reason = refinement_gate.reason or "refinement_metadata_missing_or_malformed" + violations.append(f"{issue_id}: refinement evidence incomplete ({reason}).") estimate = _extract_loc_estimate(text) if estimate is None: violations.append(f"{issue_id}: missing LOC estimate in notes/description.") diff --git a/src/atelier/skills/plan-changesets/SKILL.md b/src/atelier/skills/plan-changesets/SKILL.md index 1d524614..9b19efb6 100644 --- a/src/atelier/skills/plan-changesets/SKILL.md +++ b/src/atelier/skills/plan-changesets/SKILL.md @@ -57,6 +57,8 @@ create/edit deferred work. - If review feedback expands scope, capture that work immediately as deferred follow-on changesets (or stack extensions) rather than accreting it into the active changeset. +- If the parent epic has required refinement metadata, each child changeset + inherits required refinement with `mode=inherited` and copied budgets. ## Steps @@ -83,6 +85,8 @@ create/edit deferred work. 1. Record guardrails in the changeset description or notes. 1. The script creates the bead, applies auto-export when enabled by project config, and prints non-fatal retry instructions if export fails. +1. When parent lineage is refinement-required, inherited + `planning_refinement.v1` metadata is appended automatically to each child. 1. See [Planner Store Migration Contract] for the exact planner-side store boundary and the remaining deferred preview gap. @@ -112,6 +116,8 @@ Scenario: `Prevent premature close of active-PR changesets`. description. - Every executable path includes explicit worker-facing context fields plus acceptance criteria or `done_definition`. +- Refined parent lineages produce refined child lineages with inherited + requirement and budgets. - When auto-export is enabled and not opted out, each changeset gets its own exported external ticket link. diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index ff75277d..97c10f8a 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -29,6 +29,12 @@ compact_excerpt, validate_executable_work_payload, ) +from atelier.planning_refinement import ( # noqa: E402 + PlanningRefinementRecord, + parse_refinement_blocks, + select_winning_refinement, +) +from atelier.store import AppendNotesRequest # noqa: E402 def _fail_invalid_payload(*, title: str, description: str) -> None: @@ -72,6 +78,90 @@ def _build_store(*, beads_root: Path, repo_root: Path): return build_atelier_store(beads=client) +def _render_refinement_note(record: PlanningRefinementRecord) -> str: + payload = record.model_dump(exclude_none=True) + ordered_keys = ( + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + ) + lines = ["planning_refinement.v1"] + for key in ordered_keys: + if key not in payload: + continue + value = payload[key] + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + +def _parent_notes(*, store, epic_id: str, beads_root: Path, repo_root: Path) -> str | None: + if not hasattr(store, "get_epic"): + return None + parent = asyncio.run(store.get_epic(epic_id)) + from_store = getattr(parent, "notes", None) + if isinstance(from_store, str) and from_store.strip(): + return from_store + if isinstance(from_store, (tuple, list)): + joined = "\n".join(str(item).strip() for item in from_store if str(item).strip()) + if joined: + return joined + + from atelier.lib.beads import ShowIssueRequest, SubprocessBeadsClient + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + issue = asyncio.run(client.show(ShowIssueRequest(issue_id=epic_id))) + notes = getattr(issue, "notes", None) + if isinstance(notes, str) and notes.strip(): + return notes + if isinstance(notes, (tuple, list)): + joined = "\n".join(str(item).strip() for item in notes if str(item).strip()) + if joined: + return joined + return None + + +def _inherited_refinement_note(*, parent_notes: str | None, epic_id: str) -> str | None: + if not parent_notes: + return None + selected = select_winning_refinement(parse_refinement_blocks(parent_notes)) + if selected is None or not selected.required: + return None + inherited = PlanningRefinementRecord( + authoritative=True, + mode="inherited", + required=True, + lineage_root=selected.lineage_root or epic_id, + approval_status=selected.approval_status, + approval_source=selected.approval_source, + approved_by=selected.approved_by, + approved_at=selected.approved_at, + plan_edit_rounds_max=selected.plan_edit_rounds_max, + post_impl_review_rounds_max=selected.post_impl_review_rounds_max, + latest_verdict=selected.latest_verdict, + ) + return _render_refinement_note(inherited) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--epic-id", required=True, help="Parent epic bead id") @@ -131,6 +221,16 @@ def main() -> None: initial_status = LifecycleStatus(args.status) notes = (str(args.notes).strip(),) if str(args.notes).strip() else () try: + parent_notes = _parent_notes( + store=store, + epic_id=args.epic_id, + beads_root=context.beads_root, + repo_root=context.project_dir, + ) + refinement_note = _inherited_refinement_note( + parent_notes=parent_notes, + epic_id=args.epic_id, + ) changeset = asyncio.run( store.create_changeset( CreateChangesetRequest( @@ -144,6 +244,12 @@ def main() -> None: ) ) ) + if refinement_note is not None: + asyncio.run( + store.append_notes( + AppendNotesRequest(issue_id=changeset.id, notes=(refinement_note,)) + ) + ) except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1) from exc diff --git a/src/atelier/skills/plan-create-epic/SKILL.md b/src/atelier/skills/plan-create-epic/SKILL.md index 6e95aaa0..4445271e 100644 --- a/src/atelier/skills/plan-create-epic/SKILL.md +++ b/src/atelier/skills/plan-create-epic/SKILL.md @@ -17,6 +17,11 @@ Do not request approval to create or edit deferred beads. - acceptance: Acceptance criteria. - changeset_strategy: Guardrails or decomposition rules. - design: Optional design notes or links. +- required_refinement: Optional bool that marks the epic as refinement-required. +- refinement_approval_source: Approval source (`operator|project_policy`) when + required refinement is enabled. +- refinement_approved_by: Principal id for required refinement approval. +- refinement_approved_at: Approval timestamp for required refinement. - no_export: Optional per-bead opt-out from default auto-export. - beads_dir: Optional Beads store path. - repo_dir: Optional repo root override. Defaults to `./worktree` then cwd. @@ -24,7 +29,7 @@ Do not request approval to create or edit deferred beads. ## Steps 1. Create the epic with the script: - - `python skills/plan-create-epic/scripts/create_epic.py --title "" --scope "<scope>" --acceptance "<acceptance>" [--changeset-strategy "<changeset_strategy>"] [--design "<design>"] [--beads-dir "<beads_dir>"] [--repo-dir "<repo_dir>"] [--no-export]` + - `python skills/plan-create-epic/scripts/create_epic.py --title "<title>" --scope "<scope>" --acceptance "<acceptance>" [--changeset-strategy "<changeset_strategy>"] [--design "<design>"] [--required-refinement --refinement-approval-source operator --refinement-approved-by "<principal>" --refinement-approved-at "<timestamp>"] [--beads-dir "<beads_dir>"] [--repo-dir "<repo_dir>"] [--no-export]` - This is the canonical top-level executable-work creation path; it sets both `issue_type=epic` and the required `at:epic` discovery label. - The script is a thin planner wrapper over @@ -40,6 +45,8 @@ Do not request approval to create or edit deferred beads. 1. The script creates the bead, applies auto-export when enabled by project config, sets status to `deferred`, and prints non-fatal retry instructions if export fails. +1. If `--required-refinement` is set, approval evidence fields are required and + the script appends an authoritative `planning_refinement.v1` block. 1. If promotion is needed, use `plan-promote-epic`; promotion from `deferred` to `open` is the approval gate. 1. Use `--notes` or `--append-notes` for addendums instead of rewriting the @@ -55,6 +62,8 @@ Do not request approval to create or edit deferred beads. - Acceptance criteria stored in the acceptance field. - Epic description/notes/design capture the required planner authoring contract before promotion. +- Required refinement requests persist explicit approval evidence in + `planning_refinement.v1` note metadata. - When auto-export is enabled and not opted out, `external_tickets` is updated with `direction=exported` and `sync_mode=export`. - If startup diagnostics report identity drift, remediation is deterministic: diff --git a/src/atelier/skills/plan-create-epic/scripts/create_epic.py b/src/atelier/skills/plan-create-epic/scripts/create_epic.py index e17b1972..0159083c 100644 --- a/src/atelier/skills/plan-create-epic/scripts/create_epic.py +++ b/src/atelier/skills/plan-create-epic/scripts/create_epic.py @@ -8,6 +8,7 @@ import sys from dataclasses import replace from pathlib import Path +from typing import cast _SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" if str(_SHARED_SCRIPTS_ROOT) not in sys.path: @@ -29,6 +30,13 @@ compact_excerpt, validate_executable_work_payload, ) +from atelier.planning_refinement import ( # noqa: E402 + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ApprovalSource, + PlanningRefinementRecord, +) +from atelier.store import AppendNotesRequest # noqa: E402 def _description(scope: str, changeset_strategy: str | None) -> str: @@ -83,6 +91,38 @@ def _build_store(*, beads_root: Path, repo_root: Path): return build_atelier_store(beads=client) +def _render_refinement_note(record: PlanningRefinementRecord) -> str: + payload = record.model_dump(exclude_none=True) + ordered_keys = ( + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + ) + lines = ["planning_refinement.v1"] + for key in ordered_keys: + if key not in payload: + continue + value = payload[key] + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--title", required=True, help="Epic title") @@ -93,6 +133,27 @@ def main() -> None: help="Optional guardrail/decomposition strategy text", ) parser.add_argument("--design", help="Optional design notes") + parser.add_argument( + "--required-refinement", + action="store_true", + help="Require refinement for this epic and persist approval evidence", + ) + parser.add_argument( + "--refinement-approval-source", + choices=("project_policy", "operator"), + default="", + help="Approval source for required refinement", + ) + parser.add_argument( + "--refinement-approved-by", + default="", + help="Approver principal id for required refinement", + ) + parser.add_argument( + "--refinement-approved-at", + default="", + help="Approval timestamp for required refinement", + ) parser.add_argument( "--no-export", action="store_true", @@ -141,6 +202,31 @@ def main() -> None: ) ) ) + if args.required_refinement: + approval_source = str(args.refinement_approval_source).strip() + approved_by = str(args.refinement_approved_by).strip() + approved_at = str(args.refinement_approved_at).strip() + if not approval_source or not approved_by or not approved_at: + raise RuntimeError( + "required refinement must include approval evidence: " + "refinement_approval_source, refinement_approved_by, and " + "refinement_approved_at" + ) + note = _render_refinement_note( + PlanningRefinementRecord( + authoritative=True, + mode="requested", + required=True, + lineage_root=epic.id, + approval_status="approved", + approval_source=cast(ApprovalSource, approval_source), + approved_by=approved_by, + approved_at=approved_at, + plan_edit_rounds_max=DEFAULT_PLAN_EDIT_ROUNDS_MAX, + post_impl_review_rounds_max=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ) + ) + asyncio.run(store.append_notes(AppendNotesRequest(issue_id=epic.id, notes=(note,)))) except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1) from exc diff --git a/src/atelier/skills/plan-promote-epic/SKILL.md b/src/atelier/skills/plan-promote-epic/SKILL.md index 4513687a..e35abbd2 100644 --- a/src/atelier/skills/plan-promote-epic/SKILL.md +++ b/src/atelier/skills/plan-promote-epic/SKILL.md @@ -28,6 +28,8 @@ description: >- and related-context references for the epic and each child. - Missing required detail sections are surfaced explicitly before any confirmation prompt. +- Refined executable paths are only promotable when required refinement + evidence is complete (`approval_status=approved`, `latest_verdict=READY`). - Any remaining ambiguity has an explicit clarification loop with the operator before promotion. That loop must cover unclear scope boundaries, edge cases, explicit non-goals ("what not to do"), and missing related-context links. @@ -66,6 +68,10 @@ description: >- - Verify decomposition rationale is recorded in epic/child notes. - If rationale is missing, keep the epic as executable changeset or add the rationale before promotion. +1. For each child changeset, validate required refinement readiness from + `planning_refinement.v1` notes before confirmation. + - Block promotion when refinement evidence is missing or not `READY`. + - Surface stable reason tokens in preview diagnostics. 1. If the epic is not single-changeset sized, create only the minimum child changesets needed for execution and reviewability. 1. Summarize the executable unit(s) for the user only after the full preview is @@ -91,6 +97,8 @@ description: >- - Preview ordering is deterministic: epic first, then children by bead id. - Missing detail sections are shown explicitly instead of being skipped silently. +- Required refinement paths are blocked until approval evidence is complete and + verdict is `READY`. - Clarification questions are asked and captured before promotion whenever scope or negative scope is still ambiguous. - Epic status is `open`. diff --git a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py index 3425c340..7ace6dbf 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -27,6 +27,7 @@ resolve_skill_beads_context, ) from atelier.lib.beads import description_fields as bead_fields # noqa: E402 +from atelier.planning_refinement import evaluate_refinement_claim_gate # noqa: E402 def _build_store_and_client(*, beads_root: Path, repo_root: Path): @@ -267,6 +268,11 @@ def main() -> None: problems.append( "incomplete child changesets remain deferred: " + ", ".join(incomplete_children) ) + for record, issue in zip(changesets, child_issues, strict=True): + refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(issue)) + if refinement_gate.required and not refinement_gate.claimable: + reason = refinement_gate.reason or "refinement_metadata_missing_or_malformed" + problems.append(f"{record.id}: {reason}") if problems: raise RuntimeError("; ".join(problems)) diff --git a/src/atelier/skills/plan-split-tasks/SKILL.md b/src/atelier/skills/plan-split-tasks/SKILL.md index 03173390..a9c13987 100644 --- a/src/atelier/skills/plan-split-tasks/SKILL.md +++ b/src/atelier/skills/plan-split-tasks/SKILL.md @@ -1,8 +1,8 @@ --- name: plan-split-tasks description: >- - Split an epic into changeset beads with dependency-safe statuses when - decomposition is needed. + Split an epic or changeset into child changesets with deterministic + refinement-lineage propagation. --- # Plan split tasks @@ -12,10 +12,11 @@ review-sized unit, keep it as the executable changeset. ## Inputs -- epic_id: Parent epic bead id. -- tasks: List of changeset titles and acceptance criteria. -- subtasks: Optional nested changesets mapped to a parent changeset. +- parent_id: Parent epic or changeset bead id to split. +- tasks: List of `"<title>::<acceptance>"` entries (`--task` repeatable). +- status: Optional initial status for created children (`deferred|open`). - beads_dir: Optional Beads store path. +- repo_dir: Optional repo root override. Defaults to `./worktree` then cwd. ## Steps @@ -24,15 +25,16 @@ review-sized unit, keep it as the executable changeset. 1. If decomposition would create exactly one child changeset, keep the epic as the executable changeset unless explicit decomposition rationale is recorded in notes/description. -1. Create changeset beads under the epic: - - `bd create --parent <epic_id> --type task --title <title> --acceptance <acceptance>` - - `bd update <new_changeset_id> --status deferred` -1. Create nested changesets under a parent changeset when needed: - - `bd create --parent <changeset_id> --type task --title <title> --acceptance <acceptance>` - - `bd update <new_changeset_id> --status deferred` -1. Use `--notes` for follow-up details instead of editing descriptions. +1. Create split changesets with the script: + - `python skills/plan-split-tasks/scripts/split_tasks.py --parent-id "<parent_id>" --task "<title>::<acceptance>" [--task "<title>::<acceptance>"] [--status deferred|open] [--beads-dir "<beads_dir>"] [--repo-dir "<repo_dir>"]` +1. Keep child split units scoped and reviewable. +1. If parent lineage has required refinement metadata, each created child gets + authoritative inherited `planning_refinement.v1` metadata. +1. Use `--notes` or `--append-notes` for follow-up details instead of editing + descriptions. ## Verification - All executable work items are leaf work beads (changesets by graph inference). - One-child decompositions include explicit rationale. +- Refined parent lineage remains refined across all split descendants. diff --git a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py new file mode 100644 index 00000000..afae3259 --- /dev/null +++ b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Split one parent work item into multiple child changesets.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from dataclasses import replace +from pathlib import Path + +_SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" +if str(_SHARED_SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(_SHARED_SCRIPTS_ROOT)) + +from projected_bootstrap import ( # noqa: E402 # pyright: ignore[reportMissingImports] + bootstrap_projected_atelier_script, +) + +_BOOTSTRAP_REPO_ROOT = bootstrap_projected_atelier_script( + script_path=Path(__file__).resolve(), + argv=sys.argv[1:], + require_runtime_health=__name__ == "__main__", +) + +from atelier import auto_export # noqa: E402 +from atelier.beads_context import resolve_runtime_repo_dir_hint # noqa: E402 +from atelier.planning_refinement import ( # noqa: E402 + PlanningRefinementRecord, + parse_refinement_blocks, + select_winning_refinement, +) +from atelier.store import AppendNotesRequest # noqa: E402 + + +def _build_store(*, beads_root: Path, repo_root: Path): + from atelier.lib.beads import SubprocessBeadsClient + from atelier.store import build_atelier_store + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + return build_atelier_store(beads=client) + + +def _render_refinement_note(record: PlanningRefinementRecord) -> str: + payload = record.model_dump(exclude_none=True) + ordered_keys = ( + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + ) + lines = ["planning_refinement.v1"] + for key in ordered_keys: + if key not in payload: + continue + value = payload[key] + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + +def _normalize_notes(value: object) -> str | None: + if isinstance(value, str): + cleaned = value.strip() + return cleaned or None + if isinstance(value, (tuple, list)): + joined = "\n".join(str(item).strip() for item in value if str(item).strip()) + return joined or None + return None + + +def _task_specs(raw_tasks: tuple[str, ...]) -> tuple[tuple[str, str], ...]: + specs: list[tuple[str, str]] = [] + for task in raw_tasks: + if "::" not in task: + raise ValueError( + f"task entries must use '<title>::<acceptance>' format; received {task!r}" + ) + title, acceptance = task.split("::", 1) + title = title.strip() + acceptance = acceptance.strip() + if not title or not acceptance: + raise ValueError( + f"task entries must include non-empty title and acceptance text; received {task!r}" + ) + specs.append((title, acceptance)) + if not specs: + raise ValueError("at least one --task entry is required") + return tuple(specs) + + +def _fallback_parent_notes( + *, + parent_id: str, + beads_root: Path, + repo_root: Path, +) -> str | None: + from atelier.lib.beads import ShowIssueRequest, SubprocessBeadsClient + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + issue = asyncio.run(client.show(ShowIssueRequest(issue_id=parent_id))) + return _normalize_notes(getattr(issue, "notes", None)) + + +def _resolve_parent( + *, + store, + parent_id: str, + beads_root: Path, + repo_root: Path, +) -> tuple[str, str | None]: + try: + parent_changeset = asyncio.run(store.get_changeset(parent_id)) + epic_id = str(getattr(parent_changeset, "epic_id", "")).strip() or parent_id + has_notes = hasattr(parent_changeset, "notes") + notes = _normalize_notes(getattr(parent_changeset, "notes", None)) + if has_notes: + return epic_id, notes + return epic_id, _fallback_parent_notes( + parent_id=parent_id, + beads_root=beads_root, + repo_root=repo_root, + ) + except LookupError: + pass + parent_epic = asyncio.run(store.get_epic(parent_id)) + has_notes = hasattr(parent_epic, "notes") + notes = _normalize_notes(getattr(parent_epic, "notes", None)) + if has_notes: + return parent_epic.id, notes + return parent_epic.id, _fallback_parent_notes( + parent_id=parent_id, + beads_root=beads_root, + repo_root=repo_root, + ) + + +def _inherited_refinement_note(*, parent_notes: str | None, lineage_root: str) -> str | None: + if not parent_notes: + return None + selected = select_winning_refinement(parse_refinement_blocks(parent_notes)) + if selected is None or not selected.required: + return None + inherited = PlanningRefinementRecord( + authoritative=True, + mode="inherited", + required=True, + lineage_root=selected.lineage_root or lineage_root, + approval_status=selected.approval_status, + approval_source=selected.approval_source, + approved_by=selected.approved_by, + approved_at=selected.approved_at, + plan_edit_rounds_max=selected.plan_edit_rounds_max, + post_impl_review_rounds_max=selected.post_impl_review_rounds_max, + latest_verdict=selected.latest_verdict, + ) + return _render_refinement_note(inherited) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--parent-id", required=True, help="Parent epic or changeset id") + parser.add_argument( + "--task", + action="append", + default=[], + help="Child task in '<title>::<acceptance>' format; repeat for multiple children", + ) + parser.add_argument( + "--status", + choices=("deferred", "open"), + default="deferred", + help="Lifecycle status for created child changesets", + ) + parser.add_argument( + "--no-export", + action="store_true", + help="Opt out created children from default auto-export behavior", + ) + parser.add_argument( + "--beads-dir", + default="", + help="Beads directory override (defaults to project config)", + ) + parser.add_argument( + "--repo-dir", + default="", + help="Repo root override (defaults to ./worktree, then cwd)", + ) + args = parser.parse_args() + + try: + task_specs = _task_specs(tuple(str(value) for value in args.task)) + + repo_hint_raw, runtime_warning = resolve_runtime_repo_dir_hint( + repo_dir=str(args.repo_dir).strip() or None + ) + if runtime_warning: + print(runtime_warning, file=sys.stderr) + context = auto_export.resolve_auto_export_context( + repo_hint=Path(repo_hint_raw) if repo_hint_raw else None + ) + beads_dir = str(args.beads_dir).strip() + if beads_dir: + context = replace(context, beads_root=Path(beads_dir)) + + store = _build_store(beads_root=context.beads_root, repo_root=context.project_dir) + from atelier.store import CreateChangesetRequest, LifecycleStatus + + epic_id, parent_notes = _resolve_parent( + store=store, + parent_id=args.parent_id.strip(), + beads_root=context.beads_root, + repo_root=context.project_dir, + ) + refinement_note = _inherited_refinement_note( + parent_notes=parent_notes, + lineage_root=epic_id, + ) + + created_ids: list[str] = [] + for title, acceptance in task_specs: + created = asyncio.run( + store.create_changeset( + CreateChangesetRequest( + epic_id=epic_id, + title=title, + acceptance_criteria=acceptance, + labels=("ext:no-export",) if args.no_export else (), + initial_status=LifecycleStatus(args.status), + ) + ) + ) + created_ids.append(created.id) + if refinement_note is not None: + asyncio.run( + store.append_notes( + AppendNotesRequest(issue_id=created.id, notes=(refinement_note,)) + ) + ) + export_result = auto_export.auto_export_issue( + created.id, + context=context, + ) + print(created.id) + print(f"auto-export: {export_result.status} ({export_result.message})") + if export_result.retry_command: + print(f"retry: {export_result.retry_command}", file=sys.stderr) + + print("created_children: " + ", ".join(created_ids)) + + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/tests/atelier/skills/test_plan_changeset_guardrails_script.py b/tests/atelier/skills/test_plan_changeset_guardrails_script.py index eccde62b..5cfc2d8f 100644 --- a/tests/atelier/skills/test_plan_changeset_guardrails_script.py +++ b/tests/atelier/skills/test_plan_changeset_guardrails_script.py @@ -478,3 +478,59 @@ def fake_run(command: list[str], **_kwargs: object) -> subprocess.CompletedProce assert payload == [] assert captured["command"] == ["bd", "list", "--json"] + + +def test_evaluate_guardrails_flags_refinement_contract_gaps() -> None: + module = _load_script_module() + child = { + "id": "at-epic.1", + "labels": [], + "description": _planner_contract_text() + "\nLOC estimate: 220", + "acceptance_criteria": "Done when refinement gaps are surfaced.", + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: missing\n" + ), + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert any("refinement evidence incomplete" in item for item in report.violations) + + +def test_evaluate_guardrails_accepts_complete_required_refinement_contract() -> None: + module = _load_script_module() + child = { + "id": "at-epic.1", + "labels": [], + "description": _planner_contract_text() + "\nLOC estimate: 220", + "acceptance_criteria": "Done when refinement contract checks pass.", + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: READY\n" + ), + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert not any("refinement evidence incomplete" in item for item in report.violations) diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index ca93fb88..063c0493 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -305,3 +305,84 @@ def test_create_changeset_rejects_incident_placeholder_shapes( assert excinfo.value.code == 1 assert "invalid executable work payload for changeset creation" in captured.err assert "planner-context: NEEDS-DECISION" in captured.err + + +def test_create_changeset_inherits_required_refinement_from_parent( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + captured_notes: list[tuple[str, ...]] = [] + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + parent_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 7\n" + "post_impl_review_rounds_max: 9\n" + "latest_verdict: READY\n" + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_changeset(self, request): + del request + return SimpleNamespace(id="at-epic.1") + + async def get_epic(self, epic_id): + return SimpleNamespace(id=epic_id, notes=parent_notes) + + async def append_notes(self, request): + captured_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_changeset.py", + "--epic-id", + "at-epic", + "--title", + "Inherited refinement changeset", + "--acceptance", + "Child changesets preserve required refinement lineage.", + ], + ) + + module.main() + + assert captured_notes + note = captured_notes[0][0] + assert note.startswith("planning_refinement.v1") + assert "authoritative: true" in note + assert "mode: inherited" in note + assert "required: true" in note + assert "lineage_root: at-epic" in note + assert "plan_edit_rounds_max: 7" in note + assert "post_impl_review_rounds_max: 9" in note diff --git a/tests/atelier/skills/test_plan_create_epic_script.py b/tests/atelier/skills/test_plan_create_epic_script.py index 52c29b41..5c62c208 100644 --- a/tests/atelier/skills/test_plan_create_epic_script.py +++ b/tests/atelier/skills/test_plan_create_epic_script.py @@ -191,3 +191,70 @@ def test_create_epic_rejects_low_information_payload( assert "- title: [placeholder_value]" in captured.err assert "- scope: [placeholder_value]" in captured.err assert "planner-context: NEEDS-DECISION" in captured.err + + +def test_create_epic_appends_required_refinement_metadata( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + captured_notes: list[tuple[str, ...]] = [] + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_epic(self, request): + del request + return SimpleNamespace(id="at-epic-1") + + async def append_notes(self, request): + captured_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_epic.py", + "--title", + "Lifecycle migration", + "--scope", + "Move readiness semantics to deferred/open statuses.", + "--acceptance", + "Planner transitions use status-only lifecycle.", + "--required-refinement", + "--refinement-approval-source", + "operator", + "--refinement-approved-by", + "planner-user", + "--refinement-approved-at", + "2026-03-29T12:00:00Z", + ], + ) + + module.main() + + assert captured_notes + note = captured_notes[0][0] + assert note.startswith("planning_refinement.v1") + assert "required: true" in note + assert "approval_status: approved" in note diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 780e4da0..a11076e4 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -356,3 +356,72 @@ async def show(self, request): assert excinfo.value.code == 1 assert "epic missing detail sections: notes" in capsys.readouterr().err + + +def test_promote_epic_refinement_requires_ready_verdict( + monkeypatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + + epic_issue = _issue( + "at-epic", + title="Epic", + description=("changeset_strategy: Keep review scope small.\nrelated_context: at-context\n"), + notes="canonical epic note", + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=("changeset_note: preserve lifecycle behavior\nrelated_context: at-context\n"), + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ) + + class FakeStore: + async def get_epic(self, epic_id): + assert epic_id == "at-epic" + from atelier.store import LifecycleStatus + + return SimpleNamespace(id=epic_id, lifecycle=LifecycleStatus.DEFERRED) + + async def list_changesets(self, query): + del query + from atelier.store import LifecycleStatus + + return (SimpleNamespace(id="at-epic.1", lifecycle=LifecycleStatus.DEFERRED),) + + async def transition_lifecycle(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + class FakeClient: + async def show(self, request): + return {"at-epic": epic_issue, "at-epic.1": child_issue}[request.issue_id] + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "refinement_not_ready" in capsys.readouterr().err diff --git a/tests/atelier/skills/test_plan_split_tasks_script.py b/tests/atelier/skills/test_plan_split_tasks_script.py new file mode 100644 index 00000000..f6ed8eb4 --- /dev/null +++ b/tests/atelier/skills/test_plan_split_tasks_script.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + + +def _load_script_module(): + script_path = ( + Path(__file__).resolve().parents[3] + / "src" + / "atelier" + / "skills" + / "plan-split-tasks" + / "scripts" + / "split_tasks.py" + ) + spec = importlib.util.spec_from_file_location("split_tasks_script", script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_split_tasks_propagates_inherited_refinement_from_parent( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + appended_notes: list[tuple[str, ...]] = [] + parent_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 6\n" + "post_impl_review_rounds_max: 10\n" + "latest_verdict: READY\n" + ) + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def get_changeset(self, issue_id): + return SimpleNamespace(id=issue_id, epic_id="at-epic", notes=parent_notes) + + async def create_changeset(self, request): + child_index = len(appended_notes) + 1 + return SimpleNamespace(id=f"at-epic.{child_index + 1}") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "split_tasks.py", + "--parent-id", + "at-epic.1", + "--task", + "Split API contract::API surface is independently testable.", + "--task", + "Split worker integration::Worker integration preserves claim behavior.", + ], + ) + + module.main() + + assert len(appended_notes) == 2 + for notes in appended_notes: + note = notes[0] + assert note.startswith("planning_refinement.v1") + assert "mode: inherited" in note + assert "required: true" in note + assert "lineage_root: at-epic" in note + assert "plan_edit_rounds_max: 6" in note + assert "post_impl_review_rounds_max: 10" in note + + +def test_split_tasks_leaves_unrefined_lineage_unmarked( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + appended_notes: list[tuple[str, ...]] = [] + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def get_changeset(self, issue_id): + return SimpleNamespace(id=issue_id, epic_id="at-epic", notes="") + + async def create_changeset(self, request): + del request + return SimpleNamespace(id="at-epic.2") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "split_tasks.py", + "--parent-id", + "at-epic.1", + "--task", + "Split API contract::API surface is independently testable.", + ], + ) + + module.main() + + assert appended_notes == [] From 4089bcefe59f6c4d2dd5dc0d9900014d05201fa5 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 00:58:31 -0700 Subject: [PATCH 10/25] feat(worker): fail closed on refined claim requirements - Reject refined work claims without approval evidence and READY verdict. - Keep unrefined claim behavior unchanged. - Add selection/template tests for lineage-aware overscope handling. --- src/atelier/lifecycle.py | 17 +++++++ src/atelier/templates/AGENTS.planner.md.tmpl | 2 + src/atelier/templates/AGENTS.worker.md.tmpl | 2 + src/atelier/worker/selection.py | 1 + tests/atelier/test_lifecycle.py | 44 +++++++++++++++++ tests/atelier/test_worker_agents_template.py | 14 ++++++ tests/atelier/worker/test_selection.py | 50 ++++++++++++++++++++ tests/atelier/worker/test_session_startup.py | 38 +++++++++++++++ 8 files changed, 168 insertions(+) diff --git a/src/atelier/lifecycle.py b/src/atelier/lifecycle.py index 516cfe43..4deab718 100644 --- a/src/atelier/lifecycle.py +++ b/src/atelier/lifecycle.py @@ -15,6 +15,8 @@ from dataclasses import dataclass +from .planning_refinement import evaluate_refinement_claim_gate + ACTIVE_REVIEW_STATES = {"draft-pr", "pr-open", "in-review", "approved"} ACTIVE_PR_LIFECYCLE_STATES = {"pushed", *ACTIVE_REVIEW_STATES} INTEGRATED_REVIEW_STATES = {"merged"} @@ -89,6 +91,16 @@ def _clean_text(value: object) -> str | None: return cleaned or None +def _notes_text(value: object) -> str | None: + if isinstance(value, str): + return _clean_text(value) + if isinstance(value, (tuple, list)): + lines = tuple(cleaned for item in value if (cleaned := _clean_text(item)) is not None) + if lines: + return "\n".join(lines) + return None + + def normalize_review_state(value: object) -> str | None: """Normalize persisted PR review state values. @@ -458,6 +470,7 @@ def evaluate_epic_claimability( labels: set[str], issue_type: object, parent_id: object, + notes: object | None = None, ) -> EpicClaimEvaluation: """Evaluate whether an issue is claimable as top-level executable work. @@ -466,6 +479,7 @@ def evaluate_epic_claimability( labels: Normalized issue labels. issue_type: Raw issue type value. parent_id: Raw parent issue identifier. + notes: Raw notes payload for refinement claim-gate evaluation. Returns: Claimability evaluation with canonical status and diagnostics. @@ -486,6 +500,9 @@ def evaluate_epic_claimability( reasons.append("missing-at:epic-label") if canonical_status not in ACTIVE_LIFECYCLE_STATUSES: reasons.append(f"status={canonical_status or 'missing'}") + refinement_gate = evaluate_refinement_claim_gate(_notes_text(notes)) + if refinement_gate.required and not refinement_gate.claimable: + reasons.append(refinement_gate.reason or "refinement_metadata_missing_or_malformed") return EpicClaimEvaluation( claimable=not reasons, status=canonical_status, diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index f4683ee1..cf43f802 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -158,6 +158,8 @@ If code changes are needed, create beads and leave implementation to workers. changeset (no child changesets required). - Split into child changesets only when scope, dependencies, or reviewability require it. +- When splitting a refinement-required lineage, require inherited + `planning_refinement.v1` metadata on every child changeset. - Treat "exactly one child changeset" as an anti-pattern unless explicit decomposition rationale is recorded. - Use the guardrails in the `plan-changesets` skill. diff --git a/src/atelier/templates/AGENTS.worker.md.tmpl b/src/atelier/templates/AGENTS.worker.md.tmpl index bc672a08..cad81a6b 100644 --- a/src/atelier/templates/AGENTS.worker.md.tmpl +++ b/src/atelier/templates/AGENTS.worker.md.tmpl @@ -63,6 +63,8 @@ Skills link: ./skills - Split into nested changesets in the bead (all executable items remain leaf work beads under the epic). - Create deferred child changesets only; do not promote descendants. + - If the source lineage is refinement-required, every split child must carry + inherited `planning_refinement.v1` metadata before dispatch. - Message the planner with the proposed split. - Stop after updating the bead. 6. If any ambiguity or missing context exists: diff --git a/src/atelier/worker/selection.py b/src/atelier/worker/selection.py index e7c538ef..a95166ce 100644 --- a/src/atelier/worker/selection.py +++ b/src/atelier/worker/selection.py @@ -232,6 +232,7 @@ def evaluate_epic_claimability(issue: dict[str, object]) -> lifecycle.EpicClaimE labels=issue_labels(issue), issue_type=issue_type(issue), parent_id=issue_parent_id(issue), + notes=issue.get("notes"), ) diff --git a/tests/atelier/test_lifecycle.py b/tests/atelier/test_lifecycle.py index 6dd39d37..98936954 100644 --- a/tests/atelier/test_lifecycle.py +++ b/tests/atelier/test_lifecycle.py @@ -346,3 +346,47 @@ def test_evaluate_epic_claimability_requires_epic_label() -> None: parent_id=None, ) assert labeled.claimable is True + + +def test_evaluate_epic_claimability_rejects_required_refinement_without_approval() -> None: + blocked = lifecycle.evaluate_epic_claimability( + status="open", + labels={"at:epic"}, + issue_type="epic", + parent_id=None, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: missing\n" + ), + ) + + assert blocked.claimable is False + assert "refinement_approval_missing" in blocked.reasons + + +def test_evaluate_epic_claimability_rejects_required_refinement_without_ready_verdict() -> None: + blocked = lifecycle.evaluate_epic_claimability( + status="open", + labels={"at:epic"}, + issue_type="epic", + parent_id=None, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ) + + assert blocked.claimable is False + assert "refinement_not_ready" in blocked.reasons diff --git a/tests/atelier/test_worker_agents_template.py b/tests/atelier/test_worker_agents_template.py index bbb6c536..3fbb0d30 100644 --- a/tests/atelier/test_worker_agents_template.py +++ b/tests/atelier/test_worker_agents_template.py @@ -32,3 +32,17 @@ def test_worker_agents_template_contains_core_sections() -> None: assert "Update changeset metadata and labels." not in content assert "do not set `status=closed`" in content assert "Set `status=closed` only when terminal proof exists" in content + + +def test_worker_agents_template_calls_out_refinement_lineage_split_rule() -> None: + template_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "atelier" + / "templates" + / "AGENTS.worker.md.tmpl" + ) + content = template_path.read_text(encoding="utf-8") + + assert "If the source lineage is refinement-required" in content + assert "inherited `planning_refinement.v1`" in content diff --git a/tests/atelier/worker/test_selection.py b/tests/atelier/worker/test_selection.py index 28812e50..ae048e7d 100644 --- a/tests/atelier/worker/test_selection.py +++ b/tests/atelier/worker/test_selection.py @@ -132,6 +132,56 @@ def test_select_epic_auto_prefers_ready_before_assigned() -> None: assert selected == "at-ready" +def test_filter_epics_skips_required_refinement_without_ready_verdict() -> None: + issues = [ + { + "id": "at-ready", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-ready\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: READY\n" + ), + }, + { + "id": "at-revised", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-revised\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + }, + ] + + ready = selection.filter_epics( + issues, + require_unassigned=True, + allow_hooked=False, + skip_draft=True, + ) + + assert [item["id"] for item in ready] == ["at-ready"] + + def test_select_epic_prompt_supports_assume_yes() -> None: issues = [ { diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index ef6ad5e0..b141b1f2 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -681,6 +681,44 @@ def next_changeset(**_kwargs: Any) -> dict[str, object] | None: ] +def test_run_startup_contract_explicit_epic_refinement_not_ready_exits_cleanly() -> None: + emitted: list[str] = [] + + def next_changeset(**_kwargs: Any) -> dict[str, object] | None: + raise AssertionError("next_changeset should not run for non-claimable explicit epic") + + result = _run_startup( + explicit_epic_id="at-explicit", + show_issue=lambda _issue_id: { + "id": "at-explicit", + "status": "open", + "labels": ["at:epic"], + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-explicit\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + }, + next_changeset=next_changeset, + emit=lambda message: emitted.append(message), + ) + + assert result.should_exit is True + assert result.reason == "explicit_epic_not_claimable" + assert result.epic_id == "at-explicit" + assert emitted == [ + "Explicit epic at-explicit is not claimable under lifecycle contract " + "(refinement_not_ready); move it to open/in_progress and rerun without an epic id." + ] + + def test_run_startup_contract_explicit_epic_assigned_exits_cleanly() -> None: emitted: list[str] = [] stale_probe: list[tuple[str, str]] = [] From bc1bc1c923a4bfb596f51f441e4498c83cd3e309 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 01:00:01 -0700 Subject: [PATCH 11/25] feat(skills): add refinement compatibility alias - Add plan-refined-deliberation alias to preserve older entry points. - Keep refine-plan as the canonical iterative refinement skill. - Extend packaging tests for new planning/refinement skills. --- .../skills/plan-refined-deliberation/SKILL.md | 14 +++++++++++++ tests/atelier/test_skills.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/atelier/skills/plan-refined-deliberation/SKILL.md diff --git a/src/atelier/skills/plan-refined-deliberation/SKILL.md b/src/atelier/skills/plan-refined-deliberation/SKILL.md new file mode 100644 index 00000000..d3caa5b3 --- /dev/null +++ b/src/atelier/skills/plan-refined-deliberation/SKILL.md @@ -0,0 +1,14 @@ +--- +name: plan-refined-deliberation +description: >- + Compatibility alias for iterative planning refinement. Delegate to + `refine-plan`. +--- + +# Plan refined deliberation (alias) + +This skill is a compatibility alias for `refine-plan`. + +- Use `refine-plan` as the canonical iterative refinement workflow. +- Keep this alias available for older planner prompts and runbooks. +- Do not diverge behavior from `refine-plan`; update that skill directly. diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index bee4b367..00f130a2 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -44,7 +44,9 @@ def test_packaged_skills_include_core_set() -> None: "plan-promote-epic", "planner-startup-check", "planning", + "refine-plan", "plan-set-refinement", + "plan-refined-deliberation", }.issubset(names) assert all("_" not in name for name in names) @@ -98,7 +100,9 @@ def test_install_workspace_skills_writes_skill_docs() -> None: "plan-promote-epic", "planner-startup-check", "planning", + "refine-plan", "plan-set-refinement", + "plan-refined-deliberation", ): assert (workspace_dir / "skills" / name / "SKILL.md").exists() @@ -329,6 +333,23 @@ def test_plan_create_epic_skill_captures_drafts_without_approval() -> None: assert "done_definition" in text +def test_refinement_alias_skill_points_to_refine_plan() -> None: + skill = skills.load_packaged_skills()["plan-refined-deliberation"] + text = skill.files["SKILL.md"].decode("utf-8") + + assert "compatibility alias" in text + assert "refine-plan" in text + + +def test_refinement_skill_inventory_includes_aliases() -> None: + names = set(skills.list_packaged_skills()) + + assert "planning" in names + assert "refine-plan" in names + assert "plan-set-refinement" in names + assert "plan-refined-deliberation" in names + + def test_beads_conventions_reference_includes_concrete_authoring_examples() -> None: reference_path = ( Path(__file__).resolve().parents[2] From dfcbbd68653c026902ef78b73287696bfab9d7a8 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 01:05:30 -0700 Subject: [PATCH 12/25] chore(planning): reconcile final verification fixes - Resolve full-suite regressions discovered during final gates. - Keep changes scoped to quality-gate fixes only. --- .../scripts/check_guardrails.py | 19 ++++- .../scripts/create_changeset.py | 31 +++++--- .../plan-create-epic/scripts/create_epic.py | 71 +++++++++++++------ src/atelier/skills/plan-promote-epic/SKILL.md | 4 +- 4 files changed, 89 insertions(+), 36 deletions(-) diff --git a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py index 88b63edc..1e4b25c8 100644 --- a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py +++ b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py @@ -30,7 +30,22 @@ from atelier.bd_invocation import with_bd_mode # noqa: E402 from atelier.beads_context import resolve_runtime_repo_dir_hint # noqa: E402 from atelier.planner_contract import validate_authoring_contract # noqa: E402 -from atelier.planning_refinement import evaluate_refinement_claim_gate # noqa: E402 + + +@dataclass(frozen=True) +class _RefinementFallbackDecision: + required: bool + claimable: bool + reason: str | None + + +def _evaluate_refinement_claim_gate(notes: str | None): + try: + from atelier.planning_refinement import evaluate_refinement_claim_gate # noqa: E402 + except ModuleNotFoundError: # pragma: no cover - projected runtime compatibility + return _RefinementFallbackDecision(required=False, claimable=True, reason=None) + return evaluate_refinement_claim_gate(notes) + _LOC_TRIGGER = re.compile(r"\b(?:loc|estimate)\b", re.IGNORECASE) _NUMBER = re.compile(r"\b\d{2,5}\b") @@ -297,7 +312,7 @@ def _evaluate_guardrails( "`done_definition:` to the executable path." ) text = _text_blob(issue) - refinement_gate = evaluate_refinement_claim_gate(_normalize_text(issue.get("notes"))) + refinement_gate = _evaluate_refinement_claim_gate(_normalize_text(issue.get("notes"))) if refinement_gate.required and not refinement_gate.claimable: reason = refinement_gate.reason or "refinement_metadata_missing_or_malformed" violations.append(f"{issue_id}: refinement evidence incomplete ({reason}).") diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index 97c10f8a..96e5bf19 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -29,12 +29,6 @@ compact_excerpt, validate_executable_work_payload, ) -from atelier.planning_refinement import ( # noqa: E402 - PlanningRefinementRecord, - parse_refinement_blocks, - select_winning_refinement, -) -from atelier.store import AppendNotesRequest # noqa: E402 def _fail_invalid_payload(*, title: str, description: str) -> None: @@ -78,8 +72,16 @@ def _build_store(*, beads_root: Path, repo_root: Path): return build_atelier_store(beads=client) -def _render_refinement_note(record: PlanningRefinementRecord) -> str: - payload = record.model_dump(exclude_none=True) +def _render_refinement_note(record: object) -> str: + from typing import cast + + model_dump = getattr(record, "model_dump", None) + if not callable(model_dump): + raise RuntimeError("invalid refinement record payload") + raw_payload = model_dump(exclude_none=True) + if not isinstance(raw_payload, dict): + raise RuntimeError("invalid refinement record payload") + payload = cast(dict[str, object], raw_payload) ordered_keys = ( "authoritative", "mode", @@ -129,7 +131,10 @@ def _parent_notes(*, store, epic_id: str, beads_root: Path, repo_root: Path) -> beads_root=beads_root, env={"BEADS_DIR": str(beads_root)}, ) - issue = asyncio.run(client.show(ShowIssueRequest(issue_id=epic_id))) + try: + issue = asyncio.run(client.show(ShowIssueRequest(issue_id=epic_id))) + except Exception: + return None notes = getattr(issue, "notes", None) if isinstance(notes, str) and notes.strip(): return notes @@ -143,6 +148,12 @@ def _parent_notes(*, store, epic_id: str, beads_root: Path, repo_root: Path) -> def _inherited_refinement_note(*, parent_notes: str | None, epic_id: str) -> str | None: if not parent_notes: return None + from atelier.planning_refinement import ( + PlanningRefinementRecord, + parse_refinement_blocks, + select_winning_refinement, + ) + selected = select_winning_refinement(parse_refinement_blocks(parent_notes)) if selected is None or not selected.required: return None @@ -245,6 +256,8 @@ def main() -> None: ) ) if refinement_note is not None: + from atelier.store import AppendNotesRequest + asyncio.run( store.append_notes( AppendNotesRequest(issue_id=changeset.id, notes=(refinement_note,)) diff --git a/src/atelier/skills/plan-create-epic/scripts/create_epic.py b/src/atelier/skills/plan-create-epic/scripts/create_epic.py index 0159083c..bc5142ce 100644 --- a/src/atelier/skills/plan-create-epic/scripts/create_epic.py +++ b/src/atelier/skills/plan-create-epic/scripts/create_epic.py @@ -8,7 +8,6 @@ import sys from dataclasses import replace from pathlib import Path -from typing import cast _SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" if str(_SHARED_SCRIPTS_ROOT) not in sys.path: @@ -30,13 +29,6 @@ compact_excerpt, validate_executable_work_payload, ) -from atelier.planning_refinement import ( # noqa: E402 - DEFAULT_PLAN_EDIT_ROUNDS_MAX, - DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, - ApprovalSource, - PlanningRefinementRecord, -) -from atelier.store import AppendNotesRequest # noqa: E402 def _description(scope: str, changeset_strategy: str | None) -> str: @@ -91,8 +83,16 @@ def _build_store(*, beads_root: Path, repo_root: Path): return build_atelier_store(beads=client) -def _render_refinement_note(record: PlanningRefinementRecord) -> str: - payload = record.model_dump(exclude_none=True) +def _render_refinement_note(record: object) -> str: + from typing import cast + + model_dump = getattr(record, "model_dump", None) + if not callable(model_dump): + raise RuntimeError("invalid refinement record payload") + raw_payload = model_dump(exclude_none=True) + if not isinstance(raw_payload, dict): + raise RuntimeError("invalid refinement record payload") + payload = cast(dict[str, object], raw_payload) ordered_keys = ( "authoritative", "mode", @@ -123,6 +123,37 @@ def _render_refinement_note(record: PlanningRefinementRecord) -> str: return "\n".join(lines) +def _required_refinement_note( + *, + issue_id: str, + approval_source: str, + approved_by: str, + approved_at: str, +) -> str: + from typing import cast + + from atelier.planning_refinement import ( + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ApprovalSource, + PlanningRefinementRecord, + ) + + record = PlanningRefinementRecord( + authoritative=True, + mode="requested", + required=True, + lineage_root=issue_id, + approval_status="approved", + approval_source=cast(ApprovalSource, approval_source), + approved_by=approved_by, + approved_at=approved_at, + plan_edit_rounds_max=DEFAULT_PLAN_EDIT_ROUNDS_MAX, + post_impl_review_rounds_max=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ) + return _render_refinement_note(record) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--title", required=True, help="Epic title") @@ -212,20 +243,14 @@ def main() -> None: "refinement_approval_source, refinement_approved_by, and " "refinement_approved_at" ) - note = _render_refinement_note( - PlanningRefinementRecord( - authoritative=True, - mode="requested", - required=True, - lineage_root=epic.id, - approval_status="approved", - approval_source=cast(ApprovalSource, approval_source), - approved_by=approved_by, - approved_at=approved_at, - plan_edit_rounds_max=DEFAULT_PLAN_EDIT_ROUNDS_MAX, - post_impl_review_rounds_max=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, - ) + note = _required_refinement_note( + issue_id=epic.id, + approval_source=approval_source, + approved_by=approved_by, + approved_at=approved_at, ) + from atelier.store import AppendNotesRequest + asyncio.run(store.append_notes(AppendNotesRequest(issue_id=epic.id, notes=(note,)))) except Exception as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/src/atelier/skills/plan-promote-epic/SKILL.md b/src/atelier/skills/plan-promote-epic/SKILL.md index e35abbd2..ed74a26e 100644 --- a/src/atelier/skills/plan-promote-epic/SKILL.md +++ b/src/atelier/skills/plan-promote-epic/SKILL.md @@ -28,8 +28,8 @@ description: >- and related-context references for the epic and each child. - Missing required detail sections are surfaced explicitly before any confirmation prompt. -- Refined executable paths are only promotable when required refinement - evidence is complete (`approval_status=approved`, `latest_verdict=READY`). +- Refined executable paths are only promotable when required refinement evidence + is complete (`approval_status=approved`, `latest_verdict=READY`). - Any remaining ambiguity has an explicit clarification loop with the operator before promotion. That loop must cover unclear scope boundaries, edge cases, explicit non-goals ("what not to do"), and missing related-context links. From ce323ad262573a66ca41ddb3485677f988b15748 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 01:19:55 -0700 Subject: [PATCH 13/25] fix(refinement): enforce claim gate and project policy contract - Require complete approval evidence for required refinement before claimability succeeds. - Base claim-gate requiredness on the selected winning record while preserving fail-closed handling for malformed required metadata. - Add project-policy resolution in plan-set-refinement, auto-record policy approval evidence, apply policy budgets by default, and add regression/performance tests. --- src/atelier/planning_refinement.py | 12 +- .../scripts/set_refinement.py | 85 +++++++++++++- .../skills/test_plan_set_refinement_script.py | 108 ++++++++++++++++++ tests/atelier/test_planning_refinement.py | 50 ++++++++ 4 files changed, 248 insertions(+), 7 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index f666fad6..cb5058fd 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -261,8 +261,11 @@ def evaluate_refinement_claim_gate(notes: str | None) -> RefinementClaimGateDeci """ blocks = parse_refinement_blocks(notes) scope = _select_scope(blocks) - required = any(block.required_hint for block in scope) selected = select_winning_refinement(blocks) + if selected is None: + required = any(block.required_hint for block in scope) + else: + required = selected.required if not required: return RefinementClaimGateDecision( required=False, @@ -284,6 +287,13 @@ def evaluate_refinement_claim_gate(notes: str | None) -> RefinementClaimGateDeci reason="refinement_approval_missing", selected=selected, ) + if not selected.approval_source or not selected.approved_by or not selected.approved_at: + return RefinementClaimGateDecision( + required=True, + claimable=False, + reason="refinement_approval_missing", + selected=selected, + ) if selected.latest_verdict != "READY": return RefinementClaimGateDecision( required=True, diff --git a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py index 1bc613ea..f67cdc15 100644 --- a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py +++ b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py @@ -5,6 +5,7 @@ import argparse import asyncio +import datetime as dt import sys from pathlib import Path from typing import cast @@ -123,6 +124,8 @@ def _render_note(record: PlanningRefinementRecord) -> str: def _validate_approval_fields( args: argparse.Namespace, + *, + policy: object | None, ) -> tuple[ApprovalStatus, ApprovalSource | None, str | None, str | None]: approval_source = _clean(args.approval_source) approved_by = _clean(args.approved_by) @@ -132,6 +135,17 @@ def _validate_approval_fields( if args.required: if approval_status not in {None, "approved"}: raise ValueError("required refinement must set approval_status=approved") + if args.mode == "project_policy": + if policy is None or not bool(getattr(policy, "required_by_default", False)): + raise ValueError( + "project_policy mode requires configured policy (required_by_default=true)" + ) + return ( + "approved", + cast(ApprovalSource, approval_source or "project_policy"), + approved_by or "project_policy", + approved_at or _utc_now_iso8601(), + ) if not approval_source or not approved_by or not approved_at: raise ValueError( "required refinement must include approval evidence: " @@ -156,6 +170,53 @@ def _validate_approval_fields( ) +def _utc_now_iso8601() -> str: + now = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0) + return now.isoformat().replace("+00:00", "Z") + + +def _resolve_refinement_policy(*, repo_root: Path) -> object | None: + from atelier import config as atelier_config + from atelier import git, paths + from atelier.commands.resolve import resolve_project_for_enlistment + + _repo_root, enlistment_path, _origin_raw, origin = git.resolve_repo_enlistment(repo_root) + project_root, _project_config, _resolved_enlistment = resolve_project_for_enlistment( + enlistment_path, origin + ) + config_path = paths.project_config_path(project_root) + project_config = atelier_config.load_project_config(config_path) + if project_config is None: + return None + return atelier_config.resolve_refinement_policy(project_config) + + +def _resolve_round_limits( + args: argparse.Namespace, + *, + policy: object | None, +) -> tuple[int, int]: + policy_plan_rounds = ( + int(getattr(policy, "plan_edit_rounds_max")) + if policy is not None + else DEFAULT_PLAN_EDIT_ROUNDS_MAX + ) + policy_post_impl_rounds = ( + int(getattr(policy, "post_impl_review_rounds_max")) + if policy is not None + else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX + ) + plan_edit_rounds_max = ( + args.plan_edit_rounds_max if args.plan_edit_rounds_max is not None else policy_plan_rounds + ) + post_impl_review_rounds_max = ( + args.post_impl_review_rounds_max + if args.post_impl_review_rounds_max is not None + else policy_post_impl_rounds + ) + return int(plan_edit_rounds_max), int(post_impl_review_rounds_max) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--issue-id", required=True, help="Epic or changeset issue id") @@ -188,13 +249,13 @@ def main() -> None: parser.add_argument( "--plan-edit-rounds-max", type=int, - default=DEFAULT_PLAN_EDIT_ROUNDS_MAX, + default=None, help="Maximum planning edit rounds", ) parser.add_argument( "--post-impl-review-rounds-max", type=int, - default=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + default=None, help="Maximum post-implementation review rounds", ) parser.add_argument( @@ -214,8 +275,6 @@ def main() -> None: if args.mode == "inherited" and not _clean(args.lineage_root): raise ValueError("inherited mode requires --lineage-root") - approval_status, approval_source, approved_by, approved_at = _validate_approval_fields(args) - beads_root, repo_root, runtime_warning = _resolve_context( beads_dir=_clean(args.beads_dir), repo_dir=_clean(args.repo_dir), @@ -223,6 +282,20 @@ def main() -> None: if runtime_warning: print(runtime_warning, file=sys.stderr) + policy = ( + _resolve_refinement_policy(repo_root=repo_root) + if args.mode == "project_policy" + else None + ) + approval_status, approval_source, approved_by, approved_at = _validate_approval_fields( + args, + policy=policy, + ) + plan_edit_rounds_max, post_impl_review_rounds_max = _resolve_round_limits( + args, + policy=policy, + ) + store = _build_store(beads_root=beads_root, repo_root=repo_root) issue_id = args.issue_id.strip() work_item = asyncio.run(_resolve_work_item(store, issue_id)) @@ -244,8 +317,8 @@ def main() -> None: approval_source=approval_source, approved_by=approved_by, approved_at=approved_at, - plan_edit_rounds_max=args.plan_edit_rounds_max, - post_impl_review_rounds_max=args.post_impl_review_rounds_max, + plan_edit_rounds_max=plan_edit_rounds_max, + post_impl_review_rounds_max=post_impl_review_rounds_max, latest_verdict=latest_verdict, initial_plan_path=_clean(args.initial_plan_path), latest_plan_path=_clean(args.latest_plan_path), diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py index 4301fd24..8e0c263e 100644 --- a/tests/atelier/skills/test_plan_set_refinement_script.py +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -152,3 +152,111 @@ async def append_notes(self, request): assert "lineage_root: at-123" in note assert "plan_edit_rounds_max: 7" in note assert "post_impl_review_rounds_max: 9" in note + + +def test_set_refinement_project_policy_mode_auto_records_approval_when_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script_module() + captured: list[str] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + captured.extend(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + module, + "_resolve_refinement_policy", + lambda **_kwargs: SimpleNamespace( + required_by_default=True, + plan_edit_rounds_max=13, + post_impl_review_rounds_max=21, + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--mode", + "project_policy", + "--required", + ], + ) + + module.main() + + assert captured + note = captured[0] + assert "mode: project_policy" in note + assert "approval_status: approved" in note + assert "approval_source: project_policy" in note + assert "approved_by: project_policy" in note + assert "approved_at:" in note + assert "plan_edit_rounds_max: 13" in note + assert "post_impl_review_rounds_max: 21" in note + + +def test_set_refinement_project_policy_mode_fails_when_policy_not_configured( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_script_module() + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + module, + "_resolve_refinement_policy", + lambda **_kwargs: SimpleNamespace( + required_by_default=False, + plan_edit_rounds_max=5, + post_impl_review_rounds_max=8, + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--mode", + "project_policy", + "--required", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "project_policy mode requires configured policy" in captured.err diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index 8927555b..9946f9f7 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -1,5 +1,7 @@ from __future__ import annotations +import time + from atelier import planning_refinement @@ -74,6 +76,51 @@ def test_refinement_rejects_unknown_verdict_token() -> None: assert gate.reason == "refinement_metadata_missing_or_malformed" +def test_refinement_requires_complete_approval_evidence_when_required() -> None: + notes = _block( + authoritative="true", + required="true", + approval_status="approved", + latest_verdict="READY", + ) + + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert gate.required is True + assert gate.claimable is False + assert gate.reason == "refinement_approval_missing" + + +def test_refinement_requiredness_follows_selected_winning_record() -> None: + notes = "\n\n".join( + ( + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="READY", + ), + _block( + authoritative="true", + required="false", + approval_status="missing", + latest_verdict="REVISED", + ), + ) + ) + + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert gate.selected is not None + assert gate.selected.required is False + assert gate.required is False + assert gate.claimable is True + assert gate.reason is None + + def test_refinement_parser_handles_large_note_payload_performance() -> None: notes = "\n\n".join( _block( @@ -85,6 +132,9 @@ def test_refinement_parser_handles_large_note_payload_performance() -> None: for index in range(1000) ) + started_at = time.perf_counter() blocks = planning_refinement.parse_refinement_blocks(notes) + elapsed_seconds = time.perf_counter() - started_at assert len(blocks) == 1000 + assert elapsed_seconds < 1.0 From 50bc55d0c878dff6ac4a71f281786d31c909409e Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 01:35:47 -0700 Subject: [PATCH 14/25] fix(refinement): harden execution and lineage gating - Make refine-plan runnable without simulation by using a fail-closed default round executor and terminal USER_DECISION_REQUIRED handling. - Add snapshot-backed differential checks for refine-plan loop anchors and expand script tests for default CLI behavior. - Make inherited refinement propagation atomic by embedding inherited notes in create mutations for plan-changesets and plan-split-tasks. - Gate epic-only promotion paths on refinement readiness and add regression tests for refined epic-without-children blocking. - Add unrefined control coverage for changeset inheritance behavior per the finalized test plan. --- .../scripts/create_changeset.py | 11 +-- .../plan-promote-epic/scripts/promote_epic.py | 5 ++ .../plan-split-tasks/scripts/split_tasks.py | 8 +- .../refine-plan/scripts/run_refinement.py | 27 +++++- .../subagents/prompt-planning-edit.md | 3 +- .../subagents/prompt-planning-initial.md | 4 +- .../skills/test_plan_changesets_script.py | 83 +++++++++++++++-- .../skills/test_plan_promote_epic_script.py | 62 +++++++++++++ .../skills/test_plan_split_tasks_script.py | 28 +++--- .../atelier/skills/test_refine_plan_script.py | 89 +++++++++++++++++++ 10 files changed, 279 insertions(+), 41 deletions(-) diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index 96e5bf19..0b396dc9 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -242,6 +242,7 @@ def main() -> None: parent_notes=parent_notes, epic_id=args.epic_id, ) + persisted_notes = notes + ((refinement_note,) if refinement_note is not None else ()) changeset = asyncio.run( store.create_changeset( CreateChangesetRequest( @@ -249,20 +250,12 @@ def main() -> None: title=args.title, acceptance_criteria=args.acceptance, description=description or None, - notes=notes, + notes=persisted_notes, labels=("ext:no-export",) if args.no_export else (), initial_status=initial_status, ) ) ) - if refinement_note is not None: - from atelier.store import AppendNotesRequest - - asyncio.run( - store.append_notes( - AppendNotesRequest(issue_id=changeset.id, notes=(refinement_note,)) - ) - ) except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1) from exc diff --git a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py index 7ace6dbf..d430d6ca 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -268,6 +268,11 @@ def main() -> None: problems.append( "incomplete child changesets remain deferred: " + ", ".join(incomplete_children) ) + if not changesets: + epic_refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(epic_issue)) + if epic_refinement_gate.required and not epic_refinement_gate.claimable: + reason = epic_refinement_gate.reason or "refinement_metadata_missing_or_malformed" + problems.append(f"{epic_id}: {reason}") for record, issue in zip(changesets, child_issues, strict=True): refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(issue)) if refinement_gate.required and not refinement_gate.claimable: diff --git a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py index afae3259..982b9ac5 100644 --- a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py +++ b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py @@ -30,7 +30,6 @@ parse_refinement_blocks, select_winning_refinement, ) -from atelier.store import AppendNotesRequest # noqa: E402 def _build_store(*, beads_root: Path, repo_root: Path): @@ -248,18 +247,13 @@ def main() -> None: epic_id=epic_id, title=title, acceptance_criteria=acceptance, + notes=(refinement_note,) if refinement_note is not None else (), labels=("ext:no-export",) if args.no_export else (), initial_status=LifecycleStatus(args.status), ) ) ) created_ids.append(created.id) - if refinement_note is not None: - asyncio.run( - store.append_notes( - AppendNotesRequest(issue_id=created.id, notes=(refinement_note,)) - ) - ) export_result = auto_export.auto_export_issue( created.id, context=context, diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index cbac41c7..2744352a 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -4,6 +4,8 @@ Provenance: - Adapted from trycycle planning loop mechanics: - `orchestrator/run_phase.py` + - `_prepare_phase` + - `_command_run` - `subagents/prompt-planning-initial.md` - `subagents/prompt-planning-edit.md` - Baseline import reference: trycycle base commit `8ea3981`. @@ -136,6 +138,18 @@ def run_refinement( _write_result(output_dir=output_dir, result=result) return result + if verdict == "USER_DECISION_REQUIRED": + (output_dir / "latest-plan.md").write_text(plan_text, encoding="utf-8") + result = RefinementRunResult( + status="non_converged", + max_rounds=max_rounds, + rounds_used=rounds_used, + latest_verdict=verdict, + output_dir=output_dir, + ) + _write_result(output_dir=output_dir, result=result) + return result + (output_dir / "latest-plan.md").write_text(plan_text, encoding="utf-8") result = RefinementRunResult( status="non_converged", @@ -148,8 +162,15 @@ def run_refinement( return result -def _default_round_executor(_round_number: int, _plan_text: str) -> RoundResult: - raise RuntimeError("round execution backend not configured") +def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: + return RoundResult( + verdict="USER_DECISION_REQUIRED", + plan_text=plan_text, + summary=( + "no runtime round executor configured; " + f"stopped at round {round_number} with fail-closed verdict" + ), + ) def _write_result(*, output_dir: Path, result: RefinementRunResult) -> None: @@ -167,6 +188,8 @@ def _write_result(*, output_dir: Path, result: RefinementRunResult) -> None: def _simulate_round_executor(verdicts: list[str]) -> RoundExecutor: + if not verdicts: + raise ValueError("simulate verdict sequence must include at least one token") normalized_verdicts = [parse_verdict(verdict) for verdict in verdicts] def executor(round_number: int, plan_text: str) -> RoundResult: diff --git a/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md b/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md index 2e1ca1e3..849bddeb 100644 --- a/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md +++ b/src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md @@ -8,5 +8,6 @@ Task: - Diagnose any remaining execution risk in the current plan. - Keep edits proportional: only revise what is necessary for successful execution. -- Return one verdict token: `READY`, `REVISED`, or `USER_DECISION_REQUIRED`. +- Return `## Plan verdict` with one token: `READY`, `REVISED`, or + `USER_DECISION_REQUIRED`. - If revised, return the full updated plan. diff --git a/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md b/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md index e872034c..30ba886b 100644 --- a/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md +++ b/src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md @@ -5,8 +5,10 @@ You are the refinement planner. Own the initial refinement pass. Task: +- Review the `trycycle-planning` skill. - Read the current implementation plan and the user request. - Apply the baseline planning doctrine from `planning`. - Focus on architecture, contracts/invariants, boundaries, and cutover risk. -- Return one verdict token: `READY`, `REVISED`, or `USER_DECISION_REQUIRED`. +- Return `## Plan verdict` with one token: `READY`, `REVISED`, or + `USER_DECISION_REQUIRED`. - If revised, return a full updated plan ready for execution. diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index 063c0493..a87a92a9 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -312,7 +312,7 @@ def test_create_changeset_inherits_required_refinement_from_parent( tmp_path: Path, ) -> None: module = _load_script_module() - captured_notes: list[tuple[str, ...]] = [] + captured_request: dict[str, object] = {} context = SimpleNamespace( project_dir=tmp_path / "project", beads_root=tmp_path / ".beads", @@ -340,15 +340,14 @@ def test_create_changeset_inherits_required_refinement_from_parent( class FakeStore: async def create_changeset(self, request): - del request + captured_request["request"] = request return SimpleNamespace(id="at-epic.1") async def get_epic(self, epic_id): return SimpleNamespace(id=epic_id, notes=parent_notes) - async def append_notes(self, request): - captured_notes.append(request.notes) - return SimpleNamespace(id=request.issue_id) + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) monkeypatch.setattr( @@ -377,8 +376,9 @@ async def append_notes(self, request): module.main() - assert captured_notes - note = captured_notes[0][0] + request = captured_request["request"] + assert request.notes + note = request.notes[0] assert note.startswith("planning_refinement.v1") assert "authoritative: true" in note assert "mode: inherited" in note @@ -386,3 +386,72 @@ async def append_notes(self, request): assert "lineage_root: at-epic" in note assert "plan_edit_rounds_max: 7" in note assert "post_impl_review_rounds_max: 9" in note + + +def test_create_changeset_unrefined_control_keeps_notes_unchanged( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + captured_request: dict[str, object] = {} + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + parent_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: false\n" + "approval_status: missing\n" + "latest_verdict: REVISED\n" + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_changeset(self, request): + captured_request["request"] = request + return SimpleNamespace(id="at-epic.2") + + async def get_epic(self, epic_id): + return SimpleNamespace(id=epic_id, notes=parent_notes) + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_changeset.py", + "--epic-id", + "at-epic", + "--title", + "Unrefined control changeset", + "--acceptance", + "Unrefined parents do not add inherited refinement notes.", + "--notes", + "preserve original operator note", + ], + ) + + module.main() + + request = captured_request["request"] + assert request.notes == ("preserve original operator note",) diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index a11076e4..2d4ff35b 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -425,3 +425,65 @@ async def show(self, request): assert excinfo.value.code == 1 assert "refinement_not_ready" in capsys.readouterr().err + + +def test_promote_epic_refinement_requires_ready_verdict_for_epic_only_execution( + monkeypatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + + epic_issue = _issue( + "at-epic", + title="Epic", + description=("changeset_strategy: Keep review scope small.\nrelated_context: at-context\n"), + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ) + + class FakeStore: + async def get_epic(self, epic_id): + assert epic_id == "at-epic" + from atelier.store import LifecycleStatus + + return SimpleNamespace(id=epic_id, lifecycle=LifecycleStatus.DEFERRED) + + async def list_changesets(self, query): + del query + return () + + async def transition_lifecycle(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + class FakeClient: + async def show(self, request): + assert request.issue_id == "at-epic" + return epic_issue + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "at-epic: refinement_not_ready" in capsys.readouterr().err diff --git a/tests/atelier/skills/test_plan_split_tasks_script.py b/tests/atelier/skills/test_plan_split_tasks_script.py index f6ed8eb4..da943f76 100644 --- a/tests/atelier/skills/test_plan_split_tasks_script.py +++ b/tests/atelier/skills/test_plan_split_tasks_script.py @@ -29,7 +29,7 @@ def test_split_tasks_propagates_inherited_refinement_from_parent( tmp_path: Path, ) -> None: module = _load_script_module() - appended_notes: list[tuple[str, ...]] = [] + created_requests: list[object] = [] parent_notes = ( "planning_refinement.v1\n" "authoritative: true\n" @@ -60,12 +60,12 @@ async def get_changeset(self, issue_id): return SimpleNamespace(id=issue_id, epic_id="at-epic", notes=parent_notes) async def create_changeset(self, request): - child_index = len(appended_notes) + 1 + created_requests.append(request) + child_index = len(created_requests) return SimpleNamespace(id=f"at-epic.{child_index + 1}") - async def append_notes(self, request): - appended_notes.append(request.notes) - return SimpleNamespace(id=request.issue_id) + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) monkeypatch.setattr( @@ -94,9 +94,9 @@ async def append_notes(self, request): module.main() - assert len(appended_notes) == 2 - for notes in appended_notes: - note = notes[0] + assert len(created_requests) == 2 + for request in created_requests: + note = request.notes[0] assert note.startswith("planning_refinement.v1") assert "mode: inherited" in note assert "required: true" in note @@ -110,7 +110,7 @@ def test_split_tasks_leaves_unrefined_lineage_unmarked( tmp_path: Path, ) -> None: module = _load_script_module() - appended_notes: list[tuple[str, ...]] = [] + created_requests: list[object] = [] context = SimpleNamespace( project_dir=tmp_path / "project", beads_root=tmp_path / ".beads", @@ -127,12 +127,11 @@ async def get_changeset(self, issue_id): return SimpleNamespace(id=issue_id, epic_id="at-epic", notes="") async def create_changeset(self, request): - del request + created_requests.append(request) return SimpleNamespace(id="at-epic.2") - async def append_notes(self, request): - appended_notes.append(request.notes) - return SimpleNamespace(id=request.issue_id) + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) monkeypatch.setattr( @@ -159,4 +158,5 @@ async def append_notes(self, request): module.main() - assert appended_notes == [] + assert len(created_requests) == 1 + assert created_requests[0].notes == () diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index f2deb81b..f77b522c 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -7,6 +7,9 @@ import pytest +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TRYCYCLE_FIXTURE_ROOT = _REPO_ROOT / "tests" / "atelier" / "fixtures" / "trycycle_refinement" + def _load_script_module(): script_path = ( @@ -26,6 +29,11 @@ def _load_script_module(): return module +def _load_anchor_fixture() -> dict[str, list[str]]: + fixture_path = _TRYCYCLE_FIXTURE_ROOT / "reference_anchors.json" + return json.loads(fixture_path.read_text(encoding="utf-8")) + + def test_refine_plan_verdict_parser_accepts_only_canonical_tokens() -> None: module = _load_script_module() @@ -106,3 +114,84 @@ def fake_round_executor(round_number: int, plan_text: str): assert result.status == "non_converged" assert result.latest_verdict == "REVISED" assert result.rounds_used == 3 + + +def test_refine_plan_main_without_simulation_is_runnable_fail_closed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + initial_plan_path.write_text("initial\n", encoding="utf-8") + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + ], + ) + + exit_code = module.main() + + assert exit_code == 1 + payload = json.loads((output_dir / "result.json").read_text(encoding="utf-8")) + assert payload["status"] == "non_converged" + assert payload["latest_verdict"] == "USER_DECISION_REQUIRED" + + +def test_refine_plan_loop_artifacts_match_trycycle_snapshot_anchors() -> None: + anchors = _load_anchor_fixture()["mechanics_anchors"] + loop_snapshot = (_TRYCYCLE_FIXTURE_ROOT / "trycycle-planning-loop.snapshot.md").read_text( + encoding="utf-8" + ) + live_material = "\n".join( + ( + ( + _REPO_ROOT + / "src" + / "atelier" + / "skills" + / "refine-plan" + / "subagents" + / "prompt-planning-initial.md" + ).read_text(encoding="utf-8"), + ( + _REPO_ROOT + / "src" + / "atelier" + / "skills" + / "refine-plan" + / "subagents" + / "prompt-planning-edit.md" + ).read_text(encoding="utf-8"), + ( + _REPO_ROOT + / "src" + / "atelier" + / "skills" + / "refine-plan" + / "scripts" + / "run_refinement.py" + ).read_text(encoding="utf-8"), + ( + _REPO_ROOT + / "src" + / "atelier" + / "skills" + / "refine-plan" + / "scripts" + / "prompt_builder" + / "build.py" + ).read_text(encoding="utf-8"), + ) + ) + missing_in_snapshot = [anchor for anchor in anchors if anchor not in loop_snapshot] + missing_in_live = [anchor for anchor in anchors if anchor not in live_material] + + assert not missing_in_snapshot, f"snapshot anchors missing: {missing_in_snapshot}" + assert not missing_in_live, f"live refine-plan anchors missing: {missing_in_live}" From a16a40cbc53642166090c143f2269c5787d3a158 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 01:58:27 -0700 Subject: [PATCH 15/25] fix(refinement): persist run evidence and tighten metadata validation - Persist authoritative planning_refinement.v1 evidence from refine-plan runs back to issue notes, including rounds used, latest verdict, and artifact links. - Preserve existing refinement contract fields during persisted updates and require issue context for refine-plan execution. - Enforce ISO-8601 validation for approved_at timestamps in refinement records and add regression coverage in parser and set-refinement flows. - Accept tuple-backed notes in guardrail normalization so required refinement completeness checks cannot be skipped. - Expand refine-plan and guardrail tests with red/green coverage for note persistence, timestamp validation, and tuple-note contract evaluation. --- src/atelier/planning_refinement.py | 21 ++ .../scripts/check_guardrails.py | 2 +- src/atelier/skills/refine-plan/SKILL.md | 4 +- .../refine-plan/scripts/run_refinement.py | 200 ++++++++++++++++-- .../test_plan_changeset_guardrails_script.py | 26 +++ .../skills/test_plan_set_refinement_script.py | 49 +++++ .../atelier/skills/test_refine_plan_script.py | 107 ++++++++++ tests/atelier/test_planning_refinement.py | 21 ++ 8 files changed, 416 insertions(+), 14 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index cb5058fd..6c0c3e84 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -7,6 +7,7 @@ from __future__ import annotations +import datetime as dt from dataclasses import dataclass from typing import Final, Literal @@ -93,6 +94,14 @@ def _normalize_approval_source(cls, value: object) -> object: return normalized or None return value + @field_validator("approved_at", mode="before") + @classmethod + def _normalize_approved_at(cls, value: object) -> object: + if isinstance(value, str): + normalized = value.strip() + return normalized or None + return value + @field_validator("latest_verdict", mode="before") @classmethod def _normalize_latest_verdict(cls, value: object) -> object: @@ -131,6 +140,18 @@ def _validate_rounds_used(cls, value: int | None) -> int | None: raise ValueError("plan_edit_rounds_used must be >= 0") return value + @field_validator("approved_at") + @classmethod + def _validate_approved_at_iso8601(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.replace("Z", "+00:00") + try: + dt.datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError("approved_at must be ISO-8601 timestamp") from exc + return value + @dataclass(frozen=True) class ParsedRefinementBlock: diff --git a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py index 1e4b25c8..3f596b60 100644 --- a/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py +++ b/src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py @@ -169,7 +169,7 @@ def _labels(issue: dict[str, object]) -> set[str]: def _normalize_text(value: object) -> str: if isinstance(value, str): return value - if isinstance(value, list): + if isinstance(value, (list, tuple)): return "\n".join(str(item) for item in value if item is not None) return "" diff --git a/src/atelier/skills/refine-plan/SKILL.md b/src/atelier/skills/refine-plan/SKILL.md index beefc3ec..b4f411af 100644 --- a/src/atelier/skills/refine-plan/SKILL.md +++ b/src/atelier/skills/refine-plan/SKILL.md @@ -19,7 +19,7 @@ behavior before dispatch or promotion. ## Steps 1. Run refinement loop: - - `python skills/refine-plan/scripts/run_refinement.py --initial-plan-path "<abs-path>" --output-dir "<abs-dir>" [--max-rounds 5]` + - `python skills/refine-plan/scripts/run_refinement.py --issue-id "<epic-or-changeset-id>" --initial-plan-path "<abs-path>" --output-dir "<abs-dir>" [--max-rounds 5]` 1. Use prompt templates: - `subagents/prompt-planning-initial.md` for initial-quality framing. - `subagents/prompt-planning-edit.md` for iterative edit rounds. @@ -31,5 +31,7 @@ behavior before dispatch or promotion. - Round artifacts exist under `<output_dir>/rounds/round-XX.json`. - `latest-plan.md` is written to `<output_dir>`. +- Authoritative `planning_refinement.v1` evidence is appended to issue notes, + including rounds used, latest verdict, and artifact links. - Result status is `ready` only when verdict is `READY`. - Non-converged runs return `non_converged` and must not be treated as ready. diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index 2744352a..0b6c4f62 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -14,11 +14,27 @@ from __future__ import annotations import argparse +import asyncio import json +import sys from dataclasses import dataclass from pathlib import Path from typing import Callable, Final, Literal +_SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" +if str(_SHARED_SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(_SHARED_SCRIPTS_ROOT)) + +from projected_bootstrap import ( # pyright: ignore[reportMissingImports] + bootstrap_projected_atelier_script, +) + +bootstrap_projected_atelier_script( + script_path=Path(__file__).resolve(), + argv=sys.argv[1:], + require_runtime_health=__name__ == "__main__", +) + REFINEMENT_MAX_ROUNDS_DEFAULT: Final[int] = 5 RefinementVerdict = Literal["READY", "REVISED", "USER_DECISION_REQUIRED"] @@ -173,6 +189,144 @@ def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: ) +def _build_store(*, beads_root: Path, repo_root: Path): + from atelier.lib.beads import SubprocessBeadsClient + from atelier.store import build_atelier_store + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + return build_atelier_store(beads=client) + + +def _resolve_context( + *, + beads_dir: str | None, + repo_dir: str | None, +) -> tuple[Path, Path, str | None]: + from atelier.beads_context import resolve_runtime_repo_dir_hint, resolve_skill_beads_context + + repo_hint, runtime_warning = resolve_runtime_repo_dir_hint(repo_dir=repo_dir) + context = resolve_skill_beads_context(beads_dir=beads_dir, repo_dir=repo_hint) + return context.beads_root, context.repo_root, runtime_warning or context.override_warning + + +def _clean(value: object) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def _normalize_notes_text(value: object) -> str | None: + if isinstance(value, str): + return _clean(value) + if isinstance(value, (list, tuple)): + joined = "\n".join(str(item).strip() for item in value if str(item).strip()) + return joined or None + return None + + +async def _resolve_work_item(store, issue_id: str): + try: + return await store.get_epic(issue_id) + except LookupError: + pass + try: + return await store.get_changeset(issue_id) + except LookupError as exc: + raise RuntimeError(f"issue not found or not executable work: {issue_id}") from exc + + +def _required_hint_from_scope(blocks: tuple[object, ...]) -> bool: + authoritative = tuple(block for block in blocks if getattr(block, "authoritative_hint", False)) + scope = authoritative or blocks + return any(bool(getattr(block, "required_hint", False)) for block in scope) + + +def _render_refinement_note(record) -> str: + payload = record.model_dump(exclude_none=True) + ordered_keys = ( + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + ) + lines = ["planning_refinement.v1"] + for key in ordered_keys: + if key not in payload: + continue + value = payload[key] + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + +def _persist_refinement_evidence( + *, + store, + issue_id: str, + result: RefinementRunResult, + initial_plan_path: Path, + output_dir: Path, +) -> None: + from atelier.planning_refinement import ( + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + PlanningRefinementRecord, + parse_refinement_blocks, + select_winning_refinement, + ) + from atelier.store import AppendNotesRequest + + issue = asyncio.run(_resolve_work_item(store, issue_id)) + existing_notes = _normalize_notes_text(getattr(issue, "notes", None)) + blocks = parse_refinement_blocks(existing_notes) + selected = select_winning_refinement(blocks) + required_hint = _required_hint_from_scope(blocks) + record = PlanningRefinementRecord( + authoritative=True, + mode=selected.mode if selected is not None else "requested", + required=selected.required if selected is not None else required_hint, + lineage_root=selected.lineage_root if selected is not None else None, + approval_status=selected.approval_status if selected is not None else "missing", + approval_source=selected.approval_source if selected is not None else None, + approved_by=selected.approved_by if selected is not None else None, + approved_at=selected.approved_at if selected is not None else None, + plan_edit_rounds_max=( + selected.plan_edit_rounds_max if selected is not None else DEFAULT_PLAN_EDIT_ROUNDS_MAX + ), + post_impl_review_rounds_max=( + selected.post_impl_review_rounds_max + if selected is not None + else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX + ), + plan_edit_rounds_used=result.rounds_used, + latest_verdict=result.latest_verdict, + initial_plan_path=str(initial_plan_path), + latest_plan_path=str((output_dir / "latest-plan.md").resolve()), + round_log_dir=str((output_dir / "rounds").resolve()), + ) + note = _render_refinement_note(record) + asyncio.run(store.append_notes(AppendNotesRequest(issue_id=issue_id, notes=(note,)))) + + def _write_result(*, output_dir: Path, result: RefinementRunResult) -> None: payload = { "status": result.status, @@ -206,9 +360,12 @@ def executor(round_number: int, plan_text: str) -> RoundResult: def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--issue-id", required=True, help="Epic or changeset issue id") parser.add_argument("--initial-plan-path", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--max-rounds", type=int, default=REFINEMENT_MAX_ROUNDS_DEFAULT) + parser.add_argument("--beads-dir", default="", help="Beads directory override") + parser.add_argument("--repo-dir", default="", help="Repo root override") parser.add_argument( "--simulate-verdicts", default="", @@ -222,18 +379,37 @@ def main() -> int: output_dir = args.output_dir.resolve() output_dir.mkdir(parents=True, exist_ok=True) - if args.simulate_verdicts: - verdicts = [item.strip() for item in args.simulate_verdicts.split(",") if item.strip()] - round_executor = _simulate_round_executor(verdicts) - else: - round_executor = _default_round_executor - - result = run_refinement( - initial_plan_path=args.initial_plan_path.resolve(), - output_dir=output_dir, - round_executor=round_executor, - max_rounds=args.max_rounds, - ) + try: + if args.simulate_verdicts: + verdicts = [item.strip() for item in args.simulate_verdicts.split(",") if item.strip()] + round_executor = _simulate_round_executor(verdicts) + else: + round_executor = _default_round_executor + + initial_plan_path = args.initial_plan_path.resolve() + result = run_refinement( + initial_plan_path=initial_plan_path, + output_dir=output_dir, + round_executor=round_executor, + max_rounds=args.max_rounds, + ) + beads_root, repo_root, runtime_warning = _resolve_context( + beads_dir=_clean(args.beads_dir), + repo_dir=_clean(args.repo_dir), + ) + if runtime_warning: + print(runtime_warning, file=sys.stderr) + store = _build_store(beads_root=beads_root, repo_root=repo_root) + _persist_refinement_evidence( + store=store, + issue_id=args.issue_id.strip(), + result=result, + initial_plan_path=initial_plan_path, + output_dir=output_dir, + ) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 print(json.dumps({"status": result.status, "latest_verdict": result.latest_verdict})) return 0 if result.status == "ready" else 1 diff --git a/tests/atelier/skills/test_plan_changeset_guardrails_script.py b/tests/atelier/skills/test_plan_changeset_guardrails_script.py index 5cfc2d8f..f13ab531 100644 --- a/tests/atelier/skills/test_plan_changeset_guardrails_script.py +++ b/tests/atelier/skills/test_plan_changeset_guardrails_script.py @@ -534,3 +534,29 @@ def test_evaluate_guardrails_accepts_complete_required_refinement_contract() -> ) assert not any("refinement evidence incomplete" in item for item in report.violations) + + +def test_evaluate_guardrails_checks_required_refinement_when_notes_are_tuple() -> None: + module = _load_script_module() + child = { + "id": "at-epic.1", + "labels": [], + "description": _planner_contract_text() + "\nLOC estimate: 220", + "acceptance_criteria": "Done when tuple-backed notes are checked.", + "notes": ( + "planning_refinement.v1", + "authoritative: true", + "mode: requested", + "required: true", + "lineage_root: at-epic", + "approval_status: missing", + ), + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert any("refinement evidence incomplete" in item for item in report.violations) diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py index 8e0c263e..95f63042 100644 --- a/tests/atelier/skills/test_plan_set_refinement_script.py +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -260,3 +260,52 @@ async def append_notes(self, request): # pragma: no cover - defensive captured = capsys.readouterr() assert excinfo.value.code == 1 assert "project_policy mode requires configured policy" in captured.err + + +def test_set_refinement_rejects_non_iso_approval_timestamp( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_script_module() + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--required", + "--approval-source", + "operator", + "--approved-by", + "planner-user", + "--approved-at", + "tomorrow-ish", + "--latest-verdict", + "READY", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "approved_at" in captured.err + assert "ISO-8601" in captured.err diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index f77b522c..11e68bc3 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -4,6 +4,7 @@ import json import sys from pathlib import Path +from types import SimpleNamespace import pytest @@ -123,12 +124,45 @@ def test_refine_plan_main_without_simulation_is_runnable_fail_closed( module = _load_script_module() initial_plan_path = tmp_path / "initial.md" output_dir = tmp_path / "artifacts" + appended_notes: list[tuple[str, ...]] = [] initial_plan_path.write_text("initial\n", encoding="utf-8") + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace( + id=issue_id, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 5\n" + "post_impl_review_rounds_max: 8\n" + "latest_verdict: REVISED\n" + ), + ) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) monkeypatch.setattr( sys, "argv", [ "run_refinement.py", + "--issue-id", + "at-123", "--initial-plan-path", str(initial_plan_path), "--output-dir", @@ -139,11 +173,84 @@ def test_refine_plan_main_without_simulation_is_runnable_fail_closed( exit_code = module.main() assert exit_code == 1 + assert appended_notes payload = json.loads((output_dir / "result.json").read_text(encoding="utf-8")) assert payload["status"] == "non_converged" assert payload["latest_verdict"] == "USER_DECISION_REQUIRED" +def test_refine_plan_main_persists_authoritative_refinement_evidence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + appended_notes: list[tuple[str, ...]] = [] + initial_plan_path.write_text("initial\n", encoding="utf-8") + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace( + id=issue_id, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 7\n" + "post_impl_review_rounds_max: 9\n" + "latest_verdict: REVISED\n" + ), + ) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-epic", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + "--simulate-verdicts", + "READY", + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert appended_notes + note = appended_notes[0][0] + assert note.startswith("planning_refinement.v1") + assert "authoritative: true" in note + assert "required: true" in note + assert "approval_status: approved" in note + assert "latest_verdict: READY" in note + assert "plan_edit_rounds_used: 1" in note + assert f"initial_plan_path: {initial_plan_path.resolve()}" in note + assert f"latest_plan_path: {(output_dir / 'latest-plan.md').resolve()}" in note + assert f"round_log_dir: {(output_dir / 'rounds').resolve()}" in note + + def test_refine_plan_loop_artifacts_match_trycycle_snapshot_anchors() -> None: anchors = _load_anchor_fixture()["mechanics_anchors"] loop_snapshot = (_TRYCYCLE_FIXTURE_ROOT / "trycycle-planning-loop.snapshot.md").read_text( diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index 9946f9f7..cd060a9d 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -121,6 +121,27 @@ def test_refinement_requiredness_follows_selected_winning_record() -> None: assert gate.reason is None +def test_refinement_rejects_non_iso_approval_timestamp() -> None: + notes = _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="tomorrow-ish", + latest_verdict="READY", + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is None + assert gate.required is True + assert gate.claimable is False + assert gate.reason == "refinement_metadata_missing_or_malformed" + + def test_refinement_parser_handles_large_note_payload_performance() -> None: notes = "\n\n".join( _block( From 3c48216e7fc3a035531434505d89d66201419726 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 02:26:46 -0700 Subject: [PATCH 16/25] fix(refinement): restore converge path and add real-store harness tests - make the default refine-plan executor classify executable plans as READY instead of always hard-failing, while preserving fail-closed behavior for non-executable input - fix append-notes verification for multiline note blocks so refinement evidence persistence can be verified atomically - add H1 integration harness helpers and real store-backed integration coverage for set-refinement, changeset inheritance, split-tasks inheritance, and epic-only promote gating - add startup control coverage for malformed unrequired refinement metadata staying claimable on explicit epic startup paths - add store contract regression coverage for multiline note append behavior --- .../refine-plan/scripts/run_refinement.py | 22 +++ src/atelier/store/beads_store.py | 9 +- tests/atelier/skills/h1_store_harness.py | 51 +++++++ ...test_h1_planning_refinement_integration.py | 125 ++++++++++++++++++ .../skills/test_plan_changesets_script.py | 99 ++++++++++++++ .../skills/test_plan_promote_epic_script.py | 55 ++++++++ .../skills/test_plan_set_refinement_script.py | 55 ++++++++ .../skills/test_plan_split_tasks_script.py | 109 +++++++++++++++ .../atelier/skills/test_refine_plan_script.py | 62 +++++++++ tests/atelier/test_store_contract.py | 26 ++++ tests/atelier/worker/test_session_startup.py | 28 ++++ 11 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 tests/atelier/skills/h1_store_harness.py create mode 100644 tests/atelier/skills/test_h1_planning_refinement_integration.py diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index 0b6c4f62..4d6bbe3d 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -16,6 +16,7 @@ import argparse import asyncio import json +import re import sys from dataclasses import dataclass from pathlib import Path @@ -66,6 +67,8 @@ class RefinementRunResult: RoundExecutor = Callable[[int, str], RoundResult] +_UNCHECKED_CHECKLIST_RE: Final[re.Pattern[str]] = re.compile(r"^\s*[-*]\s+\[\s\]\s+\S") +_NUMBERED_STEP_RE: Final[re.Pattern[str]] = re.compile(r"^\s*\d+\.\s+\S") def parse_verdict(raw: str) -> RefinementVerdict: @@ -179,6 +182,15 @@ def run_refinement( def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: + if _looks_executable_plan(plan_text): + return RoundResult( + verdict="READY", + plan_text=plan_text, + summary=( + "default local executor marked plan ready from executable task structure " + f"at round {round_number}" + ), + ) return RoundResult( verdict="USER_DECISION_REQUIRED", plan_text=plan_text, @@ -189,6 +201,16 @@ def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: ) +def _looks_executable_plan(plan_text: str) -> bool: + """Return whether plan text has deterministic executable-task structure.""" + for line in plan_text.splitlines(): + if _UNCHECKED_CHECKLIST_RE.match(line): + return True + if _NUMBERED_STEP_RE.match(line): + return True + return False + + def _build_store(*, beads_root: Path, repo_root: Path): from atelier.lib.beads import SubprocessBeadsClient from atelier.store import build_atelier_store diff --git a/src/atelier/store/beads_store.py b/src/atelier/store/beads_store.py index 45b679c2..e81e6631 100644 --- a/src/atelier/store/beads_store.py +++ b/src/atelier/store/beads_store.py @@ -281,10 +281,15 @@ def _append_issue_notes(description: str, *, notes: tuple[str, ...]) -> str: def _description_ends_with_notes(description: str, *, notes: tuple[str, ...]) -> bool: if not notes: return True + expected_lines: list[str] = [] + for note in notes: + expected_lines.extend(note.rstrip("\n").splitlines()) + if not expected_lines: + return True lines = (description or "").rstrip("\n").splitlines() - if len(lines) < len(notes): + if len(lines) < len(expected_lines): return False - return tuple(lines[-len(notes) :]) == notes + return tuple(lines[-len(expected_lines) :]) == tuple(expected_lines) @dataclass diff --git a/tests/atelier/skills/h1_store_harness.py b/tests/atelier/skills/h1_store_harness.py new file mode 100644 index 00000000..efd8f755 --- /dev/null +++ b/tests/atelier/skills/h1_store_harness.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from atelier.lib.beads import BeadsCommandRequest, BeadsCommandResult, SubprocessBeadsClient +from atelier.store import build_atelier_store +from atelier.testing.beads import ( + InMemoryBeadsBackend, + IssueFixtureBuilder, + build_in_memory_beads_client, +) + +BackendKind = Literal["in-memory", "subprocess"] + +issue_builder = IssueFixtureBuilder() + + +class _InMemorySubprocessTransport: + """Drive ``SubprocessBeadsClient`` from the in-memory backend.""" + + def __init__(self, backend: InMemoryBeadsBackend) -> None: + self._backend = backend + + async def execute(self, request: BeadsCommandRequest) -> BeadsCommandResult: + completed = self._backend.run(request.argv, cwd=request.cwd, env=request.env) + return BeadsCommandResult( + argv=request.argv, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def make_store_for_backend(backend: BackendKind, *, issues: tuple[dict[str, object], ...]): + """Build a real Atelier store over in-memory or subprocess-transport backends.""" + if backend == "in-memory": + client, _backend = build_in_memory_beads_client(issues=issues) + store = build_atelier_store(beads=client) + return client, store + if backend == "subprocess": + in_memory_backend = InMemoryBeadsBackend(seeded_issues=issues) + client = SubprocessBeadsClient(transport=_InMemorySubprocessTransport(in_memory_backend)) + store = build_atelier_store(beads=client) + return client, store + raise AssertionError(f"unsupported backend: {backend!r}") + + +def runtime_paths(tmp_path: Path) -> tuple[Path, Path]: + """Return deterministic beads/repo placeholder paths for script context patches.""" + return tmp_path / ".beads", tmp_path / "repo" diff --git a/tests/atelier/skills/test_h1_planning_refinement_integration.py b/tests/atelier/skills/test_h1_planning_refinement_integration.py new file mode 100644 index 00000000..a248c79e --- /dev/null +++ b/tests/atelier/skills/test_h1_planning_refinement_integration.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import sys +from pathlib import Path + +import pytest + +from atelier.lib.beads import ShowIssueRequest +from tests.atelier.skills.h1_store_harness import issue_builder, make_store_for_backend + + +def _load_script_module(skill: str, script: str, module_name: str): + script_path = ( + Path(__file__).resolve().parents[3] + / "src" + / "atelier" + / "skills" + / skill + / "scripts" + / script + ) + spec = importlib.util.spec_from_file_location(module_name, script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("backend", ["in-memory", "subprocess"]) +def test_h1_set_refinement_persists_authoritative_note_to_real_store( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + backend: str, +) -> None: + module = _load_script_module( + "plan-set-refinement", "set_refinement.py", f"set_refinement_{backend}" + ) + client, store = make_store_for_backend( + backend, + issues=( + issue_builder.issue( + "at-123", + title="Refinement target", + issue_type="epic", + status="open", + labels=("at:epic",), + ), + ), + ) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: store) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None) + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--required", + "--approval-source", + "operator", + "--approved-by", + "planner-user", + "--approved-at", + "2026-03-29T12:00:00Z", + "--latest-verdict", + "READY", + ], + ) + + module.main() + + issue = asyncio.run(client.show(ShowIssueRequest(issue_id="at-123"))) + description = str(getattr(issue, "description", "") or "") + assert "planning_refinement.v1" in description + assert "required: true" in description + assert "latest_verdict: READY" in description + + +def test_h1_promote_epic_blocks_refined_epic_without_ready_verdict( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module("plan-promote-epic", "promote_epic.py", "promote_epic_h1") + _client, store = make_store_for_backend( + "in-memory", + issues=( + issue_builder.issue( + "at-epic", + title="Promote me", + issue_type="epic", + status="deferred", + labels=("at:epic",), + description=( + "changeset_strategy: Keep review scope small.\n" + "related_context: at-context\n" + "promotion_note: ready for confirmation\n" + "\nplanning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ), + ), + ) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None) + ) + monkeypatch.setattr(module, "_build_store_and_client", lambda **_kwargs: (store, _client)) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index a87a92a9..b73f7693 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib.util import sys from pathlib import Path @@ -7,6 +8,10 @@ import pytest +from atelier.lib.beads import ShowIssueRequest +from atelier.store import ChangesetQuery +from tests.atelier.skills.h1_store_harness import issue_builder, make_store_for_backend + def _load_script_module(): script_path = ( @@ -455,3 +460,97 @@ async def append_notes(self, request): # pragma: no cover - defensive request = captured_request["request"] assert request.notes == ("preserve original operator note",) + + +@pytest.mark.parametrize( + ("parent_notes", "expect_inherited"), + [ + ( + ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 7\n" + "post_impl_review_rounds_max: 9\n" + "latest_verdict: READY\n" + ), + True, + ), + ("", False), + ], +) +def test_create_changeset_h1_integration_refinement_inheritance( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + parent_notes: str, + expect_inherited: bool, +) -> None: + import atelier.lib.beads as beads_lib + + module = _load_script_module() + context = SimpleNamespace(project_dir=tmp_path / "repo", beads_root=tmp_path / ".beads") + context.project_dir.mkdir(parents=True, exist_ok=True) + context.beads_root.mkdir(parents=True, exist_ok=True) + _client, store = make_store_for_backend( + "in-memory", + issues=( + issue_builder.issue( + "at-epic", + title="Parent epic", + issue_type="epic", + status="open", + labels=("at:epic",), + extra_fields={"notes": parent_notes} if parent_notes else None, + ), + ), + ) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: store) + monkeypatch.setattr(beads_lib, "SubprocessBeadsClient", lambda **_kwargs: _client) + monkeypatch.setattr( + module.auto_export, "resolve_auto_export_context", lambda **_kwargs: context + ) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_changeset.py", + "--epic-id", + "at-epic", + "--title", + "Integration inheritance check", + "--acceptance", + "Child changesets preserve lineage semantics.", + ], + ) + + module.main() + + created = asyncio.run(store.list_changesets(ChangesetQuery(epic_id="at-epic"))) + assert len(created) == 1 + created_changeset = asyncio.run(store.get_changeset(created[0].id)) + created_issue = asyncio.run(_client.show(ShowIssueRequest(issue_id=created_changeset.id))) + notes_blob = str(getattr(created_issue, "description", "") or "") + + if expect_inherited: + assert "planning_refinement.v1" in notes_blob + assert "mode: inherited" in notes_blob + assert "required: true" in notes_blob + else: + assert "planning_refinement.v1" not in notes_blob diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 2d4ff35b..61c6dfa4 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -7,6 +7,8 @@ import pytest +from tests.atelier.skills.h1_store_harness import issue_builder, make_store_for_backend + def _load_script_module(): script_path = ( @@ -487,3 +489,56 @@ async def show(self, request): assert excinfo.value.code == 1 assert "at-epic: refinement_not_ready" in capsys.readouterr().err + + +def test_promote_epic_h1_integration_blocks_refined_epic_only_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + refinement_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ) + client, store = make_store_for_backend( + "in-memory", + issues=( + issue_builder.issue( + "at-epic", + title="Epic", + issue_type="epic", + status="deferred", + labels=("at:epic",), + description=( + "changeset_strategy: Keep review scope small.\n" + "related_context: at-context\n" + "promotion_note: ready for confirmation\n" + ), + acceptance_criteria="Acceptance text", + extra_fields={"notes": refinement_notes}, + ), + ), + ) + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + monkeypatch.setattr(module, "_build_store_and_client", lambda **_kwargs: (store, client)) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "at-epic: refinement_not_ready" in capsys.readouterr().err diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py index 95f63042..bf4b6c38 100644 --- a/tests/atelier/skills/test_plan_set_refinement_script.py +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib.util import sys from pathlib import Path @@ -7,6 +8,9 @@ import pytest +from atelier.lib.beads import ShowIssueRequest +from tests.atelier.skills.h1_store_harness import issue_builder, make_store_for_backend + def _load_script_module(): script_path = ( @@ -309,3 +313,54 @@ async def append_notes(self, request): # pragma: no cover - defensive assert excinfo.value.code == 1 assert "approved_at" in captured.err assert "ISO-8601" in captured.err + + +@pytest.mark.parametrize("backend", ["in-memory", "subprocess"]) +def test_set_refinement_h1_integration_persists_note_to_real_store( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + backend: str, +) -> None: + module = _load_script_module() + client, store = make_store_for_backend( + backend, + issues=( + issue_builder.issue( + "at-123", + title="Refinement target", + issue_type="epic", + status="open", + labels=("at:epic",), + ), + ), + ) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: store) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None) + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--required", + "--approval-source", + "operator", + "--approved-by", + "planner-user", + "--approved-at", + "2026-03-29T12:00:00Z", + "--latest-verdict", + "READY", + ], + ) + + module.main() + + issue = asyncio.run(client.show(ShowIssueRequest(issue_id="at-123"))) + description = str(getattr(issue, "description", "") or "") + assert "planning_refinement.v1" in description + assert "required: true" in description + assert "latest_verdict: READY" in description diff --git a/tests/atelier/skills/test_plan_split_tasks_script.py b/tests/atelier/skills/test_plan_split_tasks_script.py index da943f76..ef91ad9b 100644 --- a/tests/atelier/skills/test_plan_split_tasks_script.py +++ b/tests/atelier/skills/test_plan_split_tasks_script.py @@ -1,10 +1,17 @@ from __future__ import annotations +import asyncio import importlib.util import sys from pathlib import Path from types import SimpleNamespace +import pytest + +from atelier.lib.beads import ShowIssueRequest +from atelier.store import ChangesetQuery +from tests.atelier.skills.h1_store_harness import issue_builder, make_store_for_backend + def _load_script_module(): script_path = ( @@ -160,3 +167,105 @@ async def append_notes(self, request): # pragma: no cover - defensive assert len(created_requests) == 1 assert created_requests[0].notes == () + + +@pytest.mark.parametrize( + ("parent_notes", "expect_inherited"), + [ + ( + ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 6\n" + "post_impl_review_rounds_max: 10\n" + "latest_verdict: READY\n" + ), + True, + ), + ("", False), + ], +) +def test_split_tasks_h1_integration_preserves_refinement_lineage( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + parent_notes: str, + expect_inherited: bool, +) -> None: + import atelier.lib.beads as beads_lib + + module = _load_script_module() + context = SimpleNamespace(project_dir=tmp_path / "repo", beads_root=tmp_path / ".beads") + context.project_dir.mkdir(parents=True, exist_ok=True) + context.beads_root.mkdir(parents=True, exist_ok=True) + _client, store = make_store_for_backend( + "in-memory", + issues=( + issue_builder.issue( + "at-epic", + title="Parent epic", + issue_type="epic", + status="open", + labels=("at:epic",), + ), + issue_builder.issue( + "at-epic.1", + title="Parent changeset", + issue_type="task", + status="open", + labels=("at:changeset",), + parent="at-epic", + extra_fields={"notes": parent_notes} if parent_notes else None, + ), + ), + ) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: store) + monkeypatch.setattr(beads_lib, "SubprocessBeadsClient", lambda **_kwargs: _client) + monkeypatch.setattr( + module.auto_export, "resolve_auto_export_context", lambda **_kwargs: context + ) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "split_tasks.py", + "--parent-id", + "at-epic.1", + "--task", + "One::Acceptance one", + "--task", + "Two::Acceptance two", + ], + ) + + module.main() + + created = asyncio.run(store.list_changesets(ChangesetQuery(epic_id="at-epic"))) + created_ids = sorted(item.id for item in created if item.id != "at-epic.1") + assert len(created_ids) == 2 + for child_id in created_ids: + child_issue = asyncio.run(_client.show(ShowIssueRequest(issue_id=child_id))) + notes_blob = str(getattr(child_issue, "description", "") or "") + if expect_inherited: + assert "planning_refinement.v1" in notes_blob + assert "mode: inherited" in notes_blob + assert "required: true" in notes_blob + else: + assert "planning_refinement.v1" not in notes_blob diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index 11e68bc3..1b6d3a69 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -179,6 +179,68 @@ async def append_notes(self, request): assert payload["latest_verdict"] == "USER_DECISION_REQUIRED" +def test_refine_plan_main_without_simulation_can_reach_ready_for_executable_plan( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + appended_notes: list[tuple[str, ...]] = [] + initial_plan_path.write_text("- [ ] Step 1\n- [ ] Step 2\n", encoding="utf-8") + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace( + id=issue_id, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 5\n" + "post_impl_review_rounds_max: 8\n" + "latest_verdict: REVISED\n" + ), + ) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-123", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert appended_notes + payload = json.loads((output_dir / "result.json").read_text(encoding="utf-8")) + assert payload["status"] == "ready" + assert payload["latest_verdict"] == "READY" + + def test_refine_plan_main_persists_authoritative_refinement_evidence( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/atelier/test_store_contract.py b/tests/atelier/test_store_contract.py index 6620d719..2ac22fc4 100644 --- a/tests/atelier/test_store_contract.py +++ b/tests/atelier/test_store_contract.py @@ -295,6 +295,32 @@ def test_public_store_module_exports_single_store_surface() -> None: assert not hasattr(public_store, "AsyncAtelierStore") +def test_append_notes_accepts_multiline_note_blocks() -> None: + store = _store_for(BUILDER.issue("at-epic", issue_type="epic", labels=("at:epic",))) + note = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "required: true\n" + "approval_status: approved\n" + "latest_verdict: READY\n" + ) + + appended = _RUN( + store.append_notes( + AppendNotesRequest( + issue_id="at-epic", + notes=(note,), + ) + ) + ) + + assert appended.id == "at-epic" + refreshed = _RUN(store._show_issue("at-epic")) + assert refreshed.description is not None + assert "planning_refinement.v1" in refreshed.description + assert "latest_verdict: READY" in refreshed.description + + def test_store_contract_docs_record_invariants_and_deferred_work() -> None: store_doc = STORE_CONTRACT_DOC_PATH.read_text(encoding="utf-8") beads_doc = BEADS_CONTRACT_DOC_PATH.read_text(encoding="utf-8") diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index b141b1f2..697185a2 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -719,6 +719,34 @@ def next_changeset(**_kwargs: Any) -> dict[str, object] | None: ] +def test_run_startup_contract_explicit_epic_malformed_unrefined_metadata_stays_claimable() -> None: + emitted: list[str] = [] + + result = _run_startup( + explicit_epic_id="at-explicit", + show_issue=lambda _issue_id: { + "id": "at-explicit", + "status": "open", + "labels": ["at:epic"], + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: false\n" + "latest_verdict: NOT_READY\n" + ), + }, + next_changeset=lambda **_kwargs: {"id": "at-explicit.1"}, + emit=lambda message: emitted.append(message), + ) + + assert result.should_exit is False + assert result.reason == "explicit_epic" + assert result.epic_id == "at-explicit" + assert result.changeset_id is None + assert emitted == [] + + def test_run_startup_contract_explicit_epic_assigned_exits_cleanly() -> None: emitted: list[str] = [] stale_probe: list[tuple[str, str]] = [] From b245bcc684ccd5f6e11077c4a2a54db2612d9433 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 02:49:33 -0700 Subject: [PATCH 17/25] fix(refinement): enforce claim gates and runtime convergence - Wire refinement notes into epic claimability evaluation in beads, store adapter, and session runner classification paths so required non-READY refinements fail closed. - Replace non-simulated refine-plan execution with a runtime round executor that renders prompt-builder templates, invokes an external round runner, and parses structured verdict output. - Resolve refinement max-round budgets with precedence across CLI override, persisted refinement metadata, and project policy defaults. - Add TDD coverage for claim gate wiring, startup missing-approval rejection, runtime refine-plan execution path, and round-budget resolution behavior. - Refresh beads facade inventory counts and format touched plan docs. --- docs/beads-facade-inventory.json | 2 +- ...6-03-28-trycycle-refinement-convergence.md | 124 ++++++++-- ...ycycle-refinement-convergence-test-plan.md | 162 +++++++------ src/atelier/beads.py | 1 + .../refine-plan/scripts/run_refinement.py | 221 ++++++++++++++++-- src/atelier/worker/session/runner.py | 1 + src/atelier/worker/store_adapter.py | 1 + .../atelier/skills/test_refine_plan_script.py | 217 +++++++++++++++++ tests/atelier/test_beads.py | 37 +++ .../worker/test_session_runner_flow.py | 41 ++++ tests/atelier/worker/test_session_startup.py | 35 +++ tests/atelier/worker/test_store_adapter.py | 47 ++++ 12 files changed, 782 insertions(+), 107 deletions(-) diff --git a/docs/beads-facade-inventory.json b/docs/beads-facade-inventory.json index 61aaa583..483dbb47 100644 --- a/docs/beads-facade-inventory.json +++ b/docs/beads-facade-inventory.json @@ -97,7 +97,7 @@ "module": "atelier.beads" } ], - "dotted_refs": 327 + "dotted_refs": 329 } ] } diff --git a/docs/plans/2026-03-28-trycycle-refinement-convergence.md b/docs/plans/2026-03-28-trycycle-refinement-convergence.md index 8ebbf39b..48db77e5 100644 --- a/docs/plans/2026-03-28-trycycle-refinement-convergence.md +++ b/docs/plans/2026-03-28-trycycle-refinement-convergence.md @@ -11,8 +11,8 @@ lineage propagation, and fail-closed worker claim gates. **Architecture:** Build `planning` as the single planning doctrine skill for all planning flows (iterative and non-iterative), sourced from both Atelier planner -contract language and trycycle planning prose. Build `refine-plan` as an -opt-in wrapper that runs trycycle-style stateless planning rounds with explicit +contract language and trycycle planning prose. Build `refine-plan` as an opt-in +wrapper that runs trycycle-style stateless planning rounds with explicit verdicts and bounded retries, then persists authoritative refinement evidence in bead notes. Add a dedicated refinement mutation path (`plan-set-refinement`) so refinement can be enabled on any epic/changeset at any lifecycle point. @@ -20,7 +20,7 @@ refinement can be enabled on any epic/changeset at any lifecycle point. **Tech Stack:** Python 3.11+, Typer/runtime modules, Atelier store/Beads APIs, projected skill scripts, pytest, markdown templates/docs. ---- +______________________________________________________________________ ## Locked requirements from user decisions @@ -32,8 +32,8 @@ projected skill scripts, pytest, markdown templates/docs. 1. Worker overscope breakdown uses refinement iff source lineage is refined. 1. Refinement requires explicit approval evidence from project policy or an explicit per-item operator request. -1. Convergence includes trycycle planning tone/style and emphasis, not only - loop mechanics. +1. Convergence includes trycycle planning tone/style and emphasis, not only loop + mechanics. ## User-visible behavior after cutover @@ -89,6 +89,7 @@ round_log_dir: <abs-path-or-uri> ``` Parser rules: + - newest `authoritative: true` block wins; - if none are authoritative, newest valid block wins; - malformed blocks fail closed only when `required=true` is asserted by any @@ -99,8 +100,8 @@ Parser rules: Refinement activation must be explicit and durable: -1. `plan-set-refinement` can mark any epic/changeset as refined at any - lifecycle stage (`deferred|open|in_progress|blocked`). +1. `plan-set-refinement` can mark any epic/changeset as refined at any lifecycle + stage (`deferred|open|in_progress|blocked`). 1. `required=true` demands approval evidence (`approval_status=approved` and source/principal/timestamp). 1. `project_policy` mode can satisfy approval automatically only when policy is @@ -111,6 +112,7 @@ Refinement activation must be explicit and durable: Top-level executable work is not claimable when refinement is required and either: + - approval evidence is missing, or - latest verdict is not `READY`. @@ -251,16 +253,23 @@ split changeset must carry required inherited refinement. ### Task 1: Produce source-backed trycycle convergence map and tests **Files:** + - Create: `docs/trycycle-planning-convergence.md` + - Create: `tests/atelier/test_trycycle_planning_convergence.py` + - Modify: `docs/behavior.md` - [ ] **Step 1: Identify or write the failing test** Add tests that require the convergence doc to include: + - trycycle source inventory, + - doctrine-vs-mechanics mapping, + - Atelier adaptation rationale, + - explicit non-goals. - [ ] **Step 2: Run test to verify it fails** @@ -297,19 +306,29 @@ git commit -m "docs(planning): add trycycle convergence source map" -m "- Add a ### Task 2: Create baseline `planning` doctrine skill **Files:** + - Create: `src/atelier/skills/planning/SKILL.md` + - Create: `src/atelier/skills/planning/references/planning-doctrine.md` + - Create: `tests/atelier/skills/test_planning_skill_contract.py` + - Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` + - Modify: `tests/atelier/test_planner_agents_template.py` + - Modify: `tests/atelier/test_skills.py` - [ ] **Step 1: Identify or write the failing test** Add contract tests asserting the baseline planning doctrine includes: + - explicit intent/rationale/non-goals framing, + - strategy gate language, + - low bar for replan / high bar for user interruption, + - bite-sized execution-oriented decomposition guidance. - [ ] **Step 2: Run test to verify it fails** @@ -346,18 +365,27 @@ git commit -m "feat(planning): add baseline doctrine skill" -m "- Introduce a re ### Task 3: Add refinement artifact parser and policy config **Files:** + - Create: `src/atelier/planning_refinement.py` + - Modify: `src/atelier/models.py` + - Modify: `src/atelier/config.py` + - Create: `tests/atelier/test_planning_refinement.py` + - Modify: `tests/atelier/test_models.py` - [ ] **Step 1: Identify or write the failing test** Add tests for: + - artifact parse and winning-block selection, + - canonical verdict tokens, + - default 5/8 budgets, + - project policy defaults and validation. - [ ] **Step 2: Run test to verify it fails** @@ -395,17 +423,25 @@ git commit -m "feat(refinement): add artifact contract and policy defaults" -m " ### Task 4: Add any-time activation skill for refinement **Files:** + - Create: `src/atelier/skills/plan-set-refinement/SKILL.md` + - Create: `src/atelier/skills/plan-set-refinement/scripts/set_refinement.py` + - Create: `tests/atelier/skills/test_plan_set_refinement_script.py` + - Modify: `tests/atelier/test_skills.py` + - Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` - [ ] **Step 1: Identify or write the failing test** Add tests for: + - enabling refinement on existing epic/changeset regardless of lifecycle state, + - requiring approval evidence when `required=true`, + - inheriting refinement metadata for lineage-derived activation. - [ ] **Step 2: Run test to verify it fails** @@ -442,21 +478,35 @@ git commit -m "feat(planning): add any-time refinement activation skill" -m "- A ### Task 5: Build `refine-plan` iterative wrapper from trycycle mechanics **Files:** + - Create: `src/atelier/skills/refine-plan/SKILL.md` + - Create: `src/atelier/skills/refine-plan/subagents/prompt-planning-initial.md` + - Create: `src/atelier/skills/refine-plan/subagents/prompt-planning-edit.md` + - Create: `src/atelier/skills/refine-plan/scripts/run_refinement.py` + - Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/build.py` -- Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py` -- Create: `src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py` + +- Create: + `src/atelier/skills/refine-plan/scripts/prompt_builder/template_ast.py` + +- Create: + `src/atelier/skills/refine-plan/scripts/prompt_builder/validate_rendered.py` + - Create: `tests/atelier/skills/test_refine_plan_script.py` - [ ] **Step 1: Identify or write the failing test** Add tests for: + - bounded loop defaults (`max_rounds=5`), + - verdict parsing (`READY|REVISED|USER_DECISION_REQUIRED`), + - per-round artifact emission, + - fail-closed non-convergence behavior. - [ ] **Step 2: Run test to verify it fails** @@ -492,28 +542,48 @@ git commit -m "feat(refine-plan): add trycycle-style iterative planning loop" -m ### Task 6: Wire create/split/promote/guardrail flows to refinement contract **Files:** + - Modify: `src/atelier/skills/plan-create-epic/SKILL.md` + - Modify: `src/atelier/skills/plan-create-epic/scripts/create_epic.py` + - Modify: `src/atelier/skills/plan-changesets/SKILL.md` + - Modify: `src/atelier/skills/plan-changesets/scripts/create_changeset.py` + - Modify: `src/atelier/skills/plan-split-tasks/SKILL.md` + - Create: `src/atelier/skills/plan-split-tasks/scripts/split_tasks.py` + - Modify: `src/atelier/skills/plan-promote-epic/SKILL.md` + - Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` + - Modify: `src/atelier/skills/plan-changeset-guardrails/SKILL.md` -- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + +- Modify: + `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + - Modify: `tests/atelier/skills/test_plan_create_epic_script.py` + - Modify: `tests/atelier/skills/test_plan_changesets_script.py` + - Create: `tests/atelier/skills/test_plan_split_tasks_script.py` + - Modify: `tests/atelier/skills/test_plan_promote_epic_script.py` + - Modify: `tests/atelier/skills/test_plan_changeset_guardrails_script.py` - [ ] **Step 1: Identify or write the failing test** Add tests for: + - create-time refinement metadata writes, + - lineage inheritance on create/split, + - promotion preview exposing refinement readiness, + - guardrails reporting missing approval/verdict evidence. - [ ] **Step 2: Run test to verify it fails** @@ -535,8 +605,7 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** -Run: `uv run pytest tests/atelier/skills/test_plan_* -v` -Expected: PASS. +Run: `uv run pytest tests/atelier/skills/test_plan_* -v` Expected: PASS. - [ ] **Step 6: Commit** @@ -550,20 +619,31 @@ git commit -m "feat(planning): propagate refinement contract through authoring" ### Task 7: Enforce worker claim gate and overscope lineage rules **Files:** + - Modify: `src/atelier/lifecycle.py` + - Modify: `src/atelier/worker/selection.py` + - Modify: `src/atelier/templates/AGENTS.worker.md.tmpl` + - Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` + - Modify: `tests/atelier/test_lifecycle.py` + - Modify: `tests/atelier/worker/test_selection.py` + - Modify: `tests/atelier/worker/test_session_startup.py` + - Modify: `tests/atelier/test_worker_agents_template.py` - [ ] **Step 1: Identify or write the failing test** Add tests asserting: + - refined work claim rejection without approval or `READY`, + - stable rejection reason tokens, + - overscope split guidance preserves refinement lineage. - [ ] **Step 2: Run test to verify it fails** @@ -601,15 +681,21 @@ git commit -m "feat(worker): fail closed on refined claim requirements" -m "- Re ### Task 8: Add compatibility alias and package-level assertions **Files:** + - Create: `src/atelier/skills/plan-refined-deliberation/SKILL.md` + - Modify: `tests/atelier/test_skills.py` - [ ] **Step 1: Identify or write the failing test** Add tests asserting packaged skills include: + - `planning`, + - `refine-plan`, + - `plan-set-refinement`, + - `plan-refined-deliberation` alias. - [ ] **Step 2: Run test to verify it fails** @@ -644,6 +730,7 @@ git commit -m "feat(skills): add refinement compatibility alias" -m "- Add plan- ### Task 9: Final verification and quality gates **Files:** + - Modify: changed files from Tasks 1-8 only when fixes are required. - [ ] **Step 1: Identify or write the failing test** @@ -652,8 +739,7 @@ No new tests; run full project gates. - [ ] **Step 2: Run test to verify it fails (if regressions exist)** -Run: `just test` -Expected: PASS or actionable failures to fix. +Run: `just test` Expected: PASS or actionable failures to fix. - [ ] **Step 3: Write minimal implementation** @@ -661,14 +747,11 @@ Fix regressions without weakening valid tests. - [ ] **Step 4: Run test to verify it passes** -Run: `just test` -Expected: PASS. +Run: `just test` Expected: PASS. - [ ] **Step 5: Refactor and verify** -Run: `just format` -Run: `just lint` -Expected: PASS for both. +Run: `just format` Run: `just lint` Expected: PASS for both. - [ ] **Step 6: Commit** @@ -683,7 +766,8 @@ git commit -m "chore(planning): reconcile final verification fixes" -m "- Resolv ## Completion checklist for this implementation - [ ] `planning` is the primary doctrine for all planning quality. -- [ ] doctrine convergence includes trycycle tone/style, not only loop mechanics. +- [ ] doctrine convergence includes trycycle tone/style, not only loop + mechanics. - [ ] `refine-plan` runs bounded stateless rounds with canonical verdict tokens. - [ ] `plan-set-refinement` enables refinement on existing items at any stage. - [ ] refinement is opt-in, viral by lineage, and approval-gated. diff --git a/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md b/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md index 43eca18f..c5b7daa9 100644 --- a/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md +++ b/docs/plans/2026-03-29-trycycle-refinement-convergence-test-plan.md @@ -1,6 +1,7 @@ # Trycycle Refinement Convergence Test Plan Strategy reconciliation: + - The agreed strategy still holds after reading the implementation plan. - No cost/scope changes are required for approval. - The only adjustment is to add a repository-local trycycle reference fixture so @@ -9,6 +10,7 @@ Strategy reconciliation: ## Harness requirements 1. `H1: Planner Script + Store Integration Harness` (`extend`) + - What it does: Runs planning skill scripts against a real Atelier store built on the in-memory Beads backend (and subprocess transport parity where useful). - What it exposes: Script CLI invocation, stdout/stderr assertions, and post-run @@ -17,14 +19,16 @@ Strategy reconciliation: - Tests depending on it: 5, 6, 7, 8, 9, 10. 2. `H2: Refinement Artifact Fixture/Oracle Harness` (`new`) -- What it does: Generates canonical and malformed - `planning_refinement.v1` note blocks and expected winning/evaluation states. + +- What it does: Generates canonical and malformed `planning_refinement.v1` note + blocks and expected winning/evaluation states. - What it exposes: Programmatic artifact builders, parser inputs, and stable expected gate outcomes. - Estimated complexity: Low. - Tests depending on it: 13, 14, 15. 3. `H3: Trycycle Reference Snapshot Harness` (`new`) + - What it does: Stores repository-local snapshots of the trycycle planning subskill and planning loop prompt/mechanics used as differential references. - What it exposes: Versioned fixture text and anchor map for doctrine and loop @@ -33,6 +37,7 @@ Strategy reconciliation: - Tests depending on it: 1, 11, 12. 4. `H4: Worker Startup Contract Service Harness` (`extend`) + - What it does: Exercises worker startup selection/claimability behavior through `run_startup_contract_service` with typed fake service dependencies. - What it exposes: User-visible startup result reasons and emitted diagnostics @@ -41,6 +46,7 @@ Strategy reconciliation: - Tests depending on it: 3, 4, 14. 5. `H5: Skill Packaging + Template Contract Harness` (`extend`) + - What it does: Validates packaged skill presence/frontmatter and planner/worker template contract text. - What it exposes: Projected skill inventory and AGENTS template assertions. @@ -53,23 +59,24 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. 1. **Name**: Convergence map captures full trycycle source inventory and doctrine/mechanics mapping + - **Type**: regression - **Disposition**: new - **Harness**: `H3` -- **Preconditions**: `docs/trycycle-planning-convergence.md` exists and - trycycle reference snapshots are present in repo fixtures. +- **Preconditions**: `docs/trycycle-planning-convergence.md` exists and trycycle + reference snapshots are present in repo fixtures. - **Actions**: Run `uv run pytest tests/atelier/test_trycycle_planning_convergence.py -k "inventory or mapping" -v`. - **Expected outcome**: Test passes only when the convergence doc includes each required source input, explicit doctrine-vs-mechanics mapping, adaptation - rationale, and non-goals. - Source of truth: implementation plan "Trycycle convergence contract" and user - approved requirement for deep extraction. -- **Interactions**: `docs/trycycle-planning-convergence.md`, - `docs/behavior.md`, planning/refinement skill docs. + rationale, and non-goals. Source of truth: implementation plan "Trycycle + convergence contract" and user approved requirement for deep extraction. +- **Interactions**: `docs/trycycle-planning-convergence.md`, `docs/behavior.md`, + planning/refinement skill docs. 2. **Name**: Planner guidance routes default planning to `planning` and refined requests to `refine-plan` + - **Type**: scenario - **Disposition**: extend - **Harness**: `H5` @@ -83,22 +90,24 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. user-visible behavior. - **Interactions**: `AGENTS.planner.md.tmpl`, skill packaging/projection. -3. **Name**: Worker startup rejects refined work that lacks approval evidence - or READY verdict +3. **Name**: Worker startup rejects refined work that lacks approval evidence or + READY verdict + - **Type**: scenario - **Disposition**: extend - **Harness**: `H4` -- **Preconditions**: Refined epic/changeset metadata exists with - `required=true` and missing/invalid readiness evidence. +- **Preconditions**: Refined epic/changeset metadata exists with `required=true` + and missing/invalid readiness evidence. - **Actions**: Run `uv run pytest tests/atelier/test_lifecycle.py tests/atelier/worker/test_session_startup.py -k "refinement and claim" -v`. - **Expected outcome**: Startup exits without claim and emits stable rejection - reasons for missing approval or non-`READY` verdict. - Source of truth: implementation plan "Claim gate invariant". + reasons for missing approval or non-`READY` verdict. Source of truth: + implementation plan "Claim gate invariant". - **Interactions**: `lifecycle.py`, `worker/selection.py`, `worker/session/startup.py`. 4. **Name**: Unrefined startup/claim selection behavior remains unchanged + - **Type**: regression - **Disposition**: extend - **Harness**: `H4` @@ -107,14 +116,14 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/worker/test_selection.py tests/atelier/worker/test_session_startup.py -k "selected_auto or selected_ready_changeset" -v`. - **Expected outcome**: Existing unrefined paths still resolve actionable epics - and ready fallback exactly as before. - Source of truth: implementation plan "Unrefined flows remain behaviorally - unchanged". + and ready fallback exactly as before. Source of truth: implementation plan + "Unrefined flows remain behaviorally unchanged". - **Interactions**: Worker startup selector pipeline and ready-changeset fallback logic. 5. **Name**: Planner can enable refinement on existing work at any lifecycle point + - **Type**: integration - **Disposition**: new - **Harness**: `H1` @@ -122,15 +131,16 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `in_progress`, and `blocked` states. - **Actions**: Run `uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py -k "lifecycle" -v`. -- **Expected outcome**: `plan-set-refinement` appends authoritative - refinement metadata with mode/source/budgets and succeeds across allowed - lifecycle states. - Source of truth: locked user requirement + implementation plan "Activation and - approval invariant". -- **Interactions**: `plan-set-refinement` script, store note append and readback. +- **Expected outcome**: `plan-set-refinement` appends authoritative refinement + metadata with mode/source/budgets and succeeds across allowed lifecycle + states. Source of truth: locked user requirement + implementation plan + "Activation and approval invariant". +- **Interactions**: `plan-set-refinement` script, store note append and + readback. 6. **Name**: Required refinement cannot be enabled without explicit approval evidence + - **Type**: boundary - **Disposition**: new - **Harness**: `H1` @@ -139,12 +149,12 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/skills/test_plan_set_refinement_script.py -k "approval" -v`. - **Expected outcome**: Script fails closed with deterministic error output and - no persisted authoritative block. - Source of truth: locked user requirement "refinement requires explicit - approval". + no persisted authoritative block. Source of truth: locked user requirement + "refinement requires explicit approval". - **Interactions**: refinement activation validation and note persistence. 7. **Name**: Changeset creation inherits refinement lineage from refined parent + - **Type**: integration - **Disposition**: extend - **Harness**: `H1` @@ -153,12 +163,14 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/skills/test_plan_create_epic_script.py tests/atelier/skills/test_plan_changesets_script.py -k "refinement or inherited" -v`. - **Expected outcome**: New child changesets under refined lineage carry - inherited required refinement metadata and budgets; unrefined lineage does not. - Source of truth: locked user requirement "refinement is viral by lineage". + inherited required refinement metadata and budgets; unrefined lineage does + not. Source of truth: locked user requirement "refinement is viral by + lineage". - **Interactions**: create-epic/create-changeset scripts and store create APIs. 8. **Name**: Splitting overscoped work preserves refinement lineage in all descendants + - **Type**: scenario - **Disposition**: new - **Harness**: `H1` @@ -167,11 +179,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/skills/test_plan_split_tasks_script.py -v`. - **Expected outcome**: `plan-split-tasks` marks all descendants as inherited required refinement when parent lineage is refined and leaves unrefined trees - unmarked. - Source of truth: locked user requirement on overscope split behavior. -- **Interactions**: split script, graph parent/child writes, refinement metadata. + unmarked. Source of truth: locked user requirement on overscope split + behavior. +- **Interactions**: split script, graph parent/child writes, refinement + metadata. 9. **Name**: Guardrail checks report missing refinement contract evidence + - **Type**: integration - **Disposition**: extend - **Harness**: `H1` @@ -180,13 +194,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -k "refinement" -v`. - **Expected outcome**: Guardrail report flags missing refinement completeness - details; fully populated refined records pass. - Source of truth: implementation plan action item for refinement completeness - checks. + details; fully populated refined records pass. Source of truth: implementation + plan action item for refinement completeness checks. - **Interactions**: `check_guardrails.py`, planner authoring contract checks. 10. **Name**: Promotion preview blocks non-ready refined execution paths and surfaces missing sections + - **Type**: scenario - **Disposition**: extend - **Harness**: `H1` @@ -196,12 +210,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -k "refinement or missing detail" -v`. - **Expected outcome**: Preview output includes explicit missing refinement sections; promotion does not apply lifecycle transition until requirements are - satisfied. - Source of truth: implementation plan user-visible promotion/readiness behavior. + satisfied. Source of truth: implementation plan user-visible + promotion/readiness behavior. - **Interactions**: promote script preview renderer and lifecycle transitions. 11. **Name**: Refine-plan loop honors trycycle verdict protocol and bounded rounds + - **Type**: differential - **Disposition**: new - **Harness**: `H3` @@ -211,13 +226,14 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/skills/test_refine_plan_script.py -k "verdict or max_rounds or non_convergence" -v`. - **Expected outcome**: Defaults and behavior match reference strategy: planning-edit cap `5`, canonical verdict tokens - `READY|REVISED|USER_DECISION_REQUIRED`, fail-closed non-convergence. - Source of truth: trycycle `SKILL.md`, `subagents/prompt-planning-edit.md`, - and `orchestrator/run_phase.py`. + `READY|REVISED|USER_DECISION_REQUIRED`, fail-closed non-convergence. Source of + truth: trycycle `SKILL.md`, `subagents/prompt-planning-edit.md`, and + `orchestrator/run_phase.py`. - **Interactions**: `refine-plan` loop engine, prompt assembly, result parsing. 12. **Name**: Planning doctrine preserves trycycle planning tone/emphasis, not just mechanics + - **Type**: differential - **Disposition**: new - **Harness**: `H3`, `H5` @@ -226,13 +242,14 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/skills/test_planning_skill_contract.py -v`. - **Expected outcome**: Doctrine includes required emphasis categories from reference planning prose (strategy gate, low bar to replan, high bar to user - interruption, bite-sized tasking, explicit invariants/contracts). - Source of truth: user-approved direction and trycycle + interruption, bite-sized tasking, explicit invariants/contracts). Source of + truth: user-approved direction and trycycle `subskills/trycycle-planning/SKILL.md`. - **Interactions**: planning skill docs, planner template contract tests. 13. **Name**: Refinement artifact parser deterministically selects the winning authoritative block + - **Type**: invariant - **Disposition**: new - **Harness**: `H2` @@ -241,12 +258,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/test_planning_refinement.py -k "authoritative or newest" -v`. - **Expected outcome**: Newest authoritative valid block wins; otherwise newest - valid block wins; deterministic across permutations. - Source of truth: implementation plan parser rules. + valid block wins; deterministic across permutations. Source of truth: + implementation plan parser rules. - **Interactions**: `planning_refinement.py` parse/select functions. 14. **Name**: Malformed or unknown verdict refinement evidence fails closed only when refinement is required + - **Type**: boundary - **Disposition**: new - **Harness**: `H2`, `H4` @@ -254,12 +272,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. - **Actions**: Run `uv run pytest tests/atelier/test_planning_refinement.py tests/atelier/worker/test_session_startup.py -k "malformed or verdict" -v`. - **Expected outcome**: Required refined work is blocked with stable reason; - unrefined work remains eligible despite malformed optional metadata. - Source of truth: parser fail-closed scope in implementation plan. + unrefined work remains eligible despite malformed optional metadata. Source of + truth: parser fail-closed scope in implementation plan. - **Interactions**: parser + startup claimability evaluation. 15. **Name**: Refinement parser performance avoids catastrophic startup regressions + - **Type**: boundary - **Disposition**: new - **Harness**: `H2` @@ -268,13 +287,13 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/test_planning_refinement.py -k "performance" -v`. - **Expected outcome**: Parsing a 1,000-block note payload completes below a generous catastrophic-regression threshold (for example `<1s` on CI class - hardware). - Source of truth: worker startup repeatedly evaluates claimability and must - remain practical. + hardware). Source of truth: worker startup repeatedly evaluates claimability + and must remain practical. - **Interactions**: startup-critical parsing path. 16. **Name**: New planning/refinement skills ship and validate in projected workspaces + - **Type**: regression - **Disposition**: extend - **Harness**: `H5` @@ -283,37 +302,34 @@ Harness implementation order: `H3`, `H2`, `H1`, `H4`, `H5`. `uv run pytest tests/atelier/test_skills.py tests/atelier/test_skill_frontmatter_validation.py -k "planning or refinement" -v`. - **Expected outcome**: Packaged skill inventory includes `planning`, `refine-plan`, `plan-set-refinement`, and `plan-refined-deliberation`; all - frontmatter validation passes. - Source of truth: implementation plan file structure and migration - compatibility requirement. + frontmatter validation passes. Source of truth: implementation plan file + structure and migration compatibility requirement. - **Interactions**: skill packaging, sync/install, frontmatter validator. ## Coverage summary Covered action space: -- Planner-facing refinement actions: - `plan-set-refinement`, `plan-create-epic`, `plan-changesets`, - `plan-split-tasks`, `plan-changeset-guardrails`, `plan-promote-epic`. -- Planning doctrine and trigger behavior: - planner template routing, packaged planning/refinement skills, doctrine - content parity with trycycle references. -- Refine-plan orchestration behavior: - verdict protocol, bounded loop semantics, fail-closed non-convergence, - prompt/reference parity. -- Worker-facing claim behavior: - startup claim rejection for incomplete required refinement and unchanged - behavior for unrefined work. -- Data contract behavior: - refinement artifact parsing, winning block selection, malformed input handling, - and performance envelope. + +- Planner-facing refinement actions: `plan-set-refinement`, `plan-create-epic`, + `plan-changesets`, `plan-split-tasks`, `plan-changeset-guardrails`, + `plan-promote-epic`. +- Planning doctrine and trigger behavior: planner template routing, packaged + planning/refinement skills, doctrine content parity with trycycle references. +- Refine-plan orchestration behavior: verdict protocol, bounded loop semantics, + fail-closed non-convergence, prompt/reference parity. +- Worker-facing claim behavior: startup claim rejection for incomplete required + refinement and unchanged behavior for unrefined work. +- Data contract behavior: refinement artifact parsing, winning block selection, + malformed input handling, and performance envelope. Explicit exclusions per strategy: + - Real network/API integration with GitHub or external providers is excluded; - tests stay local and deterministic. - Risk: provider-specific failures could still appear in production. + tests stay local and deterministic. Risk: provider-specific failures could + still appear in production. - Full live multi-subagent orchestration (real Codex/Kimi/Claude sessions) is - excluded; tests validate Atelier-side contracts and scripts only. - Risk: runner integration edge cases may require follow-up in integration envs. + excluded; tests validate Atelier-side contracts and scripts only. Risk: runner + integration edge cases may require follow-up in integration envs. - Visual/manual prompt quality review is excluded; doctrine/tone checks are - enforced via reproducible text assertions and differential fixtures. - Risk: subtle prose quality regressions not represented by assertions may slip. + enforced via reproducible text assertions and differential fixtures. Risk: + subtle prose quality regressions not represented by assertions may slip. diff --git a/src/atelier/beads.py b/src/atelier/beads.py index 1786f915..ae511a28 100644 --- a/src/atelier/beads.py +++ b/src/atelier/beads.py @@ -3791,6 +3791,7 @@ def _evaluate_epic_claimability( labels=_issue_labels(issue), issue_type=lifecycle.issue_payload_type(issue), parent_id=_issue_parent_id(issue), + notes=issue.get("notes"), ) diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index 4d6bbe3d..eeec0604 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -16,7 +16,10 @@ import argparse import asyncio import json +import os import re +import shlex +import subprocess import sys from dataclasses import dataclass from pathlib import Path @@ -69,6 +72,8 @@ class RefinementRunResult: RoundExecutor = Callable[[int, str], RoundResult] _UNCHECKED_CHECKLIST_RE: Final[re.Pattern[str]] = re.compile(r"^\s*[-*]\s+\[\s\]\s+\S") _NUMBERED_STEP_RE: Final[re.Pattern[str]] = re.compile(r"^\s*\d+\.\s+\S") +_ROUND_RUNNER_ENV: Final[str] = "ATELIER_REFINEMENT_ROUND_RUNNER" +_VERDICT_HEADER_RE: Final[re.Pattern[str]] = re.compile(r"^\s*##\s*Plan verdict\s*$", re.IGNORECASE) def parse_verdict(raw: str) -> RefinementVerdict: @@ -211,6 +216,138 @@ def _looks_executable_plan(plan_text: str) -> bool: return False +def _run_prompt_builder(*, template_path: Path, bindings: dict[str, str]) -> str: + builder_root = Path(__file__).resolve().parent / "prompt_builder" + if str(builder_root) not in sys.path: + sys.path.insert(0, str(builder_root)) + + from template_ast import ( # pyright: ignore[reportMissingImports] + parse_template_text, + render_nodes, + ) + from validate_rendered import validate_rendered_prompt # pyright: ignore[reportMissingImports] + + template_text = template_path.read_text(encoding="utf-8") + full_template = ( + f"{template_text}\n\n" + "<round-context>\n" + "round: {ROUND_NUMBER}\n" + "max_rounds: {MAX_ROUNDS}\n" + "</round-context>\n\n" + "<current-plan>\n" + "{PLAN_TEXT}\n" + "</current-plan>\n" + ) + nodes = parse_template_text(full_template) + prompt_text = render_nodes(nodes, bindings) + validate_rendered_prompt(prompt_text) + return prompt_text + + +def _runner_command_tokens() -> list[str] | None: + raw = _clean(os.environ.get(_ROUND_RUNNER_ENV, "")) + if raw is None: + return None + tokens = shlex.split(raw) + if not tokens: + return None + return tokens + + +def _extract_verdict_and_plan( + *, raw_output: str, fallback_plan: str +) -> tuple[RefinementVerdict, str]: + lines = raw_output.splitlines() + verdict_line_index: int | None = None + for index, line in enumerate(lines): + if _VERDICT_HEADER_RE.match(line): + verdict_line_index = index + break + if verdict_line_index is None: + raise ValueError("round output is missing '## Plan verdict' section") + + token_index = verdict_line_index + 1 + while token_index < len(lines) and not lines[token_index].strip(): + token_index += 1 + if token_index >= len(lines): + raise ValueError("round output is missing verdict token") + + verdict_token = lines[token_index].strip().split()[0] + verdict = parse_verdict(verdict_token) + plan_tail = "\n".join(lines[token_index + 1 :]).strip() + if not plan_tail: + return verdict, fallback_plan + return verdict, plan_tail + "\n" + + +def _run_round_runner(*, command: list[str], prompt_text: str) -> str: + completed = subprocess.run( + command, + input=prompt_text, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip() or "no output" + raise RuntimeError(f"round runner failed (exit {completed.returncode}): {detail}") + return completed.stdout or "" + + +def _build_runtime_round_executor(*, max_rounds: int) -> RoundExecutor: + runner_command = _runner_command_tokens() + skill_root = Path(__file__).resolve().parent.parent + initial_template = skill_root / "subagents" / "prompt-planning-initial.md" + edit_template = skill_root / "subagents" / "prompt-planning-edit.md" + + def executor(round_number: int, plan_text: str) -> RoundResult: + template_path = initial_template if round_number == 1 else edit_template + try: + prompt_text = _run_prompt_builder( + template_path=template_path, + bindings={ + "PLAN_TEXT": plan_text, + "ROUND_NUMBER": str(round_number), + "MAX_ROUNDS": str(max_rounds), + }, + ) + except Exception as exc: + return RoundResult( + verdict="USER_DECISION_REQUIRED", + plan_text=plan_text, + summary=f"prompt render failed at round {round_number}: {exc}", + ) + + if runner_command is None: + fallback = _default_round_executor(round_number, plan_text) + summary = fallback.summary or "runtime round runner not configured" + return RoundResult( + verdict=fallback.verdict, + plan_text=fallback.plan_text, + summary=f"runtime orchestration fallback: {summary}", + ) + + try: + raw_output = _run_round_runner(command=runner_command, prompt_text=prompt_text) + verdict, revised_plan = _extract_verdict_and_plan( + raw_output=raw_output, + fallback_plan=plan_text, + ) + return RoundResult( + verdict=verdict, + plan_text=revised_plan, + summary=f"runtime round {round_number} via {' '.join(runner_command)}", + ) + except Exception as exc: + return RoundResult( + verdict="USER_DECISION_REQUIRED", + plan_text=plan_text, + summary=f"round execution failed at round {round_number}: {exc}", + ) + + return executor + + def _build_store(*, beads_root: Path, repo_root: Path): from atelier.lib.beads import SubprocessBeadsClient from atelier.store import build_atelier_store @@ -380,12 +517,63 @@ def executor(round_number: int, plan_text: str) -> RoundResult: return executor +def _selected_refinement_round_limit(*, store, issue_id: str) -> int | None: + from atelier.planning_refinement import parse_refinement_blocks, select_winning_refinement + + issue = asyncio.run(_resolve_work_item(store, issue_id)) + notes = _normalize_notes_text(getattr(issue, "notes", None)) + selected = select_winning_refinement(parse_refinement_blocks(notes)) + if selected is None: + return None + return int(selected.plan_edit_rounds_max) + + +def _resolve_policy_round_limit(*, repo_root: Path) -> int | None: + from atelier import config as atelier_config + from atelier import git, paths + from atelier.commands.resolve import resolve_project_for_enlistment + + try: + _repo_root, enlistment_path, _origin_raw, origin = git.resolve_repo_enlistment(repo_root) + project_root, _project_config, _resolved_enlistment = resolve_project_for_enlistment( + enlistment_path, origin + ) + config_path = paths.project_config_path(project_root) + project_config = atelier_config.load_project_config(config_path) + except (Exception, SystemExit): + return None + if project_config is None: + return None + policy = atelier_config.resolve_refinement_policy(project_config) + if policy is None: + return None + return int(policy.plan_edit_rounds_max) + + +def _resolve_max_rounds( + *, + cli_max_rounds: int | None, + store, + issue_id: str, + repo_root: Path, +) -> int: + if cli_max_rounds is not None: + return int(cli_max_rounds) + selected_limit = _selected_refinement_round_limit(store=store, issue_id=issue_id) + if selected_limit is not None: + return selected_limit + policy_limit = _resolve_policy_round_limit(repo_root=repo_root) + if policy_limit is not None: + return policy_limit + return REFINEMENT_MAX_ROUNDS_DEFAULT + + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--issue-id", required=True, help="Epic or changeset issue id") parser.add_argument("--initial-plan-path", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) - parser.add_argument("--max-rounds", type=int, default=REFINEMENT_MAX_ROUNDS_DEFAULT) + parser.add_argument("--max-rounds", type=int, default=None) parser.add_argument("--beads-dir", default="", help="Beads directory override") parser.add_argument("--repo-dir", default="", help="Repo root override") parser.add_argument( @@ -402,29 +590,36 @@ def main() -> int: output_dir.mkdir(parents=True, exist_ok=True) try: + issue_id = args.issue_id.strip() + initial_plan_path = args.initial_plan_path.resolve() + beads_root, repo_root, runtime_warning = _resolve_context( + beads_dir=_clean(args.beads_dir), + repo_dir=_clean(args.repo_dir), + ) + if runtime_warning: + print(runtime_warning, file=sys.stderr) + store = _build_store(beads_root=beads_root, repo_root=repo_root) + max_rounds = _resolve_max_rounds( + cli_max_rounds=args.max_rounds, + store=store, + issue_id=issue_id, + repo_root=repo_root, + ) + if args.simulate_verdicts: verdicts = [item.strip() for item in args.simulate_verdicts.split(",") if item.strip()] round_executor = _simulate_round_executor(verdicts) else: - round_executor = _default_round_executor - - initial_plan_path = args.initial_plan_path.resolve() + round_executor = _build_runtime_round_executor(max_rounds=max_rounds) result = run_refinement( initial_plan_path=initial_plan_path, output_dir=output_dir, round_executor=round_executor, - max_rounds=args.max_rounds, + max_rounds=max_rounds, ) - beads_root, repo_root, runtime_warning = _resolve_context( - beads_dir=_clean(args.beads_dir), - repo_dir=_clean(args.repo_dir), - ) - if runtime_warning: - print(runtime_warning, file=sys.stderr) - store = _build_store(beads_root=beads_root, repo_root=repo_root) _persist_refinement_evidence( store=store, - issue_id=args.issue_id.strip(), + issue_id=issue_id, result=result, initial_plan_path=initial_plan_path, output_dir=output_dir, diff --git a/src/atelier/worker/session/runner.py b/src/atelier/worker/session/runner.py index 19abb678..38363fa0 100644 --- a/src/atelier/worker/session/runner.py +++ b/src/atelier/worker/session/runner.py @@ -265,6 +265,7 @@ def _classify_claim_failure( labels=lifecycle.normalized_labels(issue.get("labels")), issue_type=lifecycle.issue_payload_type(issue), parent_id=parent_id, + notes=issue.get("notes"), ) if claimability.role.is_epic and not claimability.claimable: return ClaimFailure( diff --git a/src/atelier/worker/store_adapter.py b/src/atelier/worker/store_adapter.py index 13cb1e67..6d39f1ae 100644 --- a/src/atelier/worker/store_adapter.py +++ b/src/atelier/worker/store_adapter.py @@ -848,6 +848,7 @@ def claim_epic( labels=lifecycle.normalized_labels(issue.get("labels")), issue_type=lifecycle.issue_payload_type(issue), parent_id=worker_selection.issue_parent_id(issue), + notes=issue.get("notes"), ) is_executable = lifecycle.is_executable_epic_identity( labels=lifecycle.normalized_labels(issue.get("labels")), diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index 1b6d3a69..09c98566 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -2,6 +2,7 @@ import importlib.util import json +import subprocess import sys from pathlib import Path from types import SimpleNamespace @@ -364,3 +365,219 @@ def test_refine_plan_loop_artifacts_match_trycycle_snapshot_anchors() -> None: assert not missing_in_snapshot, f"snapshot anchors missing: {missing_in_snapshot}" assert not missing_in_live, f"live refine-plan anchors missing: {missing_in_live}" + + +def test_refine_plan_non_simulated_mode_uses_runtime_executor( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + initial_plan_path.write_text("- [ ] Step 1\n", encoding="utf-8") + calls: list[tuple[int, str]] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, notes="") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + return SimpleNamespace(id=request.issue_id) + + def fake_runtime_executor(*, max_rounds: int): + assert max_rounds == 5 + + def executor(round_number: int, plan_text: str): + calls.append((round_number, plan_text)) + return module.RoundResult(verdict="READY", plan_text=plan_text, summary="runtime") + + return executor + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr(module, "_build_runtime_round_executor", fake_runtime_executor) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-123", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert calls == [(1, "- [ ] Step 1\n")] + + +def test_refine_plan_non_simulated_uses_existing_refinement_round_budget( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + initial_plan_path.write_text("- [ ] Step 1\n", encoding="utf-8") + captured_max_rounds: list[int] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace( + id=issue_id, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 9\n" + "latest_verdict: REVISED\n" + ), + ) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + return SimpleNamespace(id=request.issue_id) + + def fake_runtime_executor(*, max_rounds: int): + captured_max_rounds.append(max_rounds) + + def executor(round_number: int, plan_text: str): + del round_number + return module.RoundResult(verdict="READY", plan_text=plan_text, summary="runtime") + + return executor + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr(module, "_build_runtime_round_executor", fake_runtime_executor) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-123", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert captured_max_rounds == [9] + + +def test_refine_plan_non_simulated_uses_policy_round_budget_when_no_metadata( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + initial_plan_path.write_text("- [ ] Step 1\n", encoding="utf-8") + captured_max_rounds: list[int] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, notes="") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + return SimpleNamespace(id=request.issue_id) + + def fake_runtime_executor(*, max_rounds: int): + captured_max_rounds.append(max_rounds) + + def executor(round_number: int, plan_text: str): + del round_number + return module.RoundResult(verdict="READY", plan_text=plan_text, summary="runtime") + + return executor + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr(module, "_build_runtime_round_executor", fake_runtime_executor) + monkeypatch.setattr(module, "_resolve_policy_round_limit", lambda **_kwargs: 11) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-123", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert captured_max_rounds == [11] + + +def test_runtime_round_executor_renders_prompt_and_parses_runner_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script_module() + prompt_builder_calls: list[dict[str, object]] = [] + executed_commands: list[list[str]] = [] + + def fake_run_prompt_builder(*, template_path: Path, bindings: dict[str, str]) -> str: + prompt_builder_calls.append({"template_path": template_path, "bindings": bindings}) + return "rendered prompt" + + def fake_run( + args: list[str], + *, + input: str, + text: bool, + capture_output: bool, + check: bool, + ) -> subprocess.CompletedProcess[str]: + del text, capture_output, check + executed_commands.append(args) + assert input == "rendered prompt" + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout="## Plan verdict\nREADY\n\n# Updated plan\n- [ ] Step\n", + stderr="", + ) + + monkeypatch.setattr(module, "_run_prompt_builder", fake_run_prompt_builder) + monkeypatch.setattr(module.subprocess, "run", fake_run) + monkeypatch.setenv("ATELIER_REFINEMENT_ROUND_RUNNER", "echo-runner --round") + + executor = module._build_runtime_round_executor(max_rounds=5) + result = executor(1, "- [ ] Step\n") + + assert result.verdict == "READY" + assert result.plan_text == "# Updated plan\n- [ ] Step\n" + assert prompt_builder_calls + assert executed_commands == [["echo-runner", "--round"]] diff --git a/tests/atelier/test_beads.py b/tests/atelier/test_beads.py index 4e5949df..c73b9d8e 100644 --- a/tests/atelier/test_beads.py +++ b/tests/atelier/test_beads.py @@ -4235,6 +4235,43 @@ def test_claim_epic_rejects_deferred_executable_work() -> None: ) +def test_claim_epic_rejects_required_refinement_without_ready_verdict() -> None: + issue = { + "id": "at-legacy", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-legacy\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + } + + with ( + patch("atelier.beads.run_bd_json", return_value=[issue]), + patch("atelier.beads.die", side_effect=RuntimeError("die called")) as die_fn, + ): + with pytest.raises(RuntimeError, match="die called"): + beads.claim_epic( + "at-legacy", + "agent", + beads_root=Path("/beads"), + cwd=Path("/repo"), + ) + + assert "not claimable under lifecycle contract (refinement_not_ready)" in str( + die_fn.call_args.args[0] + ) + + def test_set_agent_hook_updates_description() -> None: state = {"description": "role: worker\n"} captured: dict[str, str] = {} diff --git a/tests/atelier/worker/test_session_runner_flow.py b/tests/atelier/worker/test_session_runner_flow.py index 40ea47f4..e691304c 100644 --- a/tests/atelier/worker/test_session_runner_flow.py +++ b/tests/atelier/worker/test_session_runner_flow.py @@ -764,6 +764,47 @@ def test_classify_claim_failure_fails_closed_when_hook_lookup_fails() -> None: assert failure.detail == "hook_lookup_failed" +def test_classify_claim_failure_reports_refinement_not_ready_non_claimable() -> None: + beads = SimpleNamespace( + run_bd_json=Mock( + return_value=[ + { + "id": "at-refined", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-refined\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + } + ] + ), + find_agent_bead=Mock(), + run_bd_command=Mock(), + ) + + failure = runner._classify_claim_failure( # pyright: ignore[reportPrivateUsage] + beads=beads, + epic_id="at-refined", + agent_id="atelier/worker/codex/p3c", + allow_takeover_from=None, + beads_root=Path("/project/.atelier/.beads"), + repo_root=Path("/repo"), + ) + + assert failure.kind == "non_claimable" + assert failure.detail == "refinement_not_ready" + + def test_run_worker_once_reclaims_stale_explicit_assignment_and_clears_old_hook() -> None: agent = AgentHome( name="worker", diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index 697185a2..d0e22ca5 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -719,6 +719,41 @@ def next_changeset(**_kwargs: Any) -> dict[str, object] | None: ] +def test_run_startup_contract_explicit_epic_refinement_missing_approval_exits_cleanly() -> None: + emitted: list[str] = [] + + def next_changeset(**_kwargs: Any) -> dict[str, object] | None: + raise AssertionError("next_changeset should not run for non-claimable explicit epic") + + result = _run_startup( + explicit_epic_id="at-explicit", + show_issue=lambda _issue_id: { + "id": "at-explicit", + "status": "open", + "labels": ["at:epic"], + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-explicit\n" + "approval_status: missing\n" + "latest_verdict: READY\n" + ), + }, + next_changeset=next_changeset, + emit=lambda message: emitted.append(message), + ) + + assert result.should_exit is True + assert result.reason == "explicit_epic_not_claimable" + assert result.epic_id == "at-explicit" + assert emitted == [ + "Explicit epic at-explicit is not claimable under lifecycle contract " + "(refinement_approval_missing); move it to open/in_progress and rerun without an epic id." + ] + + def test_run_startup_contract_explicit_epic_malformed_unrefined_metadata_stays_claimable() -> None: emitted: list[str] = [] diff --git a/tests/atelier/worker/test_store_adapter.py b/tests/atelier/worker/test_store_adapter.py index a274cf87..15b13bb3 100644 --- a/tests/atelier/worker/test_store_adapter.py +++ b/tests/atelier/worker/test_store_adapter.py @@ -2,6 +2,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from atelier.lib.beads import IssueRecord, SyncBeadsClient from atelier.messages import render_message from atelier.store import HookRecord, StartupMessageRecord, build_atelier_store @@ -74,6 +76,51 @@ def test_claim_epic_marks_in_progress_and_hooked(monkeypatch) -> None: worker_store.clear_bundle_cache() +def test_claim_epic_rejects_required_refinement_without_ready_verdict(monkeypatch) -> None: + builder = IssueFixtureBuilder() + _patch_bundle( + monkeypatch, + issues=( + builder.issue( + "at-epic", + issue_type="epic", + labels=("at:epic",), + status="open", + extra_fields={ + "notes": ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ) + }, + ), + ), + ) + + with patch( + "atelier.worker.store_adapter.die", side_effect=RuntimeError("die called") + ) as die_fn: + with pytest.raises(RuntimeError, match="die called"): + worker_store.claim_epic( + "at-epic", + "atelier/worker/codex/p100", + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + assert "not claimable under lifecycle contract (refinement_not_ready)" in str( + die_fn.call_args.args[0] + ) + worker_store.clear_bundle_cache() + + def test_release_epic_assignment_clears_assignee_and_hook_label(monkeypatch) -> None: builder = IssueFixtureBuilder() agent_id = "atelier/worker/codex/p100" From a8fee7cbe7dae22cebd957432786143ccf88380d Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 03:03:24 -0700 Subject: [PATCH 18/25] fix(refinement): isolate note parsing and fail closed without runner - Treat blocks as contiguous field records and stop parsing at freeform note text, preventing unrelated appended notes from invalidating valid refinement evidence. - Restrict recognized field lines to contract-style keys so prose with colons is not misparsed as refinement metadata. - Make non-simulated fail closed with when no round runner is configured, removing checklist-shape auto-READY behavior. - Add TDD coverage for trailing freeform-note parser isolation and no-runner fail-closed behavior on executable-looking plans. --- src/atelier/planning_refinement.py | 26 ++++++++++++++++--- .../refine-plan/scripts/run_refinement.py | 21 --------------- .../atelier/skills/test_refine_plan_script.py | 8 +++--- tests/atelier/test_planning_refinement.py | 26 +++++++++++++++++++ 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index 6c0c3e84..e8dbd8c4 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -8,6 +8,7 @@ from __future__ import annotations import datetime as dt +import re from dataclasses import dataclass from typing import Final, Literal @@ -18,6 +19,7 @@ _REFINEMENT_MARKER: Final[str] = "planning_refinement.v1" _TRUE_TOKENS: Final[frozenset[str]] = frozenset({"true", "1", "yes"}) _FALSE_TOKENS: Final[frozenset[str]] = frozenset({"false", "0", "no"}) +_FIELD_LINE_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z_][a-z0-9_]*\s*:") RefinementMode = Literal["requested", "inherited", "project_policy"] ApprovalStatus = Literal["approved", "missing"] @@ -220,11 +222,25 @@ def parse_refinement_blocks(notes: str | None) -> tuple[ParsedRefinementBlock, . continue start = index index += 1 - while index < len(lines) and lines[index].strip() != _REFINEMENT_MARKER: - index += 1 + field_lines: list[str] = [] + while index < len(lines): + stripped = lines[index].strip() + if stripped == _REFINEMENT_MARKER: + break + if not stripped: + field_lines.append(lines[index]) + index += 1 + continue + if _looks_like_refinement_field(lines[index]): + field_lines.append(lines[index]) + index += 1 + continue + # Stop this block before freeform note text to preserve append-only + # notes behavior and avoid false malformed-state outcomes. + break raw_lines = lines[start:index] raw_text = "\n".join(raw_lines) - field_map, syntax_errors = _parse_field_map(raw_lines[1:]) + field_map, syntax_errors = _parse_field_map(field_lines) authoritative_hint = _parse_bool_token(field_map.get("authoritative")) is True required_hint = _parse_bool_token(field_map.get("required")) is True record: PlanningRefinementRecord | None = None @@ -352,6 +368,10 @@ def _parse_field_map(lines: list[str]) -> tuple[dict[str, str], tuple[str, ...]] return field_map, tuple(errors) +def _looks_like_refinement_field(line: str) -> bool: + return bool(_FIELD_LINE_RE.match(line.strip())) + + def _parse_bool_token(value: object) -> bool | None: if isinstance(value, bool): return value diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index eeec0604..acb49372 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -70,8 +70,6 @@ class RefinementRunResult: RoundExecutor = Callable[[int, str], RoundResult] -_UNCHECKED_CHECKLIST_RE: Final[re.Pattern[str]] = re.compile(r"^\s*[-*]\s+\[\s\]\s+\S") -_NUMBERED_STEP_RE: Final[re.Pattern[str]] = re.compile(r"^\s*\d+\.\s+\S") _ROUND_RUNNER_ENV: Final[str] = "ATELIER_REFINEMENT_ROUND_RUNNER" _VERDICT_HEADER_RE: Final[re.Pattern[str]] = re.compile(r"^\s*##\s*Plan verdict\s*$", re.IGNORECASE) @@ -187,15 +185,6 @@ def run_refinement( def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: - if _looks_executable_plan(plan_text): - return RoundResult( - verdict="READY", - plan_text=plan_text, - summary=( - "default local executor marked plan ready from executable task structure " - f"at round {round_number}" - ), - ) return RoundResult( verdict="USER_DECISION_REQUIRED", plan_text=plan_text, @@ -206,16 +195,6 @@ def _default_round_executor(round_number: int, plan_text: str) -> RoundResult: ) -def _looks_executable_plan(plan_text: str) -> bool: - """Return whether plan text has deterministic executable-task structure.""" - for line in plan_text.splitlines(): - if _UNCHECKED_CHECKLIST_RE.match(line): - return True - if _NUMBERED_STEP_RE.match(line): - return True - return False - - def _run_prompt_builder(*, template_path: Path, bindings: dict[str, str]) -> str: builder_root = Path(__file__).resolve().parent / "prompt_builder" if str(builder_root) not in sys.path: diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index 09c98566..1f9e05f7 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -180,7 +180,7 @@ async def append_notes(self, request): assert payload["latest_verdict"] == "USER_DECISION_REQUIRED" -def test_refine_plan_main_without_simulation_can_reach_ready_for_executable_plan( +def test_refine_plan_main_without_simulation_without_runner_fails_closed_even_for_executable_plan( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -235,11 +235,11 @@ async def append_notes(self, request): exit_code = module.main() - assert exit_code == 0 + assert exit_code == 1 assert appended_notes payload = json.loads((output_dir / "result.json").read_text(encoding="utf-8")) - assert payload["status"] == "ready" - assert payload["latest_verdict"] == "READY" + assert payload["status"] == "non_converged" + assert payload["latest_verdict"] == "USER_DECISION_REQUIRED" def test_refine_plan_main_persists_authoritative_refinement_evidence( diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index cd060a9d..eff9b526 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -142,6 +142,32 @@ def test_refinement_rejects_non_iso_approval_timestamp() -> None: assert gate.reason == "refinement_metadata_missing_or_malformed" +def test_refinement_parser_ignores_trailing_non_refinement_note_text() -> None: + notes = ( + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="READY", + ) + + "\n\n" + + "Follow-up note from operator: keep scope narrow for execution." + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is not None + assert selected.latest_verdict == "READY" + assert gate.required is True + assert gate.claimable is True + assert gate.reason is None + + def test_refinement_parser_handles_large_note_payload_performance() -> None: notes = "\n\n".join( _block( From e8fe049f589396c59e1a6642b6b1cf7e033ec7c7 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 03:19:21 -0700 Subject: [PATCH 19/25] fix(refinement): enforce fail-closed lineage and policy approval source - Fail closed in plan-changesets when fallback parent-note lookup errors, so refined lineage cannot be silently dropped under lookup failures. - Keep direct store notes as the primary source and only use fallback lookup when notes are unavailable on the parent record. - Reject operator approval_source for required project_policy mode and require project_policy attribution for policy-derived approvals. - Add TDD coverage for parent-note lookup failure handling and project_policy approval-source validation. - Update planner store migration contract parity test wiring so fallback note reads use the test backend client. --- .../scripts/create_changeset.py | 14 +++- .../scripts/set_refinement.py | 4 +- .../skills/test_plan_changesets_script.py | 71 +++++++++++++++++++ .../skills/test_plan_set_refinement_script.py | 53 ++++++++++++++ .../test_planner_store_migration_contract.py | 4 ++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index 0b396dc9..289b7220 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -113,16 +113,24 @@ def _render_refinement_note(record: object) -> str: def _parent_notes(*, store, epic_id: str, beads_root: Path, repo_root: Path) -> str | None: + sentinel = object() if not hasattr(store, "get_epic"): return None parent = asyncio.run(store.get_epic(epic_id)) - from_store = getattr(parent, "notes", None) + from_store = getattr(parent, "notes", sentinel) + if from_store is None: + return None if isinstance(from_store, str) and from_store.strip(): return from_store + if isinstance(from_store, str): + return None if isinstance(from_store, (tuple, list)): joined = "\n".join(str(item).strip() for item in from_store if str(item).strip()) if joined: return joined + return None + if from_store is not sentinel: + return None from atelier.lib.beads import ShowIssueRequest, SubprocessBeadsClient @@ -133,8 +141,8 @@ def _parent_notes(*, store, epic_id: str, beads_root: Path, repo_root: Path) -> ) try: issue = asyncio.run(client.show(ShowIssueRequest(issue_id=epic_id))) - except Exception: - return None + except Exception as exc: + raise RuntimeError(f"failed to read parent refinement notes for {epic_id}: {exc}") from exc notes = getattr(issue, "notes", None) if isinstance(notes, str) and notes.strip(): return notes diff --git a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py index f67cdc15..67b9aae6 100644 --- a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py +++ b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py @@ -140,9 +140,11 @@ def _validate_approval_fields( raise ValueError( "project_policy mode requires configured policy (required_by_default=true)" ) + if approval_source not in {None, "project_policy"}: + raise ValueError("project_policy mode requires approval_source=project_policy") return ( "approved", - cast(ApprovalSource, approval_source or "project_policy"), + "project_policy", approved_by or "project_policy", approved_at or _utc_now_iso8601(), ) diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index b73f7693..0277e9f1 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -462,6 +462,77 @@ async def append_notes(self, request): # pragma: no cover - defensive assert request.notes == ("preserve original operator note",) +def test_create_changeset_fails_closed_when_parent_notes_lookup_errors( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + import atelier.lib.beads as beads_lib + + module = _load_script_module() + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + created_requests: list[object] = [] + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_changeset(self, request): + created_requests.append(request) + return SimpleNamespace(id="at-epic.3") + + async def get_epic(self, epic_id): + # Omit notes entirely to force fallback lookup via client.show. + return SimpleNamespace(id=epic_id) + + class ExplodingClient: + def __init__(self, **_kwargs): + pass + + async def show(self, _request): + raise RuntimeError("transient show failure") + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr(beads_lib, "SubprocessBeadsClient", ExplodingClient) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_changeset.py", + "--epic-id", + "at-epic", + "--title", + "Fail closed lineage read error", + "--acceptance", + "Child creation must stop when parent refinement notes are unreadable.", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "failed to read parent refinement notes" in captured.err + assert created_requests == [] + + @pytest.mark.parametrize( ("parent_notes", "expect_inherited"), [ diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py index bf4b6c38..8e966885 100644 --- a/tests/atelier/skills/test_plan_set_refinement_script.py +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -215,6 +215,59 @@ async def append_notes(self, request): assert "post_impl_review_rounds_max: 21" in note +def test_set_refinement_project_policy_mode_rejects_operator_approval_source( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_script_module() + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + module, + "_resolve_refinement_policy", + lambda **_kwargs: SimpleNamespace( + required_by_default=True, + plan_edit_rounds_max=13, + post_impl_review_rounds_max=21, + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + "--mode", + "project_policy", + "--required", + "--approval-source", + "operator", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "approval_source=project_policy" in captured.err + + def test_set_refinement_project_policy_mode_fails_when_policy_not_configured( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/atelier/test_planner_store_migration_contract.py b/tests/atelier/test_planner_store_migration_contract.py index f6f157c6..8c9cdeae 100644 --- a/tests/atelier/test_planner_store_migration_contract.py +++ b/tests/atelier/test_planner_store_migration_contract.py @@ -7,6 +7,7 @@ import pytest +import atelier.lib.beads as beads_lib from atelier import messages, planner_overview, planner_startup_check from atelier.lib.beads import ( BeadsCommandRequest, @@ -228,6 +229,8 @@ def test_planner_authoring_and_message_flows_have_dual_backend_parity( "beads_root": tmp_path / ".beads", }, )() + context.project_dir.mkdir(parents=True, exist_ok=True) + context.beads_root.mkdir(parents=True, exist_ok=True) create_epic = _load_skill_script("plan-create-epic", "create_epic.py") create_changeset = _load_skill_script("plan-changesets", "create_changeset.py") @@ -269,6 +272,7 @@ def test_planner_authoring_and_message_flows_have_dual_backend_parity( create_epic.main() monkeypatch.setattr(create_changeset, "_build_store", lambda **_kwargs: store) + monkeypatch.setattr(beads_lib, "SubprocessBeadsClient", lambda **_kwargs: client) monkeypatch.setattr( create_changeset.auto_export, "resolve_auto_export_context", From 1cf9d6cf4f30565f70c7e7886f722de02d0fce79 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 05:10:06 -0700 Subject: [PATCH 20/25] fix(refinement): harden authoritative gating and epic validation - Enforce strict newest-authoritative selection semantics in planning refinement so malformed newest authoritative blocks fail closed instead of falling back to older valid records. - Add shared refinement invariant helpers for policy resolution, approval validation, and round budget precedence. - Move required-refinement validation ahead of epic creation mutation and apply resolved policy/override budgets in plan-create-epic. - Apply shared invariant handling in plan-set-refinement and resolve policy defaults for requested mode budgets. - Add regression tests for malformed newest-authoritative fail-closed behavior, create-epic pre-mutation validation, and policy default budget propagation. --- src/atelier/planning_refinement.py | 14 +- src/atelier/refinement_invariants.py | 158 ++++++++++++++++++ .../plan-create-epic/scripts/create_epic.py | 88 ++++++++-- .../scripts/set_refinement.py | 94 +++-------- .../skills/test_plan_create_epic_script.py | 136 +++++++++++++++ .../skills/test_plan_set_refinement_script.py | 49 ++++++ tests/atelier/test_planning_refinement.py | 31 ++++ 7 files changed, 482 insertions(+), 88 deletions(-) create mode 100644 src/atelier/refinement_invariants.py diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index e8dbd8c4..3e00c19d 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -270,8 +270,10 @@ def select_winning_refinement( ) -> PlanningRefinementRecord | None: """Select the winning refinement record from parsed blocks. - Selection is newest authoritative valid block when authoritative blocks - exist; otherwise newest valid block across all parsed blocks. + Selection is strict for authoritative scope: newest authoritative block + wins only when valid; otherwise no winner is selected. Without + authoritative blocks, the newest valid block across all parsed blocks + wins. Args: blocks: Parsed refinement blocks. @@ -280,8 +282,12 @@ def select_winning_refinement( Winning valid refinement record, or ``None`` when no valid winner exists. """ - scope = _select_scope(tuple(blocks)) - for block in reversed(scope): + parsed = tuple(blocks) + authoritative = tuple(block for block in parsed if block.authoritative_hint) + if authoritative: + newest_authoritative = authoritative[-1] + return newest_authoritative.record + for block in reversed(parsed): if block.record is not None: return block.record return None diff --git a/src/atelier/refinement_invariants.py b/src/atelier/refinement_invariants.py new file mode 100644 index 00000000..a6421315 --- /dev/null +++ b/src/atelier/refinement_invariants.py @@ -0,0 +1,158 @@ +"""Shared refinement invariants for policy, approval, and budget handling.""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from typing import Callable + +from atelier import config as atelier_config +from atelier import git, paths +from atelier.commands.resolve import resolve_project_for_enlistment +from atelier.models import PlanningRefinementConfig +from atelier.planning_refinement import ( + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, +) + + +def resolve_refinement_policy_for_repo(*, repo_root: Path) -> PlanningRefinementConfig | None: + """Resolve refinement policy defaults for a repository root. + + Args: + repo_root: Repository root used to locate project configuration. + + Returns: + Parsed refinement policy when project config is available, otherwise + ``None``. + """ + try: + _repo_root, enlistment_path, _origin_raw, origin = git.resolve_repo_enlistment(repo_root) + project_root, _project_config, _resolved_enlistment = resolve_project_for_enlistment( + enlistment_path, origin + ) + config_path = paths.project_config_path(project_root) + project_config = atelier_config.load_project_config(config_path) + except (Exception, SystemExit): + return None + if project_config is None: + return None + return atelier_config.resolve_refinement_policy(project_config) + + +def resolve_refinement_round_limits( + *, + cli_plan_edit_rounds_max: int | None, + cli_post_impl_review_rounds_max: int | None, + item_plan_edit_rounds_max: int | None, + item_post_impl_review_rounds_max: int | None, + policy: PlanningRefinementConfig | None, +) -> tuple[int, int]: + """Resolve refinement round limits with global precedence. + + Precedence is ``CLI override > item metadata > project policy > defaults``. + + Args: + cli_plan_edit_rounds_max: CLI override for plan-edit rounds. + cli_post_impl_review_rounds_max: CLI override for post-impl rounds. + item_plan_edit_rounds_max: Existing item metadata for plan-edit rounds. + item_post_impl_review_rounds_max: Existing item metadata for + post-impl rounds. + policy: Project policy defaults when available. + + Returns: + Tuple of ``(plan_edit_rounds_max, post_impl_review_rounds_max)``. + """ + policy_plan_rounds = ( + int(policy.plan_edit_rounds_max) if policy is not None else DEFAULT_PLAN_EDIT_ROUNDS_MAX + ) + policy_post_rounds = ( + int(policy.post_impl_review_rounds_max) + if policy is not None + else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX + ) + plan_edit_rounds_max = next( + candidate + for candidate in ( + cli_plan_edit_rounds_max, + item_plan_edit_rounds_max, + policy_plan_rounds, + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + ) + if candidate is not None + ) + post_impl_review_rounds_max = next( + candidate + for candidate in ( + cli_post_impl_review_rounds_max, + item_post_impl_review_rounds_max, + policy_post_rounds, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ) + if candidate is not None + ) + return int(plan_edit_rounds_max), int(post_impl_review_rounds_max) + + +def validate_required_refinement_approval( + *, + mode: str, + approval_status: str | None, + approval_source: str | None, + approved_by: str | None, + approved_at: str | None, + policy: PlanningRefinementConfig | None, + utc_now_iso8601: Callable[[], str] | None = None, +) -> tuple[str, str, str, str]: + """Validate required-refinement approval evidence. + + Args: + mode: Refinement mode (`requested`, `inherited`, or `project_policy`). + approval_status: Approval status token. + approval_source: Approval source token. + approved_by: Approver principal id. + approved_at: Approval timestamp. + policy: Project refinement policy defaults. + utc_now_iso8601: Optional timestamp generator for policy auto-approval. + + Returns: + Canonical approval tuple: + ``(approval_status, approval_source, approved_by, approved_at)``. + + Raises: + ValueError: When required approval evidence is invalid or incomplete. + """ + if approval_status not in {None, "approved"}: + raise ValueError("required refinement must set approval_status=approved") + if mode == "project_policy": + if policy is None or not bool(policy.required_by_default): + raise ValueError( + "project_policy mode requires configured policy (required_by_default=true)" + ) + if approval_source not in {None, "project_policy"}: + raise ValueError("project_policy mode requires approval_source=project_policy") + now_fn = utc_now_iso8601 or _utc_now_iso8601 + return ( + "approved", + "project_policy", + approved_by or "project_policy", + approved_at or now_fn(), + ) + if not approval_source or not approved_by or not approved_at: + raise ValueError( + "required refinement must include approval evidence: " + "approval_source, approved_by, and approved_at" + ) + return "approved", approval_source, approved_by, approved_at + + +def _utc_now_iso8601() -> str: + now = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0) + return now.isoformat().replace("+00:00", "Z") + + +__all__ = [ + "resolve_refinement_policy_for_repo", + "resolve_refinement_round_limits", + "validate_required_refinement_approval", +] diff --git a/src/atelier/skills/plan-create-epic/scripts/create_epic.py b/src/atelier/skills/plan-create-epic/scripts/create_epic.py index bc5142ce..1ecb822c 100644 --- a/src/atelier/skills/plan-create-epic/scripts/create_epic.py +++ b/src/atelier/skills/plan-create-epic/scripts/create_epic.py @@ -8,6 +8,7 @@ import sys from dataclasses import replace from pathlib import Path +from typing import cast _SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" if str(_SHARED_SCRIPTS_ROOT) not in sys.path: @@ -83,6 +84,19 @@ def _build_store(*, beads_root: Path, repo_root: Path): return build_atelier_store(beads=client) +def _clean(value: object) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def _resolve_refinement_policy(*, repo_root: Path): + from atelier.refinement_invariants import resolve_refinement_policy_for_repo + + return resolve_refinement_policy_for_repo(repo_root=repo_root) + + def _render_refinement_note(record: object) -> str: from typing import cast @@ -129,12 +143,10 @@ def _required_refinement_note( approval_source: str, approved_by: str, approved_at: str, + plan_edit_rounds_max: int, + post_impl_review_rounds_max: int, ) -> str: - from typing import cast - from atelier.planning_refinement import ( - DEFAULT_PLAN_EDIT_ROUNDS_MAX, - DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, ApprovalSource, PlanningRefinementRecord, ) @@ -148,8 +160,8 @@ def _required_refinement_note( approval_source=cast(ApprovalSource, approval_source), approved_by=approved_by, approved_at=approved_at, - plan_edit_rounds_max=DEFAULT_PLAN_EDIT_ROUNDS_MAX, - post_impl_review_rounds_max=DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + plan_edit_rounds_max=plan_edit_rounds_max, + post_impl_review_rounds_max=post_impl_review_rounds_max, ) return _render_refinement_note(record) @@ -185,6 +197,18 @@ def main() -> None: default="", help="Approval timestamp for required refinement", ) + parser.add_argument( + "--refinement-plan-edit-rounds-max", + type=int, + default=None, + help="Optional plan-edit round budget override for required refinement", + ) + parser.add_argument( + "--refinement-post-impl-review-rounds-max", + type=int, + default=None, + help="Optional post-implementation round budget override for required refinement", + ) parser.add_argument( "--no-export", action="store_true", @@ -218,9 +242,41 @@ def main() -> None: description = _description(args.scope, args.changeset_strategy) store = _build_store(beads_root=context.beads_root, repo_root=context.project_dir) + policy = _resolve_refinement_policy(repo_root=context.project_dir) from atelier.store import CreateEpicRequest, LifecycleStatus try: + required_refinement: tuple[str, str, str, int, int] | None = None + if args.required_refinement: + from atelier.refinement_invariants import ( + resolve_refinement_round_limits, + validate_required_refinement_approval, + ) + + _status, approval_source, approved_by, approved_at = ( + validate_required_refinement_approval( + mode="requested", + approval_status=None, + approval_source=_clean(args.refinement_approval_source), + approved_by=_clean(args.refinement_approved_by), + approved_at=_clean(args.refinement_approved_at), + policy=policy, + ) + ) + plan_edit_rounds_max, post_impl_review_rounds_max = resolve_refinement_round_limits( + cli_plan_edit_rounds_max=args.refinement_plan_edit_rounds_max, + cli_post_impl_review_rounds_max=args.refinement_post_impl_review_rounds_max, + item_plan_edit_rounds_max=None, + item_post_impl_review_rounds_max=None, + policy=policy, + ) + required_refinement = ( + approval_source, + approved_by, + approved_at, + plan_edit_rounds_max, + post_impl_review_rounds_max, + ) epic = asyncio.run( store.create_epic( CreateEpicRequest( @@ -233,21 +289,21 @@ def main() -> None: ) ) ) - if args.required_refinement: - approval_source = str(args.refinement_approval_source).strip() - approved_by = str(args.refinement_approved_by).strip() - approved_at = str(args.refinement_approved_at).strip() - if not approval_source or not approved_by or not approved_at: - raise RuntimeError( - "required refinement must include approval evidence: " - "refinement_approval_source, refinement_approved_by, and " - "refinement_approved_at" - ) + if required_refinement is not None: + ( + approval_source, + approved_by, + approved_at, + plan_edit_rounds_max, + post_impl_review_rounds_max, + ) = required_refinement note = _required_refinement_note( issue_id=epic.id, approval_source=approval_source, approved_by=approved_by, approved_at=approved_at, + plan_edit_rounds_max=plan_edit_rounds_max, + post_impl_review_rounds_max=post_impl_review_rounds_max, ) from atelier.store import AppendNotesRequest diff --git a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py index 67b9aae6..2db985eb 100644 --- a/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py +++ b/src/atelier/skills/plan-set-refinement/scripts/set_refinement.py @@ -5,7 +5,6 @@ import argparse import asyncio -import datetime as dt import sys from pathlib import Path from typing import cast @@ -28,14 +27,18 @@ resolve_runtime_repo_dir_hint, resolve_skill_beads_context, ) +from atelier.models import PlanningRefinementConfig # noqa: E402 from atelier.planning_refinement import ( # noqa: E402 - DEFAULT_PLAN_EDIT_ROUNDS_MAX, - DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, ApprovalSource, ApprovalStatus, PlanningRefinementRecord, RefinementVerdict, ) +from atelier.refinement_invariants import ( # noqa: E402 + resolve_refinement_policy_for_repo, + resolve_refinement_round_limits, + validate_required_refinement_approval, +) from atelier.store import AppendNotesRequest # noqa: E402 _ALLOWED_LIFECYCLES = {"deferred", "open", "in_progress", "blocked"} @@ -125,7 +128,7 @@ def _render_note(record: PlanningRefinementRecord) -> str: def _validate_approval_fields( args: argparse.Namespace, *, - policy: object | None, + policy: PlanningRefinementConfig | None, ) -> tuple[ApprovalStatus, ApprovalSource | None, str | None, str | None]: approval_source = _clean(args.approval_source) approved_by = _clean(args.approved_by) @@ -133,27 +136,15 @@ def _validate_approval_fields( approval_status = _clean(args.approval_status) if args.required: - if approval_status not in {None, "approved"}: - raise ValueError("required refinement must set approval_status=approved") - if args.mode == "project_policy": - if policy is None or not bool(getattr(policy, "required_by_default", False)): - raise ValueError( - "project_policy mode requires configured policy (required_by_default=true)" - ) - if approval_source not in {None, "project_policy"}: - raise ValueError("project_policy mode requires approval_source=project_policy") - return ( - "approved", - "project_policy", - approved_by or "project_policy", - approved_at or _utc_now_iso8601(), - ) - if not approval_source or not approved_by or not approved_at: - raise ValueError( - "required refinement must include approval evidence: " - "approval_source, approved_by, and approved_at" - ) - return "approved", cast(ApprovalSource, approval_source), approved_by, approved_at + status, source, by, at = validate_required_refinement_approval( + mode=args.mode, + approval_status=approval_status, + approval_source=approval_source, + approved_by=approved_by, + approved_at=approved_at, + policy=policy, + ) + return cast(ApprovalStatus, status), cast(ApprovalSource, source), by, at if approval_status is None: approval_status = "missing" @@ -172,51 +163,22 @@ def _validate_approval_fields( ) -def _utc_now_iso8601() -> str: - now = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0) - return now.isoformat().replace("+00:00", "Z") - - -def _resolve_refinement_policy(*, repo_root: Path) -> object | None: - from atelier import config as atelier_config - from atelier import git, paths - from atelier.commands.resolve import resolve_project_for_enlistment - - _repo_root, enlistment_path, _origin_raw, origin = git.resolve_repo_enlistment(repo_root) - project_root, _project_config, _resolved_enlistment = resolve_project_for_enlistment( - enlistment_path, origin - ) - config_path = paths.project_config_path(project_root) - project_config = atelier_config.load_project_config(config_path) - if project_config is None: - return None - return atelier_config.resolve_refinement_policy(project_config) +def _resolve_refinement_policy(*, repo_root: Path) -> PlanningRefinementConfig | None: + return resolve_refinement_policy_for_repo(repo_root=repo_root) def _resolve_round_limits( args: argparse.Namespace, *, - policy: object | None, + policy: PlanningRefinementConfig | None, ) -> tuple[int, int]: - policy_plan_rounds = ( - int(getattr(policy, "plan_edit_rounds_max")) - if policy is not None - else DEFAULT_PLAN_EDIT_ROUNDS_MAX + return resolve_refinement_round_limits( + cli_plan_edit_rounds_max=args.plan_edit_rounds_max, + cli_post_impl_review_rounds_max=args.post_impl_review_rounds_max, + item_plan_edit_rounds_max=None, + item_post_impl_review_rounds_max=None, + policy=policy, ) - policy_post_impl_rounds = ( - int(getattr(policy, "post_impl_review_rounds_max")) - if policy is not None - else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX - ) - plan_edit_rounds_max = ( - args.plan_edit_rounds_max if args.plan_edit_rounds_max is not None else policy_plan_rounds - ) - post_impl_review_rounds_max = ( - args.post_impl_review_rounds_max - if args.post_impl_review_rounds_max is not None - else policy_post_impl_rounds - ) - return int(plan_edit_rounds_max), int(post_impl_review_rounds_max) def main() -> None: @@ -284,11 +246,7 @@ def main() -> None: if runtime_warning: print(runtime_warning, file=sys.stderr) - policy = ( - _resolve_refinement_policy(repo_root=repo_root) - if args.mode == "project_policy" - else None - ) + policy = _resolve_refinement_policy(repo_root=repo_root) approval_status, approval_source, approved_by, approved_at = _validate_approval_fields( args, policy=policy, diff --git a/tests/atelier/skills/test_plan_create_epic_script.py b/tests/atelier/skills/test_plan_create_epic_script.py index 5c62c208..f6abe29f 100644 --- a/tests/atelier/skills/test_plan_create_epic_script.py +++ b/tests/atelier/skills/test_plan_create_epic_script.py @@ -258,3 +258,139 @@ async def append_notes(self, request): assert note.startswith("planning_refinement.v1") assert "required: true" in note assert "approval_status: approved" in note + + +def test_create_epic_required_refinement_validates_before_epic_create( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + create_calls: list[object] = [] + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_epic(self, request): + create_calls.append(request) + return SimpleNamespace(id="at-epic-1") + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_epic.py", + "--title", + "Lifecycle migration", + "--scope", + "Move readiness semantics to deferred/open statuses.", + "--acceptance", + "Planner transitions use status-only lifecycle.", + "--required-refinement", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "required refinement must include approval evidence" in captured.err + assert create_calls == [] + + +def test_create_epic_required_refinement_uses_policy_budget_defaults( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + captured_notes: list[tuple[str, ...]] = [] + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + monkeypatch.setattr( + module, + "_resolve_refinement_policy", + lambda **_kwargs: SimpleNamespace( + plan_edit_rounds_max=13, + post_impl_review_rounds_max=21, + required_by_default=True, + ), + ) + + class FakeStore: + async def create_epic(self, request): + del request + return SimpleNamespace(id="at-epic-1") + + async def append_notes(self, request): + captured_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_epic.py", + "--title", + "Lifecycle migration", + "--scope", + "Move readiness semantics to deferred/open statuses.", + "--acceptance", + "Planner transitions use status-only lifecycle.", + "--required-refinement", + "--refinement-approval-source", + "operator", + "--refinement-approved-by", + "planner-user", + "--refinement-approved-at", + "2026-03-29T12:00:00Z", + ], + ) + + module.main() + + assert captured_notes + note = captured_notes[0][0] + assert "plan_edit_rounds_max: 13" in note + assert "post_impl_review_rounds_max: 21" in note diff --git a/tests/atelier/skills/test_plan_set_refinement_script.py b/tests/atelier/skills/test_plan_set_refinement_script.py index 8e966885..9c28b019 100644 --- a/tests/atelier/skills/test_plan_set_refinement_script.py +++ b/tests/atelier/skills/test_plan_set_refinement_script.py @@ -158,6 +158,55 @@ async def append_notes(self, request): assert "post_impl_review_rounds_max: 9" in note +def test_set_refinement_requested_mode_uses_policy_budget_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script_module() + captured: list[str] = [] + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id, lifecycle="open") + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + captured.extend(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module, "_resolve_context", lambda **_kwargs: (Path("/tmp/.beads"), Path("/tmp"), None) + ) + monkeypatch.setattr( + module, + "_resolve_refinement_policy", + lambda **_kwargs: SimpleNamespace( + required_by_default=True, + plan_edit_rounds_max=11, + post_impl_review_rounds_max=17, + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "set_refinement.py", + "--issue-id", + "at-123", + ], + ) + + module.main() + + assert captured + note = captured[0] + assert "plan_edit_rounds_max: 11" in note + assert "post_impl_review_rounds_max: 17" in note + + def test_set_refinement_project_policy_mode_auto_records_approval_when_configured( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index eff9b526..1375ee44 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -121,6 +121,37 @@ def test_refinement_requiredness_follows_selected_winning_record() -> None: assert gate.reason is None +def test_refinement_malformed_newest_authoritative_fails_closed_when_required() -> None: + notes = "\n\n".join( + ( + _block( + authoritative="true", + required="false", + approval_status="missing", + latest_verdict="REVISED", + ), + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="NOT_READY", + ), + ) + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is None + assert gate.required is True + assert gate.claimable is False + assert gate.reason == "refinement_metadata_missing_or_malformed" + + def test_refinement_rejects_non_iso_approval_timestamp() -> None: notes = _block( authoritative="true", From a19dcd1ef11e2648f37815f12a122738c30d7d47 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 07:19:03 -0700 Subject: [PATCH 21/25] fix(refinement): harden note fallback and fail-closed lineage - tighten refinement block parsing to only accept recognized keys so trailing ordinary key/value notes do not invalidate valid authoritative records - make refine-plan resolve existing notes through the real show-path fallback when store models omit notes, preserving required/approval evidence during persisted updates - fail closed in plan-changesets when parent notes indicate required refinement but the winning record is malformed - add focused red-to-green tests for parser isolation, refine-plan note fallback, and malformed-required lineage rejection --- src/atelier/planning_refinement.py | 25 +++++- .../scripts/create_changeset.py | 11 ++- .../refine-plan/scripts/run_refinement.py | 59 ++++++++++++-- .../skills/test_plan_changesets_script.py | 70 +++++++++++++++++ .../atelier/skills/test_refine_plan_script.py | 77 +++++++++++++++++++ tests/atelier/test_planning_refinement.py | 26 +++++++ 6 files changed, 259 insertions(+), 9 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index 3e00c19d..95455e90 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -20,6 +20,25 @@ _TRUE_TOKENS: Final[frozenset[str]] = frozenset({"true", "1", "yes"}) _FALSE_TOKENS: Final[frozenset[str]] = frozenset({"false", "0", "no"}) _FIELD_LINE_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z_][a-z0-9_]*\s*:") +_REFINEMENT_FIELD_KEYS: Final[frozenset[str]] = frozenset( + { + "authoritative", + "mode", + "required", + "lineage_root", + "approval_status", + "approval_source", + "approved_by", + "approved_at", + "plan_edit_rounds_max", + "post_impl_review_rounds_max", + "plan_edit_rounds_used", + "latest_verdict", + "initial_plan_path", + "latest_plan_path", + "round_log_dir", + } +) RefinementMode = Literal["requested", "inherited", "project_policy"] ApprovalStatus = Literal["approved", "missing"] @@ -375,7 +394,11 @@ def _parse_field_map(lines: list[str]) -> tuple[dict[str, str], tuple[str, ...]] def _looks_like_refinement_field(line: str) -> bool: - return bool(_FIELD_LINE_RE.match(line.strip())) + stripped = line.strip() + if not _FIELD_LINE_RE.match(stripped): + return False + key, _, _value = stripped.partition(":") + return key.strip() in _REFINEMENT_FIELD_KEYS def _parse_bool_token(value: object) -> bool | None: diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index 289b7220..aa4c971a 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -162,8 +162,15 @@ def _inherited_refinement_note(*, parent_notes: str | None, epic_id: str) -> str select_winning_refinement, ) - selected = select_winning_refinement(parse_refinement_blocks(parent_notes)) - if selected is None or not selected.required: + parsed_blocks = parse_refinement_blocks(parent_notes) + selected = select_winning_refinement(parsed_blocks) + if selected is None: + authoritative_scope = tuple(block for block in parsed_blocks if block.authoritative_hint) + scope = authoritative_scope or parsed_blocks + if any(block.required_hint for block in scope): + raise RuntimeError("required parent refinement metadata is malformed") + return None + if not selected.required: return None inherited = PlanningRefinementRecord( authoritative=True, diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index acb49372..0413c642 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -367,6 +367,14 @@ def _normalize_notes_text(value: object) -> str | None: return None +def _notes_from_issue_model(issue: object) -> tuple[bool, str | None]: + sentinel = object() + raw_notes = getattr(issue, "notes", sentinel) + if raw_notes is sentinel: + return False, None + return True, _normalize_notes_text(raw_notes) + + async def _resolve_work_item(store, issue_id: str): try: return await store.get_epic(issue_id) @@ -384,6 +392,24 @@ def _required_hint_from_scope(blocks: tuple[object, ...]) -> bool: return any(bool(getattr(block, "required_hint", False)) for block in scope) +def _load_existing_notes(*, store, issue_id: str, beads_root: Path, repo_root: Path) -> str | None: + issue = asyncio.run(_resolve_work_item(store, issue_id)) + notes_present, notes_text = _notes_from_issue_model(issue) + if notes_present: + return notes_text + + from atelier.lib.beads import ShowIssueRequest, SubprocessBeadsClient + + client = SubprocessBeadsClient( + cwd=repo_root, + beads_root=beads_root, + env={"BEADS_DIR": str(beads_root)}, + ) + shown_issue = asyncio.run(client.show(ShowIssueRequest(issue_id=issue_id))) + _present, shown_notes = _notes_from_issue_model(shown_issue) + return shown_notes + + def _render_refinement_note(record) -> str: payload = record.model_dump(exclude_none=True) ordered_keys = ( @@ -420,6 +446,8 @@ def _persist_refinement_evidence( *, store, issue_id: str, + beads_root: Path, + repo_root: Path, result: RefinementRunResult, initial_plan_path: Path, output_dir: Path, @@ -433,8 +461,12 @@ def _persist_refinement_evidence( ) from atelier.store import AppendNotesRequest - issue = asyncio.run(_resolve_work_item(store, issue_id)) - existing_notes = _normalize_notes_text(getattr(issue, "notes", None)) + existing_notes = _load_existing_notes( + store=store, + issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) blocks = parse_refinement_blocks(existing_notes) selected = select_winning_refinement(blocks) required_hint = _required_hint_from_scope(blocks) @@ -496,11 +528,17 @@ def executor(round_number: int, plan_text: str) -> RoundResult: return executor -def _selected_refinement_round_limit(*, store, issue_id: str) -> int | None: +def _selected_refinement_round_limit( + *, store, issue_id: str, beads_root: Path, repo_root: Path +) -> int | None: from atelier.planning_refinement import parse_refinement_blocks, select_winning_refinement - issue = asyncio.run(_resolve_work_item(store, issue_id)) - notes = _normalize_notes_text(getattr(issue, "notes", None)) + notes = _load_existing_notes( + store=store, + issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) selected = select_winning_refinement(parse_refinement_blocks(notes)) if selected is None: return None @@ -534,11 +572,17 @@ def _resolve_max_rounds( cli_max_rounds: int | None, store, issue_id: str, + beads_root: Path, repo_root: Path, ) -> int: if cli_max_rounds is not None: return int(cli_max_rounds) - selected_limit = _selected_refinement_round_limit(store=store, issue_id=issue_id) + selected_limit = _selected_refinement_round_limit( + store=store, + issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) if selected_limit is not None: return selected_limit policy_limit = _resolve_policy_round_limit(repo_root=repo_root) @@ -582,6 +626,7 @@ def main() -> int: cli_max_rounds=args.max_rounds, store=store, issue_id=issue_id, + beads_root=beads_root, repo_root=repo_root, ) @@ -599,6 +644,8 @@ def main() -> int: _persist_refinement_evidence( store=store, issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, result=result, initial_plan_path=initial_plan_path, output_dir=output_dir, diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index 0277e9f1..993bedd7 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -533,6 +533,76 @@ async def show(self, _request): assert created_requests == [] +def test_create_changeset_fails_closed_when_parent_required_refinement_is_malformed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + created_requests: list[object] = [] + parent_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: NOT_READY\n" + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def create_changeset(self, request): + created_requests.append(request) + return SimpleNamespace(id="at-epic.3") + + async def get_epic(self, epic_id): + return SimpleNamespace(id=epic_id, notes=parent_notes) + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "create_changeset.py", + "--epic-id", + "at-epic", + "--title", + "Fail closed malformed required refinement", + "--acceptance", + "Child creation must stop when required parent refinement is malformed.", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "required parent refinement metadata is malformed" in captured.err + assert created_requests == [] + + @pytest.mark.parametrize( ("parent_notes", "expect_inherited"), [ diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index 1f9e05f7..e82869c8 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -314,6 +314,83 @@ async def append_notes(self, request): assert f"round_log_dir: {(output_dir / 'rounds').resolve()}" in note +def test_refine_plan_main_uses_show_fallback_when_store_model_omits_notes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import atelier.lib.beads as beads_lib + + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + appended_notes: list[tuple[str, ...]] = [] + initial_plan_path.write_text("initial\n", encoding="utf-8") + existing_refinement = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 7\n" + "post_impl_review_rounds_max: 9\n" + "latest_verdict: REVISED\n" + ) + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace(id=issue_id) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + class FakeClient: + def __init__(self, **_kwargs): + pass + + async def show(self, _request): + return SimpleNamespace(notes=existing_refinement) + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr(beads_lib, "SubprocessBeadsClient", FakeClient) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-epic", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + "--simulate-verdicts", + "READY", + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert appended_notes + note = appended_notes[0][0] + assert "required: true" in note + assert "approval_status: approved" in note + assert "approval_source: operator" in note + assert "approved_by: planner-user" in note + assert "approved_at: 2026-03-29T12:00:00Z" in note + assert "lineage_root: at-epic" in note + + def test_refine_plan_loop_artifacts_match_trycycle_snapshot_anchors() -> None: anchors = _load_anchor_fixture()["mechanics_anchors"] loop_snapshot = (_TRYCYCLE_FIXTURE_ROOT / "trycycle-planning-loop.snapshot.md").read_text( diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index 1375ee44..9302cf2f 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -199,6 +199,32 @@ def test_refinement_parser_ignores_trailing_non_refinement_note_text() -> None: assert gate.reason is None +def test_refinement_parser_ignores_trailing_key_value_note_text() -> None: + notes = ( + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="READY", + ) + + "\n\n" + + "owner: platform-team" + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is not None + assert selected.latest_verdict == "READY" + assert gate.required is True + assert gate.claimable is True + assert gate.reason is None + + def test_refinement_parser_handles_large_note_payload_performance() -> None: notes = "\n\n".join( _block( From e82a5a9aeaad513e1c8583911a3a215c94ae0c82 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 07:35:55 -0700 Subject: [PATCH 22/25] fix(refinement): fail closed split lineage and persist effective budgets - mirror create-changeset fail-closed lineage behavior in split-tasks when required parent refinement metadata is malformed or unusable - resolve effective refinement round budgets once per run and persist those effective values in refinement evidence after precedence resolution - keep compatibility helpers for existing max-round tests while routing runtime persistence through effective budget resolution - add focused regression tests for malformed required split lineage rejection and persisted effective budget correctness under CLI override --- .../plan-split-tasks/scripts/split_tasks.py | 11 +- .../refine-plan/scripts/run_refinement.py | 105 +++++++++++++----- .../skills/test_plan_split_tasks_script.py | 68 ++++++++++++ .../atelier/skills/test_refine_plan_script.py | 67 +++++++++++ 4 files changed, 223 insertions(+), 28 deletions(-) diff --git a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py index 982b9ac5..ea413c77 100644 --- a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py +++ b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py @@ -159,8 +159,15 @@ def _resolve_parent( def _inherited_refinement_note(*, parent_notes: str | None, lineage_root: str) -> str | None: if not parent_notes: return None - selected = select_winning_refinement(parse_refinement_blocks(parent_notes)) - if selected is None or not selected.required: + parsed_blocks = parse_refinement_blocks(parent_notes) + selected = select_winning_refinement(parsed_blocks) + if selected is None: + authoritative_scope = tuple(block for block in parsed_blocks if block.authoritative_hint) + scope = authoritative_scope or parsed_blocks + if any(block.required_hint for block in scope): + raise RuntimeError("required parent refinement metadata is malformed") + return None + if not selected.required: return None inherited = PlanningRefinementRecord( authoritative=True, diff --git a/src/atelier/skills/refine-plan/scripts/run_refinement.py b/src/atelier/skills/refine-plan/scripts/run_refinement.py index 0413c642..23a191e2 100644 --- a/src/atelier/skills/refine-plan/scripts/run_refinement.py +++ b/src/atelier/skills/refine-plan/scripts/run_refinement.py @@ -448,13 +448,13 @@ def _persist_refinement_evidence( issue_id: str, beads_root: Path, repo_root: Path, + effective_plan_edit_rounds_max: int, + effective_post_impl_review_rounds_max: int, result: RefinementRunResult, initial_plan_path: Path, output_dir: Path, ) -> None: from atelier.planning_refinement import ( - DEFAULT_PLAN_EDIT_ROUNDS_MAX, - DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, PlanningRefinementRecord, parse_refinement_blocks, select_winning_refinement, @@ -479,14 +479,8 @@ def _persist_refinement_evidence( approval_source=selected.approval_source if selected is not None else None, approved_by=selected.approved_by if selected is not None else None, approved_at=selected.approved_at if selected is not None else None, - plan_edit_rounds_max=( - selected.plan_edit_rounds_max if selected is not None else DEFAULT_PLAN_EDIT_ROUNDS_MAX - ), - post_impl_review_rounds_max=( - selected.post_impl_review_rounds_max - if selected is not None - else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX - ), + plan_edit_rounds_max=effective_plan_edit_rounds_max, + post_impl_review_rounds_max=effective_post_impl_review_rounds_max, plan_edit_rounds_used=result.rounds_used, latest_verdict=result.latest_verdict, initial_plan_path=str(initial_plan_path), @@ -531,6 +525,18 @@ def executor(round_number: int, plan_text: str) -> RoundResult: def _selected_refinement_round_limit( *, store, issue_id: str, beads_root: Path, repo_root: Path ) -> int | None: + selected_plan_limit, _selected_post_limit = _selected_refinement_round_limits( + store=store, + issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) + return selected_plan_limit + + +def _selected_refinement_round_limits( + *, store, issue_id: str, beads_root: Path, repo_root: Path +) -> tuple[int | None, int | None]: from atelier.planning_refinement import parse_refinement_blocks, select_winning_refinement notes = _load_existing_notes( @@ -541,11 +547,21 @@ def _selected_refinement_round_limit( ) selected = select_winning_refinement(parse_refinement_blocks(notes)) if selected is None: - return None - return int(selected.plan_edit_rounds_max) + return None, None + return int(selected.plan_edit_rounds_max), int(selected.post_impl_review_rounds_max) def _resolve_policy_round_limit(*, repo_root: Path) -> int | None: + plan_limit, _post_limit = _resolve_policy_round_limits(repo_root=repo_root) + return plan_limit + + +def _resolve_policy_post_impl_round_limit(*, repo_root: Path) -> int | None: + _plan_limit, post_limit = _resolve_policy_round_limits(repo_root=repo_root) + return post_limit + + +def _resolve_policy_round_limits(*, repo_root: Path) -> tuple[int | None, int | None]: from atelier import config as atelier_config from atelier import git, paths from atelier.commands.resolve import resolve_project_for_enlistment @@ -558,13 +574,53 @@ def _resolve_policy_round_limit(*, repo_root: Path) -> int | None: config_path = paths.project_config_path(project_root) project_config = atelier_config.load_project_config(config_path) except (Exception, SystemExit): - return None + return None, None if project_config is None: - return None + return None, None policy = atelier_config.resolve_refinement_policy(project_config) if policy is None: - return None - return int(policy.plan_edit_rounds_max) + return None, None + return int(policy.plan_edit_rounds_max), int(policy.post_impl_review_rounds_max) + + +def _resolve_effective_round_limits( + *, + cli_max_rounds: int | None, + store, + issue_id: str, + beads_root: Path, + repo_root: Path, +) -> tuple[int, int]: + from atelier.planning_refinement import ( + DEFAULT_PLAN_EDIT_ROUNDS_MAX, + DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX, + ) + + selected_plan_limit, selected_post_limit = _selected_refinement_round_limits( + store=store, + issue_id=issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) + policy_plan_limit = _resolve_policy_round_limit(repo_root=repo_root) + policy_post_limit = _resolve_policy_post_impl_round_limit(repo_root=repo_root) + plan_limit = ( + int(cli_max_rounds) + if cli_max_rounds is not None + else selected_plan_limit + if selected_plan_limit is not None + else policy_plan_limit + if policy_plan_limit is not None + else DEFAULT_PLAN_EDIT_ROUNDS_MAX + ) + post_limit = ( + selected_post_limit + if selected_post_limit is not None + else policy_post_limit + if policy_post_limit is not None + else DEFAULT_POST_IMPL_REVIEW_ROUNDS_MAX + ) + return int(plan_limit), int(post_limit) def _resolve_max_rounds( @@ -575,20 +631,14 @@ def _resolve_max_rounds( beads_root: Path, repo_root: Path, ) -> int: - if cli_max_rounds is not None: - return int(cli_max_rounds) - selected_limit = _selected_refinement_round_limit( + effective_plan_limit, _effective_post_limit = _resolve_effective_round_limits( + cli_max_rounds=cli_max_rounds, store=store, issue_id=issue_id, beads_root=beads_root, repo_root=repo_root, ) - if selected_limit is not None: - return selected_limit - policy_limit = _resolve_policy_round_limit(repo_root=repo_root) - if policy_limit is not None: - return policy_limit - return REFINEMENT_MAX_ROUNDS_DEFAULT + return effective_plan_limit def _parse_args() -> argparse.Namespace: @@ -622,13 +672,14 @@ def main() -> int: if runtime_warning: print(runtime_warning, file=sys.stderr) store = _build_store(beads_root=beads_root, repo_root=repo_root) - max_rounds = _resolve_max_rounds( + effective_plan_rounds_max, effective_post_impl_rounds_max = _resolve_effective_round_limits( cli_max_rounds=args.max_rounds, store=store, issue_id=issue_id, beads_root=beads_root, repo_root=repo_root, ) + max_rounds = effective_plan_rounds_max if args.simulate_verdicts: verdicts = [item.strip() for item in args.simulate_verdicts.split(",") if item.strip()] @@ -646,6 +697,8 @@ def main() -> int: issue_id=issue_id, beads_root=beads_root, repo_root=repo_root, + effective_plan_edit_rounds_max=effective_plan_rounds_max, + effective_post_impl_review_rounds_max=effective_post_impl_rounds_max, result=result, initial_plan_path=initial_plan_path, output_dir=output_dir, diff --git a/tests/atelier/skills/test_plan_split_tasks_script.py b/tests/atelier/skills/test_plan_split_tasks_script.py index ef91ad9b..39d3fbef 100644 --- a/tests/atelier/skills/test_plan_split_tasks_script.py +++ b/tests/atelier/skills/test_plan_split_tasks_script.py @@ -169,6 +169,74 @@ async def append_notes(self, request): # pragma: no cover - defensive assert created_requests[0].notes == () +def test_split_tasks_fails_closed_when_parent_required_refinement_is_malformed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + created_requests: list[object] = [] + parent_notes = ( + "planning_refinement.v1\n" + "authoritative: true\n" + "required: true\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: NOT_READY\n" + ) + context = SimpleNamespace( + project_dir=tmp_path / "project", + beads_root=tmp_path / ".beads", + ) + + monkeypatch.setattr( + module.auto_export, + "resolve_auto_export_context", + lambda **_kwargs: context, + ) + + class FakeStore: + async def get_changeset(self, issue_id): + return SimpleNamespace(id=issue_id, epic_id="at-epic", notes=parent_notes) + + async def create_changeset(self, request): + created_requests.append(request) + return SimpleNamespace(id="at-epic.2") + + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + module.auto_export, + "auto_export_issue", + lambda issue_id, *, context: module.auto_export.AutoExportResult( + status="skipped", + issue_id=issue_id, + provider=None, + message="auto-export disabled for test", + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "split_tasks.py", + "--parent-id", + "at-epic.1", + "--task", + "Split API contract::API surface is independently testable.", + ], + ) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert "required parent refinement metadata is malformed" in captured.err + assert created_requests == [] + + @pytest.mark.parametrize( ("parent_notes", "expect_inherited"), [ diff --git a/tests/atelier/skills/test_refine_plan_script.py b/tests/atelier/skills/test_refine_plan_script.py index e82869c8..f79a3a4c 100644 --- a/tests/atelier/skills/test_refine_plan_script.py +++ b/tests/atelier/skills/test_refine_plan_script.py @@ -391,6 +391,73 @@ async def show(self, _request): assert "lineage_root: at-epic" in note +def test_refine_plan_main_persists_effective_round_budgets_after_cli_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + initial_plan_path = tmp_path / "initial.md" + output_dir = tmp_path / "artifacts" + appended_notes: list[tuple[str, ...]] = [] + initial_plan_path.write_text("initial\n", encoding="utf-8") + + class FakeStore: + async def get_epic(self, issue_id: str): + return SimpleNamespace( + id=issue_id, + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "plan_edit_rounds_max: 7\n" + "post_impl_review_rounds_max: 9\n" + "latest_verdict: REVISED\n" + ), + ) + + async def get_changeset(self, issue_id: str): + del issue_id + raise LookupError("not a changeset") + + async def append_notes(self, request): + appended_notes.append(request.notes) + return SimpleNamespace(id=request.issue_id) + + monkeypatch.setattr(module, "_resolve_context", lambda **_kwargs: (tmp_path, tmp_path, None)) + monkeypatch.setattr(module, "_build_store", lambda **_kwargs: FakeStore()) + monkeypatch.setattr( + sys, + "argv", + [ + "run_refinement.py", + "--issue-id", + "at-epic", + "--initial-plan-path", + str(initial_plan_path), + "--output-dir", + str(output_dir), + "--max-rounds", + "11", + "--simulate-verdicts", + "READY", + ], + ) + + exit_code = module.main() + + assert exit_code == 0 + assert appended_notes + note = appended_notes[0][0] + assert "plan_edit_rounds_max: 11" in note + assert "post_impl_review_rounds_max: 9" in note + + def test_refine_plan_loop_artifacts_match_trycycle_snapshot_anchors() -> None: anchors = _load_anchor_fixture()["mechanics_anchors"] loop_snapshot = (_TRYCYCLE_FIXTURE_ROOT / "trycycle-planning-loop.snapshot.md").read_text( From d2b3096f0a26dfca1907e781d5b9d1b4d442a886 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 07:52:54 -0700 Subject: [PATCH 23/25] fix(refinement): skip closed children in promotion gate - apply child refinement gating only to executable promotion-path children by excluding closed lifecycle records - keep existing refinement blocking behavior for deferred/open/in-progress/blocked children unchanged - add regression coverage proving closed children with non-ready refinement metadata do not block epic promotion --- .../plan-promote-epic/scripts/promote_epic.py | 2 + .../skills/test_plan_promote_epic_script.py | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py index d430d6ca..f2c89a78 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -274,6 +274,8 @@ def main() -> None: reason = epic_refinement_gate.reason or "refinement_metadata_missing_or_malformed" problems.append(f"{epic_id}: {reason}") for record, issue in zip(changesets, child_issues, strict=True): + if record.lifecycle is LifecycleStatus.CLOSED: + continue refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(issue)) if refinement_gate.required and not refinement_gate.claimable: reason = refinement_gate.reason or "refinement_metadata_missing_or_malformed" diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 61c6dfa4..13c2e826 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -429,6 +429,79 @@ async def show(self, request): assert "refinement_not_ready" in capsys.readouterr().err +def test_promote_epic_ignores_non_ready_refinement_for_closed_children( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + transitions: list[object] = [] + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + + epic_issue = _issue( + "at-epic", + title="Epic", + description=( + "changeset_strategy: Keep review scope small.\n" + "related_context: at-context\n" + "promotion_note: ready for confirmation\n" + ), + notes="canonical epic note", + ) + closed_child_issue = _issue( + "at-epic.1", + title="Closed child", + status="closed", + description=("changeset_note: preserve lifecycle behavior\nrelated_context: at-context\n"), + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: inherited\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ) + + class FakeStore: + async def get_epic(self, epic_id): + assert epic_id == "at-epic" + from atelier.store import LifecycleStatus + + return SimpleNamespace(id=epic_id, lifecycle=LifecycleStatus.DEFERRED) + + async def list_changesets(self, query): + del query + from atelier.store import LifecycleStatus + + return (SimpleNamespace(id="at-epic.1", lifecycle=LifecycleStatus.CLOSED),) + + async def transition_lifecycle(self, request): + transitions.append(request) + return request + + class FakeClient: + async def show(self, request): + return {"at-epic": epic_issue, "at-epic.1": closed_child_issue}[request.issue_id] + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) + + module.main() + + assert [request.issue_id for request in transitions] == ["at-epic"] + + def test_promote_epic_refinement_requires_ready_verdict_for_epic_only_execution( monkeypatch, capsys: pytest.CaptureFixture[str], From 6139ee7eec1b0de7631bc5b30a4ae48db32c1e48 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 08:07:47 -0700 Subject: [PATCH 24/25] fix(refinement): harden epic gating and malformed block parsing - enforce epic-level refinement readiness in plan-promote-epic even when child changesets exist, while keeping closed-child skip and existing child gating behavior intact - make planning_refinement block parsing fail closed on unknown/malformed in-block lines instead of truncating and validating partial defaults - preserve append-only note behavior by treating blank lines as refinement block boundaries so trailing freeform/key-value notes are not absorbed - add regression coverage for epic-with-children gating, malformed unknown-field fail-closed parsing, and permutation-based winner-selection determinism --- src/atelier/planning_refinement.py | 22 +++-- .../plan-promote-epic/scripts/promote_epic.py | 9 +-- .../skills/test_plan_promote_epic_script.py | 80 +++++++++++++++++++ tests/atelier/test_planning_refinement.py | 62 ++++++++++++++ 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index 95455e90..43fb339c 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -242,28 +242,30 @@ def parse_refinement_blocks(notes: str | None) -> tuple[ParsedRefinementBlock, . start = index index += 1 field_lines: list[str] = [] + parse_errors: list[str] = [] while index < len(lines): stripped = lines[index].strip() if stripped == _REFINEMENT_MARKER: break if not stripped: + # Treat blank lines as block boundaries so adjacent freeform notes + # are not absorbed into refinement parsing. + break + if _looks_like_key_value_line(lines[index]): field_lines.append(lines[index]) + if not _looks_like_refinement_field(lines[index]): + parse_errors.append(f"unknown refinement field: {lines[index].strip()!r}") index += 1 continue - if _looks_like_refinement_field(lines[index]): - field_lines.append(lines[index]) - index += 1 - continue - # Stop this block before freeform note text to preserve append-only - # notes behavior and avoid false malformed-state outcomes. - break + parse_errors.append(f"invalid line inside refinement block: {lines[index].strip()!r}") + index += 1 raw_lines = lines[start:index] raw_text = "\n".join(raw_lines) field_map, syntax_errors = _parse_field_map(field_lines) authoritative_hint = _parse_bool_token(field_map.get("authoritative")) is True required_hint = _parse_bool_token(field_map.get("required")) is True record: PlanningRefinementRecord | None = None - errors = list(syntax_errors) + errors = [*parse_errors, *syntax_errors] if not errors: try: record = PlanningRefinementRecord.model_validate(field_map) @@ -401,6 +403,10 @@ def _looks_like_refinement_field(line: str) -> bool: return key.strip() in _REFINEMENT_FIELD_KEYS +def _looks_like_key_value_line(line: str) -> bool: + return bool(_FIELD_LINE_RE.match(line.strip())) + + def _parse_bool_token(value: object) -> bool | None: if isinstance(value, bool): return value diff --git a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py index f2c89a78..3310703c 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -268,11 +268,10 @@ def main() -> None: problems.append( "incomplete child changesets remain deferred: " + ", ".join(incomplete_children) ) - if not changesets: - epic_refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(epic_issue)) - if epic_refinement_gate.required and not epic_refinement_gate.claimable: - reason = epic_refinement_gate.reason or "refinement_metadata_missing_or_malformed" - problems.append(f"{epic_id}: {reason}") + epic_refinement_gate = evaluate_refinement_claim_gate(_issue_notes_text(epic_issue)) + if epic_refinement_gate.required and not epic_refinement_gate.claimable: + reason = epic_refinement_gate.reason or "refinement_metadata_missing_or_malformed" + problems.append(f"{epic_id}: {reason}") for record, issue in zip(changesets, child_issues, strict=True): if record.lifecycle is LifecycleStatus.CLOSED: continue diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 13c2e826..b0fd5b3d 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -429,6 +429,86 @@ async def show(self, request): assert "refinement_not_ready" in capsys.readouterr().err +def test_promote_epic_refinement_requires_ready_verdict_even_with_children( + monkeypatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + + epic_issue = _issue( + "at-epic", + title="Epic", + description=("changeset_strategy: Keep review scope small.\nrelated_context: at-context\n"), + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: requested\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: REVISED\n" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=("changeset_note: preserve lifecycle behavior\nrelated_context: at-context\n"), + notes=( + "planning_refinement.v1\n" + "authoritative: true\n" + "mode: inherited\n" + "required: true\n" + "lineage_root: at-epic\n" + "approval_status: approved\n" + "approval_source: operator\n" + "approved_by: planner-user\n" + "approved_at: 2026-03-29T12:00:00Z\n" + "latest_verdict: READY\n" + ), + ) + + class FakeStore: + async def get_epic(self, epic_id): + assert epic_id == "at-epic" + from atelier.store import LifecycleStatus + + return SimpleNamespace(id=epic_id, lifecycle=LifecycleStatus.DEFERRED) + + async def list_changesets(self, query): + del query + from atelier.store import LifecycleStatus + + return (SimpleNamespace(id="at-epic.1", lifecycle=LifecycleStatus.DEFERRED),) + + async def transition_lifecycle(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + class FakeClient: + async def show(self, request): + return {"at-epic": epic_issue, "at-epic.1": child_issue}[request.issue_id] + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "at-epic: refinement_not_ready" in capsys.readouterr().err + + def test_promote_epic_ignores_non_ready_refinement_for_closed_children( monkeypatch, tmp_path: Path, diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index 9302cf2f..c63ea38f 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import time from atelier import planning_refinement @@ -225,6 +226,67 @@ def test_refinement_parser_ignores_trailing_key_value_note_text() -> None: assert gate.reason is None +def test_refinement_parser_fails_closed_on_unknown_field_inside_block() -> None: + notes = "\n".join( + ( + "planning_refinement.v1", + "authoritative: true", + "required: true", + "approval_status: approved", + "approval_source: operator", + "approved_by: planner-user", + "approved_at: 2026-03-29T12:00:00Z", + "latest_verdict: READY", + "owner: platform-team", + ) + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is None + assert gate.required is True + assert gate.claimable is False + assert gate.reason == "refinement_metadata_missing_or_malformed" + + +def test_refinement_winner_selection_deterministic_under_prefix_permutations() -> None: + fixed_winner = _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="READY", + plan_edit_rounds_max="7", + post_impl_review_rounds_max="9", + ) + prefix_blocks = ( + _block(authoritative="false", required="false", approval_status="missing"), + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="REVISED", + ), + _block(authoritative="false", required="true", approval_status="missing"), + ) + + for prefix_permutation in itertools.permutations(prefix_blocks): + notes = "\n\n".join((*prefix_permutation, fixed_winner)) + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + assert selected is not None + assert selected.latest_verdict == "READY" + assert selected.plan_edit_rounds_max == 7 + assert selected.post_impl_review_rounds_max == 9 + + def test_refinement_parser_handles_large_note_payload_performance() -> None: notes = "\n\n".join( _block( From 46ef9c97e739389bebf374d6e6abaf57116f94c8 Mon Sep 17 00:00:00 2001 From: Scott Haug <scott.haug@gmail.com> Date: Sun, 29 Mar 2026 08:22:29 -0700 Subject: [PATCH 25/25] fix(refinement): preserve append boundaries and clear inherited readiness - treat non-key freeform lines after a refinement block as block boundaries so single-newline note appends do not invalidate otherwise valid required refinement metadata - keep fail-closed behavior for unknown key:value lines inside refinement blocks to prevent silent malformed-field truncation - stop propagating parent latest_verdict into inherited child refinement notes in both changeset creation flows so descendants cannot inherit READY state - add regression coverage for single-newline append parsing and for inherited-note payloads excluding latest_verdict --- src/atelier/planning_refinement.py | 5 ++-- .../scripts/create_changeset.py | 1 - .../plan-split-tasks/scripts/split_tasks.py | 1 - .../skills/test_plan_changesets_script.py | 1 + .../skills/test_plan_split_tasks_script.py | 1 + tests/atelier/test_planning_refinement.py | 26 +++++++++++++++++++ 6 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/atelier/planning_refinement.py b/src/atelier/planning_refinement.py index 43fb339c..76b950fc 100644 --- a/src/atelier/planning_refinement.py +++ b/src/atelier/planning_refinement.py @@ -257,8 +257,9 @@ def parse_refinement_blocks(notes: str | None) -> tuple[ParsedRefinementBlock, . parse_errors.append(f"unknown refinement field: {lines[index].strip()!r}") index += 1 continue - parse_errors.append(f"invalid line inside refinement block: {lines[index].strip()!r}") - index += 1 + # Stop this refinement block at freeform note text. Notes are + # append-only and may be joined with single newlines. + break raw_lines = lines[start:index] raw_text = "\n".join(raw_lines) field_map, syntax_errors = _parse_field_map(field_lines) diff --git a/src/atelier/skills/plan-changesets/scripts/create_changeset.py b/src/atelier/skills/plan-changesets/scripts/create_changeset.py index aa4c971a..2fbafe4d 100644 --- a/src/atelier/skills/plan-changesets/scripts/create_changeset.py +++ b/src/atelier/skills/plan-changesets/scripts/create_changeset.py @@ -183,7 +183,6 @@ def _inherited_refinement_note(*, parent_notes: str | None, epic_id: str) -> str approved_at=selected.approved_at, plan_edit_rounds_max=selected.plan_edit_rounds_max, post_impl_review_rounds_max=selected.post_impl_review_rounds_max, - latest_verdict=selected.latest_verdict, ) return _render_refinement_note(inherited) diff --git a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py index ea413c77..33941457 100644 --- a/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py +++ b/src/atelier/skills/plan-split-tasks/scripts/split_tasks.py @@ -180,7 +180,6 @@ def _inherited_refinement_note(*, parent_notes: str | None, lineage_root: str) - approved_at=selected.approved_at, plan_edit_rounds_max=selected.plan_edit_rounds_max, post_impl_review_rounds_max=selected.post_impl_review_rounds_max, - latest_verdict=selected.latest_verdict, ) return _render_refinement_note(inherited) diff --git a/tests/atelier/skills/test_plan_changesets_script.py b/tests/atelier/skills/test_plan_changesets_script.py index 993bedd7..7e5c3806 100644 --- a/tests/atelier/skills/test_plan_changesets_script.py +++ b/tests/atelier/skills/test_plan_changesets_script.py @@ -391,6 +391,7 @@ async def append_notes(self, request): # pragma: no cover - defensive assert "lineage_root: at-epic" in note assert "plan_edit_rounds_max: 7" in note assert "post_impl_review_rounds_max: 9" in note + assert "latest_verdict:" not in note def test_create_changeset_unrefined_control_keeps_notes_unchanged( diff --git a/tests/atelier/skills/test_plan_split_tasks_script.py b/tests/atelier/skills/test_plan_split_tasks_script.py index 39d3fbef..4503040f 100644 --- a/tests/atelier/skills/test_plan_split_tasks_script.py +++ b/tests/atelier/skills/test_plan_split_tasks_script.py @@ -110,6 +110,7 @@ async def append_notes(self, request): # pragma: no cover - defensive assert "lineage_root: at-epic" in note assert "plan_edit_rounds_max: 6" in note assert "post_impl_review_rounds_max: 10" in note + assert "latest_verdict:" not in note def test_split_tasks_leaves_unrefined_lineage_unmarked( diff --git a/tests/atelier/test_planning_refinement.py b/tests/atelier/test_planning_refinement.py index c63ea38f..4ac86342 100644 --- a/tests/atelier/test_planning_refinement.py +++ b/tests/atelier/test_planning_refinement.py @@ -226,6 +226,32 @@ def test_refinement_parser_ignores_trailing_key_value_note_text() -> None: assert gate.reason is None +def test_refinement_parser_ignores_single_newline_appended_freeform_note() -> None: + notes = ( + _block( + authoritative="true", + required="true", + approval_status="approved", + approval_source="operator", + approved_by="planner-user", + approved_at="2026-03-29T12:00:00Z", + latest_verdict="READY", + ) + + "\n" + + "Operator follow-up note after append." + ) + + blocks = planning_refinement.parse_refinement_blocks(notes) + selected = planning_refinement.select_winning_refinement(blocks) + gate = planning_refinement.evaluate_refinement_claim_gate(notes) + + assert selected is not None + assert selected.latest_verdict == "READY" + assert gate.required is True + assert gate.claimable is True + assert gate.reason is None + + def test_refinement_parser_fails_closed_on_unknown_field_inside_block() -> None: notes = "\n".join( (