From ff4f055c05de59d920533b3a7e998d30e8acc85d Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 17:57:57 -0700 Subject: [PATCH 01/16] docs(plans): add issue 719 trycycle-ready implementation plan - add a complete trycycle-planning execution plan for GitHub issue #719\n- define behavior contracts, invariants, architecture decisions, and cutover risks\n- break implementation into TDD task slices with exact files, tests, commands, and commit checkpoints --- ...6-03-26-trycycle-ready-planner-contract.md | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 docs/plans/2026-03-26-trycycle-ready-planner-contract.md diff --git a/docs/plans/2026-03-26-trycycle-ready-planner-contract.md b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md new file mode 100644 index 00000000..3ffea402 --- /dev/null +++ b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md @@ -0,0 +1,542 @@ +# Trycycle-Ready Planner Contract and Approval Gate 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:** Add a fail-closed planner+worker contract so trycycle-targeted +changesets cannot be claimed until a validated plan package and explicit +operator approval are both recorded and auditable. + +**Architecture:** Introduce a typed trycycle plan-contract model and shared +validator in core `src/atelier` code, then route planner guardrails, +promotion-time approval capture, and worker startup selection through that one +validator so readiness semantics are identical everywhere. Keep canonical +lifecycle statuses unchanged (`deferred|open|in_progress|blocked|closed`) and +represent staged review with namespaced metadata fields to avoid destabilizing +existing lifecycle logic. + +**Tech Stack:** Python 3.11, Pydantic models, Atelier store/beads adapters, +planner skill scripts, worker startup pipeline, pytest. + +--- + +## User-Visible Behavior Contract + +1. A changeset becomes trycycle-targeted only when it carries + `trycycle.targeted: true` metadata. +1. Trycycle-targeted changesets must include a typed plan payload under + `trycycle.contract_json`. +1. Planner validation must fail closed for trycycle-targeted changesets before + they are promoted to runnable/open work. +1. Operator approval is explicit and auditable. Promotion records: + - `trycycle.plan_stage: approved` + - `trycycle.approved_by` + - `trycycle.approved_at` + - `trycycle.approval_message_id` +1. Worker startup refuses to select trycycle-targeted changesets whose contract + is invalid, missing, or unapproved. +1. Non-trycycle changesets retain current behavior with no new gating. + +## Contracts and Invariants + +1. Lifecycle contract remains canonical and unchanged. No new status values are + added to `LifecycleStatus`. +1. `planning_in_review` is represented as metadata + (`trycycle.plan_stage: planning_in_review`) rather than lifecycle state. +1. Validation is deterministic and shared. The same checker must be reused by: + - planner guardrail scripts + - promotion/approval path + - worker startup claim gate +1. Approval is mandatory for trycycle-targeted claim eligibility. +1. Missing or malformed contract metadata is treated as non-runnable for + trycycle-targeted changesets. +1. Completion definition must not conflict with existing finalize semantics: + close only when PR lifecycle is terminal (`merged|closed`) or when + `changeset.integrated_sha` proves integration. + +## Strategy Gate Decisions (Locked) + +1. Use namespaced metadata fields, not a new lifecycle status. + Reason: adding lifecycle states would touch broad store/lifecycle/finalize + logic and risks regressions unrelated to #719. +1. Use one typed JSON payload field (`trycycle.contract_json`) plus small, + auditable metadata fields for stage/approval. + Reason: typed payload gives strict schema guarantees; small scalar fields + keep approval evidence easy to inspect with current description-field + tooling. +1. Reuse one shared core validator for planner and worker paths. + Reason: avoids drift where planner and worker disagree on readiness. +1. Make worker gate selective (only `trycycle.targeted: true`). + Reason: preserves existing execution behavior for non-trycycle work. + +## File Structure + +- Create: `src/atelier/trycycle_contract.py` + - Typed models, parser/serializer, validation helpers, completion-definition + conflict checks, evidence summary rendering. +- Create: `tests/atelier/test_trycycle_contract.py` + - Unit tests for parsing, schema validation, quality checks, and lifecycle + completion-definition conflict detection. +- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + - Invoke shared trycycle validator, surface deterministic violations. +- Modify: + `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + - Add trycycle-targeted valid/invalid/missing-field coverage. +- Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` + - Enforce trycycle validation before promotion, persist approval metadata, + write evidence summary note, emit approval message. +- Modify: `tests/atelier/skills/test_plan_promote_epic_script.py` + - Add tests for required approval markers/evidence persistence. +- Modify: `src/atelier/worker/session/startup.py` + - Extend next-changeset protocol to require trycycle claim eligibility. +- Modify: `src/atelier/worker/work_startup_runtime.py` + - Implement service adapter method(s) using shared validator. +- Modify: `tests/atelier/worker/test_session_startup.py` + - Verify worker skips/rejects unapproved or invalid trycycle-targeted + changesets. +- Modify: `tests/atelier/worker/test_work_startup_runtime.py` + - Verify startup runtime adapter uses store/contract helpers (not ad hoc + parsing). +- Modify: `tests/atelier/worker/test_lifecycle_matrix.py` + - Add matrix coverage for trycycle claim-gating invariants. +- Modify: `tests/atelier/skills/test_projected_skill_runtime_bootstrap.py` + - Keep projected runtime fixtures compatible with any new core imports from + planner scripts. +- Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` + - Add trycycle-ready planning + explicit approval guidance. +- Modify: `src/atelier/skills/plan-changesets/SKILL.md` +- Modify: `src/atelier/skills/plan-changeset-guardrails/SKILL.md` +- Modify: `src/atelier/skills/plan-promote-epic/SKILL.md` + - Document new contract fields and approval gate. +- Modify: `docs/behavior.md` + - Add runtime behavior notes for trycycle-targeted claim gating. + +## Cutover and Regression Risks + +1. Risk: worker refuses all work due to over-broad gating. + Mitigation: gate only when `trycycle.targeted: true`; add explicit tests for + unchanged non-trycycle flow. +1. Risk: planner script import drift in projected skill runtime tests. + Mitigation: update bootstrap fixture module stubs when introducing + `trycycle_contract` import. +1. Risk: approval metadata write succeeds partially. + Mitigation: use store/beads update + read-after-write verification (existing + atomic verification pattern used in store adapters). +1. Risk: completion-definition checker blocks valid plans. + Mitigation: codify allowed forms aligned to current worker prompt/finalize + contract and cover with focused tests. + +### Task 1: Add Typed Trycycle Contract Model and Shared Validator + +**Files:** +- Create: `src/atelier/trycycle_contract.py` +- Test: `tests/atelier/test_trycycle_contract.py` + +- [ ] **Step 1: Identify or write the failing test** + +```python +# tests/atelier/test_trycycle_contract.py +from atelier import trycycle_contract + + +def test_validate_contract_accepts_complete_payload() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + "trycycle.contract_json: {\"objective\":\"...\",...}\n" + ) + } + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + assert result.ok is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` +Expected: FAIL (`ImportError` / missing module or missing API). + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/atelier/trycycle_contract.py +class TrycycleContract(BaseModel): + objective: str + non_goals: tuple[str, ...] + acceptance_criteria: tuple[AcceptanceCriterion, ...] + scope: ScopeBoundary + verification_plan: tuple[str, ...] + risks: tuple[RiskItem, ...] + escalation_conditions: tuple[str, ...] + completion_definition: CompletionDefinition + + +def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessResult: + # parse description fields + # if trycycle.targeted != true: return ok/non-targeted + # parse trycycle.contract_json and stage/approval fields + # run schema + quality + lifecycle conflict checks +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Tighten parse/normalization boundaries and add dedicated tests for: +- malformed JSON +- missing escalation conditions +- non-testable acceptance criteria (missing evidence) +- conflicting completion definition + +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/atelier/test_trycycle_contract.py src/atelier/trycycle_contract.py +git commit -m "feat(planner): add typed trycycle contract validator" \ + -m "- add shared trycycle readiness schema and parser\n- add deterministic readiness validation and diagnostics" +``` + +### Task 2: Enforce Trycycle Contract in Planner Guardrail Checks + +**Files:** +- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` +- Test: `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + +- [ ] **Step 1: Identify or write the failing test** + +```python +# tests/atelier/skills/test_plan_changeset_guardrails_script.py + +def test_guardrails_flags_trycycle_target_missing_contract() -> None: + child = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + assert any("trycycle.contract_json" in item for item in report.violations) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -k trycycle -v` +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +```python +from atelier import trycycle_contract + +readiness = trycycle_contract.evaluate_issue_trycycle_readiness(issue) +if readiness.targeted and not readiness.contract_present: + violations.append(f"{issue_id}: missing trycycle.contract_json") +if readiness.targeted and readiness.errors: + violations.extend(f"{issue_id}: {err}" for err in readiness.errors) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -k trycycle -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Add test coverage for: +- valid targeted payload (no violations) +- completion-definition conflict violation +- explicit `planning_in_review` stage requirement + +Run: `uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py \ + tests/atelier/skills/test_plan_changeset_guardrails_script.py +git commit -m "feat(planner): validate trycycle-ready contracts in guardrails" \ + -m "- wire guardrail script to shared trycycle readiness validator\n- add deterministic contract violation coverage" +``` + +### Task 3: Add Promotion-Time Approval Capture and Evidence Persistence + +**Files:** +- Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` +- Test: `tests/atelier/skills/test_plan_promote_epic_script.py` + +- [ ] **Step 1: Identify or write the failing test** + +```python +# tests/atelier/skills/test_plan_promote_epic_script.py + +def test_promote_epic_blocks_trycycle_target_without_valid_contract(...): + # targeted child with invalid payload + # expect SystemExit(1) and explicit trycycle validation error + + +def test_promote_epic_records_trycycle_approval_metadata(...): + # targeted valid child + --yes + # expect description fields include approved_by/approved_at/stage +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -k trycycle -v` +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +```python +# in promote_epic.py before transition_lifecycle +readiness = trycycle_contract.evaluate_issue_trycycle_readiness(child_issue) +if readiness.targeted and not readiness.ok: + raise RuntimeError(f"{child_id} trycycle readiness failed: {readiness.summary}") + +# on --yes for targeted issue +# set: trycycle.plan_stage=approved +# set: trycycle.approved_by= +# set: trycycle.approved_at= +# append concise evidence note +# create work-threaded approval message and persist message id +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -k trycycle -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Add coverage for: +- non-targeted changesets unchanged +- target with `planning_in_review` stage transitions to `approved` +- persisted approval message id is included in metadata + +Run: `uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -v` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/skills/plan-promote-epic/scripts/promote_epic.py \ + tests/atelier/skills/test_plan_promote_epic_script.py +git commit -m "feat(planner): require and record trycycle operator approval" \ + -m "- block promotion when trycycle contract validation fails\n- persist approval metadata and planning evidence audit trail" +``` + +### Task 4: Gate Worker Claim Selection on Trycycle Readiness + +**Files:** +- Modify: `src/atelier/worker/session/startup.py` +- Modify: `src/atelier/worker/work_startup_runtime.py` +- Test: `tests/atelier/worker/test_session_startup.py` +- Test: `tests/atelier/worker/test_work_startup_runtime.py` +- Test: `tests/atelier/worker/test_lifecycle_matrix.py` + +- [ ] **Step 1: Identify or write the failing test** + +```python +# tests/atelier/worker/test_session_startup.py + +def test_next_changeset_skips_unapproved_trycycle_changeset() -> None: + # first candidate targeted+invalid, second candidate non-targeted valid + # expect second candidate selected + + +def test_explicit_epic_with_only_invalid_trycycle_changeset_returns_none() -> None: + # expect no actionable changeset and startup reason remains fail-closed +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` +Expected: FAIL. + +- [ ] **Step 3: Write minimal implementation** + +```python +# startup.NextChangesetService + +def trycycle_claim_eligible(self, issue: dict[str, object]) -> tuple[bool, str | None]: ... + +# next_changeset_service loop +eligible, reason = service.trycycle_claim_eligible(issue) +if not eligible: + continue +``` + +```python +# work_startup_runtime._NextChangesetService + +def trycycle_claim_eligible(self, issue): + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + if not result.targeted: + return True, None + if result.approved: + return True, None + return False, result.summary +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` +Expected: PASS. + +- [ ] **Step 5: Refactor and verify** + +Add matrix/regression coverage: +- targeted+approved is selectable +- targeted+missing approval is not selectable +- non-targeted flow unchanged + +Run: +- `uv run pytest tests/atelier/worker/test_session_startup.py -v` +- `uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v` +- `uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/atelier/worker/session/startup.py src/atelier/worker/work_startup_runtime.py \ + tests/atelier/worker/test_session_startup.py \ + tests/atelier/worker/test_work_startup_runtime.py \ + tests/atelier/worker/test_lifecycle_matrix.py +git commit -m "feat(worker): fail closed on non-approved trycycle changesets" \ + -m "- add startup claim gate for trycycle contract readiness\n- preserve existing non-trycycle claim behavior" +``` + +### Task 5: Keep Skill Runtime Bootstrap and Planner Guidance Consistent + +**Files:** +- Modify: `tests/atelier/skills/test_projected_skill_runtime_bootstrap.py` +- Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` +- Modify: `src/atelier/skills/plan-changesets/SKILL.md` +- Modify: `src/atelier/skills/plan-changeset-guardrails/SKILL.md` +- Modify: `src/atelier/skills/plan-promote-epic/SKILL.md` +- Modify: `docs/behavior.md` +- Test: `tests/atelier/test_planner_agents_template.py` +- Test: `tests/atelier/test_skills.py` + +- [ ] **Step 1: Identify or write the failing test** + +```python +# tests/atelier/test_skills.py + +def test_plan_promote_epic_skill_mentions_trycycle_approval_gate() -> None: + text = skills.load_packaged_skills()["plan-promote-epic"].text + assert "trycycle.plan_stage" in text +``` + +- [ ] **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. + +- [ ] **Step 3: Write minimal implementation** + +Update planner guidance docs/templates to require: +- `@plan-changeset-guardrails` validation for trycycle-targeted work +- explicit operator confirmation and metadata fields +- worker fail-closed expectation for unapproved trycycle changesets + +- [ ] **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 projected bootstrap fixtures still satisfy script imports. + +Run: +`uv run pytest tests/atelier/skills/test_projected_skill_runtime_bootstrap.py -k guardrails -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/atelier/skills/test_projected_skill_runtime_bootstrap.py \ + src/atelier/templates/AGENTS.planner.md.tmpl \ + src/atelier/skills/plan-changesets/SKILL.md \ + src/atelier/skills/plan-changeset-guardrails/SKILL.md \ + src/atelier/skills/plan-promote-epic/SKILL.md docs/behavior.md \ + tests/atelier/test_planner_agents_template.py tests/atelier/test_skills.py +git commit -m "docs(planner): codify trycycle-ready approval workflow" \ + -m "- align planner template and skill docs with trycycle gating contract\n- update bootstrap and docs coverage for new shared contract usage" +``` + +### Task 6: Run Full Verification and Land + +**Files:** +- Modify: any files touched by fixes discovered during verification. + +- [ ] **Step 1: Run focused regression suite** + +Run: +```bash +uv run pytest tests/atelier/test_trycycle_contract.py -v +uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v +uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -v +uv run pytest tests/atelier/worker/test_session_startup.py -v +uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v +uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v +``` +Expected: all PASS. + +- [ ] **Step 2: Run canonical project gates** + +Run: +```bash +just format +just lint +just test +``` +Expected: all PASS. + +- [ ] **Step 3: If any gate fails, fix root cause (not tests)** + +Prefer production/contract fixes over weakening assertions. + +- [ ] **Step 4: Re-run failing gate(s) and full required gates** + +Re-run whichever command failed, then re-run: +`just format && just lint && just test` +Expected: all PASS. + +- [ ] **Step 5: Final review of behavior invariants** + +Confirm before merge: +- non-trycycle flows unchanged +- trycycle-targeted flows fail closed unless approved +- approval/evidence metadata is auditable from beads/messages + +- [ ] **Step 6: Commit verification fixes** + +```bash +git add -A +git commit -m "test(planner): finalize trycycle-ready claim-gate coverage" \ + -m "- close verification gaps across planner validation and worker gating\n- ensure format/lint/test gates pass with fail-closed invariants" +``` + +## Definition of Done + +1. Planner can produce and validate typed trycycle-ready payloads. +1. Promotion path enforces validation and captures explicit approval evidence. +1. Worker startup claim path rejects non-compliant trycycle-targeted changesets. +1. Metadata/messages provide auditable evidence summary + approval record. +1. Existing non-trycycle lifecycle behavior remains unchanged. +1. `just format`, `just lint`, and `just test` pass. From f15b35b039bfcbb629da861e1827d5e0f1c95a7d Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:03:07 -0700 Subject: [PATCH 02/16] docs(plan): tighten trycycle claim-gate coverage - close startup bypass gaps by requiring trycycle gating across explicit, review-feedback, and merge-conflict claim paths - add missing test-surface updates for next-changeset protocol consumers and focused regression commands - capture epic-as-single-unit approval metadata expectations to avoid promotion-time audit gaps --- ...6-03-26-trycycle-ready-planner-contract.md | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-03-26-trycycle-ready-planner-contract.md b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md index 3ffea402..a6c19a14 100644 --- a/docs/plans/2026-03-26-trycycle-ready-planner-contract.md +++ b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md @@ -32,8 +32,9 @@ planner skill scripts, worker startup pipeline, pytest. - `trycycle.approved_by` - `trycycle.approved_at` - `trycycle.approval_message_id` -1. Worker startup refuses to select trycycle-targeted changesets whose contract - is invalid, missing, or unapproved. +1. Worker startup refuses to select or resume trycycle-targeted changesets + whose contract is invalid, missing, or unapproved (including explicit-epic, + next-changeset, merge-conflict, and review-feedback selection paths). 1. Non-trycycle changesets retain current behavior with no new gating. ## Contracts and Invariants @@ -46,6 +47,8 @@ planner skill scripts, worker startup pipeline, pytest. - planner guardrail scripts - promotion/approval path - worker startup claim gate +1. Worker startup claim gate applies to all claim sources, not only + next-changeset selection. 1. Approval is mandatory for trycycle-targeted claim eligibility. 1. Missing or malformed contract metadata is treated as non-runnable for trycycle-targeted changesets. @@ -87,15 +90,20 @@ planner skill scripts, worker startup pipeline, pytest. - Modify: `tests/atelier/skills/test_plan_promote_epic_script.py` - Add tests for required approval markers/evidence persistence. - Modify: `src/atelier/worker/session/startup.py` - - Extend next-changeset protocol to require trycycle claim eligibility. + - Extend startup/next-changeset protocols to require trycycle claim + eligibility and apply the same gate to all startup selection paths. - Modify: `src/atelier/worker/work_startup_runtime.py` - - Implement service adapter method(s) using shared validator. + - Implement startup + next-changeset service adapter methods using shared + validator. - Modify: `tests/atelier/worker/test_session_startup.py` - Verify worker skips/rejects unapproved or invalid trycycle-targeted - changesets. + changesets across claim paths. +- Modify: `tests/atelier/worker/test_session_next_changeset.py` + - Verify next-changeset service uses shared trycycle gate for leaf-epic and + descendant candidate selection. - Modify: `tests/atelier/worker/test_work_startup_runtime.py` - - Verify startup runtime adapter uses store/contract helpers (not ad hoc - parsing). + - Verify startup runtime adapters use store/contract helpers (not ad hoc + parsing), including trycycle eligibility adapters. - Modify: `tests/atelier/worker/test_lifecycle_matrix.py` - Add matrix coverage for trycycle claim-gating invariants. - Modify: `tests/atelier/skills/test_projected_skill_runtime_bootstrap.py` @@ -115,6 +123,9 @@ planner skill scripts, worker startup pipeline, pytest. 1. Risk: worker refuses all work due to over-broad gating. Mitigation: gate only when `trycycle.targeted: true`; add explicit tests for unchanged non-trycycle flow. +1. Risk: startup bypasses gate via review-feedback/merge-conflict selection. + Mitigation: apply one shared `trycycle_claim_eligible` check before every + startup return path that yields a concrete changeset id. 1. Risk: planner script import drift in projected skill runtime tests. Mitigation: update bootstrap fixture module stubs when introducing `trycycle_contract` import. @@ -284,6 +295,11 @@ def test_promote_epic_blocks_trycycle_target_without_valid_contract(...): def test_promote_epic_records_trycycle_approval_metadata(...): # targeted valid child + --yes # expect description fields include approved_by/approved_at/stage + + +def test_promote_epic_records_trycycle_approval_metadata_for_epic_single_unit(...): + # targeted epic with no child changesets + --yes + # expect same approval fields/message id on epic record ``` - [ ] **Step 2: Run test to verify it fails** @@ -304,8 +320,11 @@ if readiness.targeted and not readiness.ok: # set: trycycle.plan_stage=approved # set: trycycle.approved_by= # set: trycycle.approved_at= +# set: trycycle.approval_message_id= # append concise evidence note # create work-threaded approval message and persist message id +# apply updates to every promoted targeted executable record +# (child changesets and epic-as-single-unit when no children exist) ``` - [ ] **Step 4: Run test to verify it passes** @@ -320,6 +339,7 @@ Add coverage for: - non-targeted changesets unchanged - target with `planning_in_review` stage transitions to `approved` - persisted approval message id is included in metadata +- single-unit epic target records identical approval metadata Run: `uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -v` Expected: all PASS. @@ -339,6 +359,7 @@ git commit -m "feat(planner): require and record trycycle operator approval" \ - Modify: `src/atelier/worker/session/startup.py` - Modify: `src/atelier/worker/work_startup_runtime.py` - Test: `tests/atelier/worker/test_session_startup.py` +- Test: `tests/atelier/worker/test_session_next_changeset.py` - Test: `tests/atelier/worker/test_work_startup_runtime.py` - Test: `tests/atelier/worker/test_lifecycle_matrix.py` @@ -354,12 +375,23 @@ def test_next_changeset_skips_unapproved_trycycle_changeset() -> None: def test_explicit_epic_with_only_invalid_trycycle_changeset_returns_none() -> None: # expect no actionable changeset and startup reason remains fail-closed + + +def test_review_feedback_selection_rejects_unapproved_trycycle_changeset() -> None: + # explicit/global review-feedback selection yields a targeted unapproved id + # expect startup keeps searching or exits with deterministic non-claim reason + + +def test_merge_conflict_selection_rejects_unapproved_trycycle_changeset() -> None: + # explicit/global merge-conflict selection yields targeted unapproved id + # expect startup refuses claim and does not bypass gate ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` +`uv run pytest tests/atelier/worker/test_session_next_changeset.py -k trycycle -v` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** @@ -369,10 +401,18 @@ Expected: FAIL. def trycycle_claim_eligible(self, issue: dict[str, object]) -> tuple[bool, str | None]: ... +# startup.StartupContractService + +def trycycle_claim_eligible(self, issue: dict[str, object]) -> tuple[bool, str | None]: ... + # next_changeset_service loop eligible, reason = service.trycycle_claim_eligible(issue) if not eligible: continue + +# run_startup_contract_service paths that return a concrete changeset id +# (explicit/global review-feedback, explicit/global merge-conflict) +# must load the selected issue and apply the same gate before returning. ``` ```python @@ -385,6 +425,9 @@ def trycycle_claim_eligible(self, issue): if result.approved: return True, None return False, result.summary + +# work_startup_runtime._StartupContractService +# delegates to the same helper/validator as _NextChangesetService ``` - [ ] **Step 4: Run test to verify it passes** @@ -398,10 +441,14 @@ Expected: PASS. Add matrix/regression coverage: - targeted+approved is selectable - targeted+missing approval is not selectable +- explicit/global review-feedback and merge-conflict selections cannot bypass + trycycle approval gate - non-targeted flow unchanged +- fake/test services implement the new protocol method Run: - `uv run pytest tests/atelier/worker/test_session_startup.py -v` +- `uv run pytest tests/atelier/worker/test_session_next_changeset.py -v` - `uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v` - `uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v` Expected: all PASS. @@ -411,10 +458,11 @@ Expected: all PASS. ```bash git add src/atelier/worker/session/startup.py src/atelier/worker/work_startup_runtime.py \ tests/atelier/worker/test_session_startup.py \ + tests/atelier/worker/test_session_next_changeset.py \ tests/atelier/worker/test_work_startup_runtime.py \ tests/atelier/worker/test_lifecycle_matrix.py git commit -m "feat(worker): fail closed on non-approved trycycle changesets" \ - -m "- add startup claim gate for trycycle contract readiness\n- preserve existing non-trycycle claim behavior" + -m "- add startup claim gate for trycycle contract readiness across all claim paths\n- preserve existing non-trycycle claim behavior" ``` ### Task 5: Keep Skill Runtime Bootstrap and Planner Guidance Consistent @@ -492,6 +540,7 @@ uv run pytest tests/atelier/test_trycycle_contract.py -v uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v uv run pytest tests/atelier/skills/test_plan_promote_epic_script.py -v uv run pytest tests/atelier/worker/test_session_startup.py -v +uv run pytest tests/atelier/worker/test_session_next_changeset.py -v uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v ``` From e93ae73e31597236a39c53ac2efd73ad7685aec0 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:22:21 -0700 Subject: [PATCH 03/16] test(planner): add trycycle ready contract test plan - add a prioritized, source-of-truth-mapped test plan for issue #719\n- define harness prerequisites for planner scripts, startup gating, and projected runtime\n- enumerate coverage and explicit exclusions for trycycle contract gating --- ...ycycle-ready-planner-contract-test-plan.md | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md diff --git a/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md b/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md new file mode 100644 index 00000000..f002d7b3 --- /dev/null +++ b/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md @@ -0,0 +1,355 @@ +# Trycycle-Ready Planner Contract Test Plan + +## Strategy reconciliation +The transcript goal and the implementation plan are aligned: ship a fail-closed +planner/worker contract for trycycle-targeted changesets and record auditable +approval evidence before work becomes runnable. + +No strategy changes requiring user approval were identified. + +Adjustments made during reconciliation: + +1. Expanded worker claim-gate coverage to include global review-feedback and + global merge-conflict startup paths, not only explicit-epic paths. + Why: the implementation plan requires all startup claim sources to share the + same fail-closed gate. +1. Added differential drift checks that compare planner and worker readiness + outcomes through the shared validator boundary. + Why: the implementation plan requires one reused validator and deterministic + readiness semantics. + +## Harness requirements +1. **Trycycle metadata fixture builder (new, low complexity)** + - What it does: builds issue payloads with description-field metadata for + `trycycle.targeted`, `trycycle.contract_json`, `trycycle.plan_stage`, and + approval evidence fields. + - Exposes: helper API for valid, invalid, missing-field, and unapproved + payload variants. + - Depends-on tests: 1-9, 17. +1. **Startup protocol fake-service extension (extend, medium complexity)** + - What it does: updates fake startup services to expose + `trycycle_claim_eligible(issue) -> (bool, reason)` and configurable + eligibility outcomes. + - Exposes: per-issue eligibility maps and captured rejection reasons for + assertions in claim-path tests. + - Depends-on tests: 10-16, 18. +1. **Promotion approval-audit harness (extend, medium complexity)** + - What it does: extends promotion script fakes to capture description-field + writes, audit message creation, and read-after-write verification paths. + - Exposes: recorded lifecycle transitions, metadata updates, and approval + message ids. + - Depends-on tests: 7-9. +1. **Projected skill bootstrap shim for new core import (extend, low + complexity)** + - What it does: updates projected-runtime fake module trees so planner skill + scripts importing the new core contract module still run in tests. + - Exposes: repo-src precedence assertions with the new import graph. + - Depends-on tests: 19. + +## Test plan +1. **Name:** Targeted changeset without `trycycle.contract_json` is rejected + by readiness evaluation + **Type:** regression + **Disposition:** new + **Harness:** Trycycle metadata fixture builder + **Preconditions:** Issue description contains `trycycle.targeted: true` and + omits `trycycle.contract_json`. + **Actions:** Call `evaluate_issue_trycycle_readiness(issue)` from + `src/atelier/trycycle_contract.py`. + **Expected outcome:** Result is fail-closed (`ok=False`, targeted true) with + a deterministic error mentioning missing `trycycle.contract_json`. + Source: implementation plan, User-Visible Behavior Contract items 1-3 and 6. + **Interactions:** Description-field parser and contract JSON edge parsing. + +1. **Name:** Valid contract in `planning_in_review` is planner-valid but not + worker-claim-eligible + **Type:** invariant + **Disposition:** new + **Harness:** Trycycle metadata fixture builder + **Preconditions:** Targeted issue has valid typed contract JSON and + `trycycle.plan_stage: planning_in_review` with no approval fields. + **Actions:** Call readiness evaluator and claim-eligibility helper used by + worker startup. + **Expected outcome:** Contract validation passes, but claim eligibility is + false until explicit approval metadata exists. + Source: implementation plan, User-Visible Behavior Contract items 3-5 and + Contracts and Invariants item 5. + **Interactions:** Shared validator output consumed by planner and worker. + +1. **Name:** Approval evidence requires all audit fields + **Type:** boundary + **Disposition:** new + **Harness:** Trycycle metadata fixture builder + **Preconditions:** Targeted issue has valid contract and stage `approved`, + but one of `approved_by`, `approved_at`, or `approval_message_id` is + missing/malformed. + **Actions:** Evaluate readiness for each missing or malformed field case. + **Expected outcome:** Each case is non-eligible with deterministic + diagnostics naming the missing evidence field. + Source: implementation plan, User-Visible Behavior Contract item 4. + **Interactions:** Field normalization and timestamp parsing logic. + +1. **Name:** Completion-definition lifecycle conflicts are rejected + **Type:** boundary + **Disposition:** new + **Harness:** Trycycle metadata fixture builder + **Preconditions:** Contract completion definition conflicts with finalize + semantics (for example, allows close without terminal PR state or integrated + SHA proof). + **Actions:** Evaluate readiness on conflicting completion definitions. + **Expected outcome:** Readiness fails with conflict diagnostics; compliant + definitions pass. + Source: implementation plan, Contracts and Invariants item 7. + **Interactions:** Shared completion-definition checker with lifecycle rules. + +1. **Name:** Guardrails report fails targeted changesets missing contract + payload + **Type:** scenario + **Disposition:** extend + **Harness:** Planner script harness (`check_guardrails.py` module entrypoint) + **Preconditions:** Guardrails target list contains at least one targeted + changeset without `trycycle.contract_json`. + **Actions:** Run `_evaluate_guardrails(...)` and `main()` paths. + **Expected outcome:** Report includes targeted-contract violation text and + retains existing guardrail reporting format. + Source: implementation plan Task 2 and User-Visible Behavior Contract items + 2-3. + **Interactions:** Planner contract checks plus trycycle validator reuse. + +1. **Name:** Guardrails accept fully valid targeted planning payload + **Type:** integration + **Disposition:** extend + **Harness:** Planner script harness (`check_guardrails.py`) + **Preconditions:** Targeted issue has valid contract payload with required + plan-stage semantics for planner readiness. + **Actions:** Run guardrail evaluation for targeted child and epic-as-single + paths. + **Expected outcome:** No trycycle-targeted violations are emitted for valid + payloads. + Source: implementation plan Task 2 and Strategy Gate Decision 3. + **Interactions:** Cross-check between authoring-contract and trycycle checks. + +1. **Name:** Promotion blocks targeted work when contract validation fails + **Type:** scenario + **Disposition:** extend + **Harness:** Promotion approval-audit harness (`promote_epic.py` main path) + **Preconditions:** Deferred epic has targeted executable unit with invalid or + missing trycycle contract. + **Actions:** Run promotion script with and without `--yes`. + **Expected outcome:** Script exits fail-closed before lifecycle transitions; + stderr includes deterministic trycycle validation failure. + Source: implementation plan Task 3 and User-Visible Behavior Contract item 3. + **Interactions:** Store transition API, preview renderer, shared validator. + +1. **Name:** Promotion records complete approval audit metadata for targeted + child changesets + **Type:** integration + **Disposition:** extend + **Harness:** Promotion approval-audit harness + **Preconditions:** Deferred epic with deferred targeted child changeset, + valid contract, explicit `--yes`. + **Actions:** Run `promote_epic.py --yes`, then inspect persisted metadata. + **Expected outcome:** Promoted targeted child includes + `trycycle.plan_stage=approved`, `approved_by`, `approved_at`, and + `approval_message_id`, and audit evidence is persisted/readable. + Source: implementation plan User-Visible Behavior Contract item 4 and Task 3. + **Interactions:** Description-field updates, message-thread persistence, + lifecycle transition sequencing. + +1. **Name:** Promotion records identical approval metadata for epic-as-single + executable path + **Type:** integration + **Disposition:** extend + **Harness:** Promotion approval-audit harness + **Preconditions:** Deferred targeted epic has no child changesets and valid + contract payload. + **Actions:** Run `promote_epic.py --yes` for single-unit epic path. + **Expected outcome:** Epic record receives the same approval audit fields and + evidence message behavior as child-changeset path. + Source: implementation plan Task 3 and File Structure note for + epic-as-single-unit coverage. + **Interactions:** Single-unit execution path in promotion script and store. + +1. **Name:** Next-changeset selection skips unapproved targeted candidates + **Type:** scenario + **Disposition:** extend + **Harness:** Startup protocol fake-service extension + **Preconditions:** Descendant candidate list contains targeted unapproved + candidate first, followed by eligible non-targeted candidate. + **Actions:** Run `next_changeset_service(...)`. + **Expected outcome:** First candidate is rejected by claim gate; second is + returned. + Source: implementation plan User-Visible Behavior Contract item 5. + **Interactions:** Dependency checks, review-state checks, and trycycle gate + order. + +1. **Name:** Explicit-epic startup path fails closed when only targeted + unapproved work exists + **Type:** scenario + **Disposition:** extend + **Harness:** Startup protocol fake-service extension + **Preconditions:** `explicit_epic_id` points to claimable epic; next + executable candidate is targeted and unapproved. + **Actions:** Run `run_startup_contract_service(...)` with explicit epic mode. + **Expected outcome:** Startup does not return a runnable changeset and exits + with deterministic fail-closed reason/output. + Source: implementation plan User-Visible Behavior Contract item 5 and + Contracts and Invariants item 6. + **Interactions:** Explicit epic branch, claimability checks, emission path. + +1. **Name:** Explicit merge-conflict selection cannot bypass trycycle approval + gate + **Type:** scenario + **Disposition:** extend + **Harness:** Startup protocol fake-service extension + **Preconditions:** PR startup context enabled; merge-conflict selector + returns targeted unapproved changeset. + **Actions:** Run startup contract explicit path with + `select_conflicted_changeset` returning that candidate. + **Expected outcome:** Startup rejects the candidate and continues scanning or + exits non-claimable; it does not return the blocked changeset. + Source: implementation plan User-Visible Behavior Contract item 5. + **Interactions:** Merge-conflict selector, startup reason routing. + +1. **Name:** Explicit review-feedback selection cannot bypass trycycle approval + gate + **Type:** scenario + **Disposition:** extend + **Harness:** Startup protocol fake-service extension + **Preconditions:** PR startup context enabled; review-feedback selector + returns targeted unapproved changeset. + **Actions:** Run startup contract explicit path with + `select_review_feedback_changeset`. + **Expected outcome:** Candidate is rejected by the shared gate; startup does + not claim it. + Source: implementation plan User-Visible Behavior Contract item 5. + **Interactions:** Review-feedback selector and claim gating. + +1. **Name:** Global review-feedback and merge-conflict selectors are gated with + the same trycycle check + **Type:** integration + **Disposition:** extend + **Harness:** Startup protocol fake-service extension + **Preconditions:** Global selectors return targeted unapproved candidates + from other epic families. + **Actions:** Run non-explicit startup contract flow through global selector + stages. + **Expected outcome:** Global candidates are rejected identically to explicit + selectors; no bypass path remains. + Source: implementation plan Contracts and Invariants item 4 and Task 4. + **Interactions:** Global selector cache, claimability checks, fallback paths. + +1. **Name:** Approved targeted changesets remain selectable across all claim + sources + **Type:** invariant + **Disposition:** new + **Harness:** Startup protocol fake-service extension + **Preconditions:** Targeted candidates include complete approved metadata and + valid contract payload. + **Actions:** Exercise explicit next-changeset, explicit review-feedback, + explicit merge-conflict, and global selector paths. + **Expected outcome:** All paths accept approved targeted candidates when + other lifecycle constraints are satisfied. + Source: implementation plan User-Visible Behavior Contract items 4-5. + **Interactions:** Shared gate with existing claimability predicates. + +1. **Name:** Non-trycycle startup behavior remains unchanged + **Type:** regression + **Disposition:** extend + **Harness:** Existing startup/lifecycle matrix harnesses + **Preconditions:** Issues do not carry `trycycle.targeted: true`. + **Actions:** Re-run existing startup and lifecycle matrix scenarios. + **Expected outcome:** Existing non-trycycle selection and lifecycle outcomes + remain unchanged. + Source: implementation plan User-Visible Behavior Contract item 6 and + Contracts and Invariants item 1. + **Interactions:** Baseline startup selection, lifecycle matrix predicates. + +1. **Name:** Shared-validator differential parity across planner and worker + entrypoints + **Type:** differential + **Disposition:** new + **Harness:** Trycycle metadata fixture builder + startup fake extensions + **Preconditions:** Matrix of targeted payloads: valid approved, valid + unapproved, malformed JSON, missing fields, completion conflict. + **Actions:** Evaluate each payload through + `trycycle_contract.evaluate_issue_trycycle_readiness`, guardrails integration + wrapper, promotion preflight path, and worker claim-eligibility adapter. + **Expected outcome:** All entrypoints agree on eligible vs non-eligible and + deterministic summary class. + Source: implementation plan Strategy Gate Decision 3. + **Interactions:** Cross-module contract reuse and error-summary formatting. + +1. **Name:** Runtime adapters call shared validator (no ad hoc parsing) + **Type:** integration + **Disposition:** extend + **Harness:** `test_work_startup_runtime.py` adapter harness + **Preconditions:** Monkeypatched validator helper records calls; adapter + service receives targeted and non-targeted issues. + **Actions:** Call `_NextChangesetService.trycycle_claim_eligible(...)` and + `_StartupContractService.trycycle_claim_eligible(...)`. + **Expected outcome:** Both adapters delegate to shared validator and return + normalized `(eligible, reason)` tuples. + Source: implementation plan Task 4 and File Structure notes. + **Interactions:** Runtime adapter boundary to core validator module. + +1. **Name:** Projected planner scripts keep bootstrap compatibility after adding + shared trycycle contract import + **Type:** integration + **Disposition:** extend + **Harness:** Projected skill bootstrap shim + **Preconditions:** Projected guardrails and promotion scripts run in agent + home with repo/src precedence test setup. + **Actions:** Execute projected script `--help` runs under bootstrap tests. + **Expected outcome:** Scripts import successfully and still resolve repo + source modules before installed packages. + Source: implementation plan Cutover and Regression Risks item 3. + **Interactions:** projected bootstrap, Python path ordering, skill packaging. + +1. **Name:** Planner template and skill docs encode trycycle approval gate + contract + **Type:** regression + **Disposition:** extend + **Harness:** `tests/atelier/test_skills.py` and + `tests/atelier/test_planner_agents_template.py` + **Preconditions:** Updated `AGENTS.planner.md.tmpl`, + `plan-changesets/SKILL.md`, `plan-changeset-guardrails/SKILL.md`, and + `plan-promote-epic/SKILL.md`. + **Actions:** Run skill/template content assertions against packaged files. + **Expected outcome:** Guidance explicitly references trycycle fields, + approval gate, and worker fail-closed behavior. + Source: implementation plan Task 5 and User-Visible Behavior Contract items + 3-5. + **Interactions:** Packaged skill sync/install and template rendering paths. + +## Coverage summary +Covered action space: + +1. Planner guardrail CLI actions: + `--epic-id`, `--changeset-id`, targeted contract validation reporting. +1. Promotion CLI actions: + preview vs `--yes` promotion, targeted validation gate, approval evidence + persistence for child and epic-as-single paths. +1. Worker startup claim actions: + explicit epic, next-changeset, review-feedback, merge-conflict, global + review-feedback, global merge-conflict, and no-bypass fail-closed behavior. +1. Shared validator parity: + planner, promotion, and worker paths all consume one deterministic contract + checker. +1. Packaging and documentation surface: + projected skill bootstrap compatibility plus planner guidance updates. + +Explicit exclusions (per current implementation strategy): + +1. End-to-end networked GitHub review API behavior is not exercised directly in + these tests. + Risk: integration bugs in upstream review fetchers could still affect startup + candidate discovery before gating. +1. Full `atelier work` subprocess E2E is not expanded for this slice; startup + contract/service tests remain the primary behavior proof. + Risk: CLI wiring regressions outside startup contract adapters could require + follow-up command-level tests. +1. No performance benchmark suite is added because this change is contract + gating, not throughput-sensitive logic. + Risk: minimal; catastrophic regressions are still caught by normal test + runtime and CI gates. From ed19bb807305c93c4e44d9776d1b4b01de7be794 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:29:02 -0700 Subject: [PATCH 04/16] feat(planner): add typed trycycle contract validator - add shared trycycle readiness schema and parser - add deterministic readiness validation and diagnostics --- src/atelier/trycycle_contract.py | 383 ++++++++++++++++++++++++ tests/atelier/test_trycycle_contract.py | 129 ++++++++ 2 files changed, 512 insertions(+) create mode 100644 src/atelier/trycycle_contract.py create mode 100644 tests/atelier/test_trycycle_contract.py diff --git a/src/atelier/trycycle_contract.py b/src/atelier/trycycle_contract.py new file mode 100644 index 00000000..c776a049 --- /dev/null +++ b/src/atelier/trycycle_contract.py @@ -0,0 +1,383 @@ +"""Trycycle planner/worker contract models and readiness validators.""" + +from __future__ import annotations + +import datetime as dt +import json +from collections.abc import Mapping +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from . import beads + +_TARGETED_FIELD = "trycycle.targeted" +_CONTRACT_JSON_FIELD = "trycycle.contract_json" +_PLAN_STAGE_FIELD = "trycycle.plan_stage" +_APPROVED_BY_FIELD = "trycycle.approved_by" +_APPROVED_AT_FIELD = "trycycle.approved_at" +_APPROVAL_MESSAGE_ID_FIELD = "trycycle.approval_message_id" +_PLANNING_IN_REVIEW = "planning_in_review" +_APPROVED = "approved" + + +class AcceptanceCriterion(BaseModel): + """One measurable acceptance criterion for trycycle planning.""" + + model_config = ConfigDict(extra="forbid") + + statement: str = Field(min_length=1) + evidence: tuple[str, ...] = Field(min_length=1) + + @field_validator("statement") + @classmethod + def _validate_statement(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("statement must be non-empty") + return cleaned + + @field_validator("evidence") + @classmethod + def _validate_evidence(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(item.strip() for item in value if item.strip()) + if not cleaned: + raise ValueError("evidence must include at least one testable artifact") + return cleaned + + +class ScopeBoundary(BaseModel): + """Contract scope boundary describing included/excluded surfaces.""" + + model_config = ConfigDict(extra="forbid") + + includes: tuple[str, ...] = Field(min_length=1) + excludes: tuple[str, ...] = Field(min_length=1) + + @field_validator("includes", "excludes") + @classmethod + def _validate_lines(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(item.strip() for item in value if item.strip()) + if not cleaned: + raise ValueError("scope boundaries must include non-empty entries") + return cleaned + + +class RiskItem(BaseModel): + """Risk statement and mitigation plan.""" + + model_config = ConfigDict(extra="forbid") + + risk: str = Field(min_length=1) + mitigation: str = Field(min_length=1) + + @field_validator("risk", "mitigation") + @classmethod + def _validate_text(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("risk and mitigation fields must be non-empty") + return cleaned + + +class CompletionDefinition(BaseModel): + """Completion-rule configuration constrained by worker finalize semantics.""" + + model_config = ConfigDict(extra="forbid") + + requires_terminal_pr_state: bool = True + allowed_terminal_pr_states: tuple[Literal["merged", "closed"], ...] = ("merged", "closed") + allows_integrated_sha_proof: bool = True + allow_close_without_terminal_or_integrated_sha: bool = False + + @field_validator("allowed_terminal_pr_states") + @classmethod + def _validate_terminal_states( + cls, + value: tuple[Literal["merged", "closed"], ...], + ) -> tuple[Literal["merged", "closed"], ...]: + if not value: + raise ValueError("allowed_terminal_pr_states must not be empty") + return value + + +class TrycycleContract(BaseModel): + """Typed trycycle contract payload stored under ``trycycle.contract_json``.""" + + model_config = ConfigDict(extra="forbid") + + objective: str = Field(min_length=1) + non_goals: tuple[str, ...] = Field(min_length=1) + acceptance_criteria: tuple[AcceptanceCriterion, ...] = Field(min_length=1) + scope: ScopeBoundary + verification_plan: tuple[str, ...] = Field(min_length=1) + risks: tuple[RiskItem, ...] = Field(min_length=1) + escalation_conditions: tuple[str, ...] = Field(min_length=1) + completion_definition: CompletionDefinition + + @field_validator("objective") + @classmethod + def _validate_objective(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("objective must be non-empty") + return cleaned + + @field_validator("non_goals", "verification_plan", "escalation_conditions") + @classmethod + def _validate_text_arrays(cls, value: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple(item.strip() for item in value if item.strip()) + if not cleaned: + raise ValueError("list fields must include at least one non-empty item") + return cleaned + + +class ReadinessResult(BaseModel): + """Structured readiness evaluation outcome for one issue payload.""" + + model_config = ConfigDict(frozen=True) + + targeted: bool + contract_present: bool + stage: str | None + ok: bool + approved: bool + claim_eligible: bool + errors: tuple[str, ...] + claim_blockers: tuple[str, ...] + + @property + def summary(self) -> str: + """Return deterministic human-readable readiness diagnostics.""" + + diagnostics: list[str] = list(self.errors) + diagnostics.extend(blocker for blocker in self.claim_blockers if blocker not in diagnostics) + return "; ".join(diagnostics) + + +def parse_contract_json(raw_contract_json: str) -> TrycycleContract: + """Parse and validate a serialized ``trycycle.contract_json`` payload. + + Args: + raw_contract_json: Raw JSON string captured in issue metadata. + + Returns: + Parsed and validated ``TrycycleContract`` model. + + Raises: + ValueError: Raised when JSON is malformed or schema validation fails. + """ + + try: + payload = json.loads(raw_contract_json) + except json.JSONDecodeError as exc: # pragma: no cover - exercised via wrapper path + raise ValueError("trycycle.contract_json must be valid JSON") from exc + try: + return TrycycleContract.model_validate(payload) + except ValidationError as exc: # pragma: no cover - exercised via wrapper path + first_error = exc.errors(include_url=False)[0] + location = ".".join(str(part) for part in first_error.get("loc", ()) if part is not None) + detail = first_error.get("msg", "invalid contract payload") + if location: + raise ValueError(f"trycycle.contract_json invalid at {location}: {detail}") from exc + raise ValueError(f"trycycle.contract_json invalid: {detail}") from exc + + +def serialize_contract(contract: TrycycleContract) -> str: + """Serialize a validated contract for deterministic metadata persistence. + + Args: + contract: Contract model to serialize. + + Returns: + JSON string sorted by key for stable audits. + """ + + return json.dumps(contract.model_dump(mode="json"), separators=(",", ":"), sort_keys=True) + + +def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessResult: + """Evaluate planner/worker trycycle readiness for one issue payload. + + Args: + issue: Issue-like mapping containing at least an optional description. + + Returns: + Structured readiness with deterministic validation diagnostics. + """ + + description = issue.get("description") + fields = beads.parse_description_fields(description if isinstance(description, str) else "") + targeted = _parse_bool(fields.get(_TARGETED_FIELD)) + if not targeted: + return ReadinessResult( + targeted=False, + contract_present=False, + stage=None, + ok=True, + approved=False, + claim_eligible=True, + errors=(), + claim_blockers=(), + ) + + errors: list[str] = [] + claim_blockers: list[str] = [] + + stage_raw = fields.get(_PLAN_STAGE_FIELD) + stage = _normalize_field(stage_raw) + if stage not in {_PLANNING_IN_REVIEW, _APPROVED}: + errors.append( + "targeted changesets require trycycle.plan_stage set to " + "'planning_in_review' or 'approved'" + ) + + contract_raw = fields.get(_CONTRACT_JSON_FIELD) + contract_text = contract_raw.strip() if isinstance(contract_raw, str) else "" + contract_present = bool(contract_text) + contract: TrycycleContract | None = None + if not contract_present: + errors.append("targeted changesets require trycycle.contract_json") + else: + try: + contract = parse_contract_json(contract_text) + except ValueError as exc: + errors.append(str(exc)) + if contract is not None: + errors.extend(_completion_definition_conflicts(contract.completion_definition)) + + approved = stage == _APPROVED + if approved: + approval_errors = _approval_errors(fields) + errors.extend(approval_errors) + else: + claim_blockers.append( + "targeted changesets require trycycle.plan_stage=approved before worker claim" + ) + + ok = not errors + claim_eligible = ok and approved + if not claim_eligible and approved and errors: + claim_blockers.extend(errors) + + return ReadinessResult( + targeted=True, + contract_present=contract_present, + stage=stage, + ok=ok, + approved=approved, + claim_eligible=claim_eligible, + errors=tuple(errors), + claim_blockers=tuple(dict.fromkeys(claim_blockers)), + ) + + +def trycycle_claim_eligible(issue: Mapping[str, object]) -> tuple[bool, str | None]: + """Return worker-claim eligibility and rejection reason for one issue. + + Args: + issue: Issue payload to evaluate. + + Returns: + Tuple ``(eligible, reason)`` where reason is populated only for blocked + trycycle-targeted changesets. + """ + + readiness = evaluate_issue_trycycle_readiness(issue) + if not readiness.targeted or readiness.claim_eligible: + return True, None + reason = readiness.summary or "targeted changeset is not claim-eligible" + return False, reason + + +def approval_evidence_summary(issue: Mapping[str, object]) -> str: + """Render a concise audit summary for persisted approval metadata. + + Args: + issue: Issue payload that may contain trycycle approval metadata fields. + + Returns: + Human-readable approval evidence summary string. + """ + + description = issue.get("description") + fields = beads.parse_description_fields(description if isinstance(description, str) else "") + parts = [ + f"stage={_normalize_field(fields.get(_PLAN_STAGE_FIELD)) or '(missing)'}", + f"approved_by={_normalize_field(fields.get(_APPROVED_BY_FIELD)) or '(missing)'}", + f"approved_at={_normalize_field(fields.get(_APPROVED_AT_FIELD)) or '(missing)'}", + "approval_message_id=" + f"{_normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) or '(missing)'}", + ] + return "trycycle approval evidence: " + ", ".join(parts) + + +def _parse_bool(raw: str | None) -> bool: + value = _normalize_field(raw) + if value is None: + return False + return value in {"1", "true", "yes", "on"} + + +def _normalize_field(raw: str | None) -> str | None: + if raw is None: + return None + cleaned = raw.strip().lower() + if not cleaned or cleaned == "null": + return None + return cleaned + + +def _approval_errors(fields: Mapping[str, str]) -> list[str]: + errors: list[str] = [] + approved_by = _normalize_field(fields.get(_APPROVED_BY_FIELD)) + if approved_by is None: + errors.append("approved stage requires trycycle.approved_by") + approved_at_raw = _normalize_field(fields.get(_APPROVED_AT_FIELD)) + if approved_at_raw is None: + errors.append("approved stage requires trycycle.approved_at") + elif not _is_valid_iso_timestamp(approved_at_raw): + errors.append("trycycle.approved_at must be an ISO-8601 timestamp") + approval_message_id = _normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) + if approval_message_id is None: + errors.append("approved stage requires trycycle.approval_message_id") + return errors + + +def _is_valid_iso_timestamp(value: str) -> bool: + try: + dt.datetime.fromisoformat(value.replace("z", "+00:00")) + except ValueError: + return False + return True + + +def _completion_definition_conflicts(definition: CompletionDefinition) -> list[str]: + conflicts: list[str] = [] + if definition.allow_close_without_terminal_or_integrated_sha: + conflicts.append( + "completion_definition conflicts with lifecycle finalize semantics: close without " + "terminal PR state or integrated SHA proof is not allowed" + ) + if not definition.requires_terminal_pr_state and not definition.allows_integrated_sha_proof: + conflicts.append( + "completion_definition must require terminal PR state or integrated SHA proof" + ) + if definition.requires_terminal_pr_state and not definition.allowed_terminal_pr_states: + conflicts.append("completion_definition requires non-empty terminal PR states") + return conflicts + + +__all__ = [ + "AcceptanceCriterion", + "CompletionDefinition", + "ReadinessResult", + "RiskItem", + "ScopeBoundary", + "TrycycleContract", + "approval_evidence_summary", + "evaluate_issue_trycycle_readiness", + "parse_contract_json", + "serialize_contract", + "trycycle_claim_eligible", +] diff --git a/tests/atelier/test_trycycle_contract.py b/tests/atelier/test_trycycle_contract.py new file mode 100644 index 00000000..db3d06ed --- /dev/null +++ b/tests/atelier/test_trycycle_contract.py @@ -0,0 +1,129 @@ +import json + +from atelier import trycycle_contract + + +def _valid_contract_json( + *, + escalation_conditions: list[str] | None = None, + acceptance_evidence: list[str] | None = None, + completion_allow_unsafe_close: bool = False, +) -> str: + escalation = ( + ["validator disagreement"] if escalation_conditions is None else escalation_conditions + ) + evidence = ["tests"] if acceptance_evidence is None else acceptance_evidence + return ( + '{"objective":"Ship fail-closed trycycle gating",' + '"non_goals":["Do not alter non-trycycle flows"],' + '"acceptance_criteria":[{"statement":"Gate invalid changesets","evidence":' + f"{json.dumps(evidence)}" + "}]," + '"scope":{"includes":["worker startup"],"excludes":["CLI redesign"]},' + '"verification_plan":["uv run pytest tests/atelier/worker/test_session_startup.py -v"],' + '"risks":[{"risk":"Over-broad blocking","mitigation":"targeted-only gate"}],' + f'"escalation_conditions":{json.dumps(escalation)},' + '"completion_definition":{"requires_terminal_pr_state":true,' + '"allowed_terminal_pr_states":["merged","closed"],' + '"allows_integrated_sha_proof":true,' + '"allow_close_without_terminal_or_integrated_sha":' + f"{'true' if completion_allow_unsafe_close else 'false'}" + "}}" + ) + + +def test_validate_contract_accepts_complete_payload() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is True + assert result.targeted is True + assert result.claim_eligible is False + assert result.claim_blockers == ( + "targeted changesets require trycycle.plan_stage=approved before worker claim", + ) + + +def test_validate_contract_rejects_malformed_json() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + "trycycle.contract_json: {not-json}\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.contract_json must be valid JSON" in result.summary + + +def test_validate_contract_rejects_missing_escalation_conditions() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {_valid_contract_json(escalation_conditions=[])}\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "escalation_conditions" in result.summary + + +def test_validate_contract_rejects_non_testable_acceptance_criteria() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {_valid_contract_json(acceptance_evidence=[])}\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "evidence" in result.summary + + +def test_validate_contract_rejects_completion_definition_conflicts() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + "trycycle.contract_json: " + f"{_valid_contract_json(completion_allow_unsafe_close=True)}\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "completion_definition conflicts" in result.summary + + +def test_validate_contract_approved_stage_requires_full_evidence_fields() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + "trycycle.approved_by: operator\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.approved_at" in result.summary + assert "trycycle.approval_message_id" in result.summary From d4b9a289ee1c8cbd966d0874b2bc442faf2bc1b9 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:31:26 -0700 Subject: [PATCH 05/16] feat(planner): validate trycycle-ready contracts in guardrails - wire guardrail script to shared trycycle readiness validator - add deterministic contract violation coverage --- .../scripts/check_guardrails.py | 40 +++++- .../test_plan_changeset_guardrails_script.py | 118 ++++++++++++++++++ 2 files changed, 152 insertions(+), 6 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 8641f5d0..8d0305af 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.trycycle_contract import evaluate_issue_trycycle_readiness # noqa: E402 _LOC_TRIGGER = re.compile(r"\b(?:loc|estimate)\b", re.IGNORECASE) _NUMBER = re.compile(r"\b\d{2,5}\b") @@ -194,7 +195,13 @@ def _cross_cutting_corpus( if epic_issue is not None: parts.append(_text_blob(epic_issue)) parts.extend(_text_blob(issue) for issue in target_changesets) - return "\n".join(part for part in parts if part.strip()) + corpus = "\n".join(part for part in parts if part.strip()) + # Ignore raw contract metadata lines that would otherwise trip narrative checks. + return "\n".join( + line + for line in corpus.splitlines() + if not line.strip().lower().startswith("trycycle.contract_json:") + ) def _subject_id( @@ -270,9 +277,30 @@ def _evaluate_guardrails( ) -> _GuardrailReport: violations: list[str] = [] path_summary: str | None = None + seen_violations: set[str] = set() + + def _append_violation(message: str) -> None: + if message in seen_violations: + return + seen_violations.add(message) + violations.append(message) for issue in target_changesets: issue_id = _issue_id(issue) or "(unknown)" + trycycle_readiness = evaluate_issue_trycycle_readiness(issue) + if trycycle_readiness.targeted: + if not trycycle_readiness.contract_present: + _append_violation( + f"{issue_id}: targeted changesets require trycycle.contract_json." + ) + if trycycle_readiness.stage != "planning_in_review": + _append_violation( + f"{issue_id}: targeted planner payload must set " + "trycycle.plan_stage: planning_in_review." + ) + for error in trycycle_readiness.errors: + _append_violation(f"{issue_id}: {error}") + inherited_context = ( [epic_issue] if epic_issue is not None and epic_issue is not issue else [] ) @@ -285,23 +313,23 @@ def _evaluate_guardrails( field for field in missing_contract_fields if field != "done_definition" ) if missing_sections: - violations.append( + _append_violation( f"{issue_id}: missing planner authoring contract fields " f"({', '.join(missing_sections)}); add explicit key/value context to the " "epic or changeset description/notes/design." ) if "done_definition" in missing_contract_fields: - violations.append( + _append_violation( f"{issue_id}: missing explicit done definition; add acceptance criteria or " "`done_definition:` to the executable path." ) text = _text_blob(issue) estimate = _extract_loc_estimate(text) if estimate is None: - violations.append(f"{issue_id}: missing LOC estimate in notes/description.") + _append_violation(f"{issue_id}: missing LOC estimate in notes/description.") continue if estimate > 800 and not _APPROVAL.search(text): - violations.append( + _append_violation( f"{issue_id}: LOC estimate {estimate} exceeds 800 without explicit approval note." ) @@ -321,7 +349,7 @@ def _evaluate_guardrails( ) else: path_summary = f"{epic_id}: one child changeset ({child_id}) without rationale." - violations.append( + _append_violation( f"{epic_id}: one-child anti-pattern; add decomposition rationale " f"for {child_id} or keep the epic as the executable changeset." ) diff --git a/tests/atelier/skills/test_plan_changeset_guardrails_script.py b/tests/atelier/skills/test_plan_changeset_guardrails_script.py index eccde62b..c137b003 100644 --- a/tests/atelier/skills/test_plan_changeset_guardrails_script.py +++ b/tests/atelier/skills/test_plan_changeset_guardrails_script.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import subprocess import sys from pathlib import Path @@ -35,6 +36,123 @@ def _planner_contract_text() -> str: ) +def _valid_trycycle_contract_json() -> str: + return json.dumps( + { + "objective": "Ship fail-closed trycycle contract enforcement", + "non_goals": ["Do not modify non-trycycle startup semantics"], + "acceptance_criteria": [ + {"statement": "Reject missing contracts", "evidence": ["guardrails tests"]} + ], + "scope": { + "includes": ["planner guardrails"], + "excludes": ["worker PR flow redesign"], + }, + "verification_plan": ["uv run pytest tests/atelier/skills -k trycycle -v"], + "risks": [{"risk": "over-blocking", "mitigation": "targeted-only gating"}], + "escalation_conditions": ["validator disagreement"], + "completion_definition": { + "requires_terminal_pr_state": True, + "allowed_terminal_pr_states": ["merged", "closed"], + "allows_integrated_sha_proof": True, + "allow_close_without_terminal_or_integrated_sha": False, + }, + }, + separators=(",", ":"), + ) + + +def _targeted_description(*, stage: str, contract_json: str) -> str: + return ( + f"{_planner_contract_text()}\n" + "LOC estimate: 220\n" + "done_definition: Done when guardrails and worker startup reject invalid targeted work.\n" + "trycycle.targeted: true\n" + f"trycycle.plan_stage: {stage}\n" + f"trycycle.contract_json: {contract_json}\n" + ) + + +def test_guardrails_flags_trycycle_target_missing_contract() -> None: + module = _load_script_module() + child = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert any("trycycle.contract_json" in item for item in report.violations) + + +def test_guardrails_accepts_valid_targeted_trycycle_payload() -> None: + module = _load_script_module() + child = { + "id": "at-epic.1", + "description": _targeted_description( + stage="planning_in_review", + contract_json=_valid_trycycle_contract_json(), + ), + "acceptance_criteria": "Done when validation fails closed before promotion.", + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert not any("trycycle." in item for item in report.violations) + + +def test_guardrails_flags_targeted_completion_definition_conflict() -> None: + module = _load_script_module() + contract_payload = json.loads(_valid_trycycle_contract_json()) + contract_payload["completion_definition"]["allow_close_without_terminal_or_integrated_sha"] = ( + True + ) + child = { + "id": "at-epic.1", + "description": _targeted_description( + stage="planning_in_review", + contract_json=json.dumps(contract_payload, separators=(",", ":")), + ), + "acceptance_criteria": "Done when conflicts are rejected deterministically.", + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert any("completion_definition conflicts" in item for item in report.violations) + + +def test_guardrails_require_planning_in_review_for_targeted_payloads() -> None: + module = _load_script_module() + child = { + "id": "at-epic.1", + "description": _targeted_description( + stage="approved", + contract_json=_valid_trycycle_contract_json(), + ) + + "trycycle.approved_by: operator\n" + + "trycycle.approved_at: 2026-03-26T12:00:00Z\n" + + "trycycle.approval_message_id: at-msg.1\n", + "acceptance_criteria": "Done when approval metadata is persisted.", + } + + report = module._evaluate_guardrails( + epic_issue=None, + child_changesets=[], + target_changesets=[child], + ) + + assert any("planning_in_review" in item for item in report.violations) + + def test_evaluate_guardrails_accepts_single_unit_epic_path() -> None: module = _load_script_module() epic = { From 902c2a071059a4e3367e58fed0dc3edfb7e3d46b Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:36:42 -0700 Subject: [PATCH 06/16] feat(planner): require and record trycycle operator approval - block promotion when trycycle contract validation fails - persist approval metadata and planning evidence audit trail --- .../plan-promote-epic/scripts/promote_epic.py | 123 +++++++ .../skills/test_plan_promote_epic_script.py | 327 ++++++++++++++++++ 2 files changed, 450 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 3425c340..721cb842 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -5,8 +5,11 @@ import argparse import asyncio +import datetime as dt +import os import sys from pathlib import Path +from typing import Protocol _SHARED_SCRIPTS_ROOT = Path(__file__).resolve().parents[2] / "shared" / "scripts" if str(_SHARED_SCRIPTS_ROOT) not in sys.path: @@ -22,11 +25,21 @@ require_runtime_health=__name__ == "__main__", ) +from atelier import beads, trycycle_contract # noqa: E402 from atelier.beads_context import ( # noqa: E402 resolve_runtime_repo_dir_hint, resolve_skill_beads_context, ) from atelier.lib.beads import description_fields as bead_fields # noqa: E402 +from atelier.store import AppendNotesRequest, CreateMessageRequest # noqa: E402 + + +class _ApprovalStore(Protocol): + """Minimal typed boundary for trycycle approval persistence helpers.""" + + async def create_message(self, request: CreateMessageRequest) -> object: ... + + async def append_notes(self, request: AppendNotesRequest) -> object: ... def _build_store_and_client(*, beads_root: Path, repo_root: Path): @@ -188,6 +201,92 @@ def _render_issue_preview(*, header: str, issue: object) -> str: return "\n".join(lines) +def _issue_metadata_payload(issue: object) -> dict[str, object]: + return { + "id": _issue_text(issue, "id") or "", + "description": _issue_text(issue, "description") or "", + } + + +def _trycycle_validation_error(issue: object) -> str | None: + issue_id = _issue_text(issue, "id") or "(issue)" + readiness = trycycle_contract.evaluate_issue_trycycle_readiness(_issue_metadata_payload(issue)) + if readiness.targeted and not readiness.ok: + return f"{issue_id} trycycle readiness failed: {readiness.summary}" + return None + + +def _issue_thread_kind(issue_id: str): + from atelier.store import MessageThreadKind + + return MessageThreadKind.CHANGESET if "." in issue_id else MessageThreadKind.EPIC + + +def _approval_timestamp() -> str: + return ( + dt.datetime.now(tz=dt.timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _record_trycycle_approval( + *, + store: _ApprovalStore, + issue: object, + beads_root: Path, + repo_root: Path, + operator_id: str, +) -> str: + issue_id = _issue_text(issue, "id") + if issue_id is None: + raise RuntimeError("targeted trycycle issue is missing id") + + issue_payload = _issue_metadata_payload(issue) + evidence = trycycle_contract.approval_evidence_summary(issue_payload) + message = asyncio.run( + store.create_message( + CreateMessageRequest( + title=f"Trycycle approval: {issue_id}", + body=( + f"Operator {operator_id} approved trycycle promotion for {issue_id}.\n" + f"{evidence}" + ), + sender=operator_id, + thread_id=issue_id, + thread_kind=_issue_thread_kind(issue_id), + ) + ) + ) + approval_message_id = str(getattr(message, "id", "")).strip() + if not approval_message_id: + raise RuntimeError(f"{issue_id} trycycle approval message id missing") + + approved_at = _approval_timestamp() + updated = beads.update_issue_description_fields( + issue_id, + { + "trycycle.plan_stage": "approved", + "trycycle.approved_by": operator_id, + "trycycle.approved_at": approved_at, + "trycycle.approval_message_id": approval_message_id, + }, + beads_root=beads_root, + cwd=repo_root, + ) + updated_evidence = trycycle_contract.approval_evidence_summary(updated) + asyncio.run( + store.append_notes( + AppendNotesRequest( + issue_id=issue_id, + notes=(f"Trycycle approval recorded. {updated_evidence}",), + ) + ) + ) + return approval_message_id + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--epic-id", required=True, help="Deferred epic bead id") @@ -244,6 +343,7 @@ def main() -> None: ) preview_blocks = [_render_issue_preview(header=f"EPIC {epic_id}", issue=epic_issue)] child_missing: dict[str, tuple[str, ...]] = {} + child_issues_by_id = {str(getattr(issue, "id")): issue for issue in child_issues} promotable_children: list[str] = [] for record, issue in zip(changesets, child_issues, strict=True): preview_blocks.append( @@ -267,6 +367,15 @@ def main() -> None: problems.append( "incomplete child changesets remain deferred: " + ", ".join(incomplete_children) ) + validation_targets = tuple(child_issues) if changesets else (epic_issue,) + executable_targets = ( + tuple(child_issues_by_id[issue_id] for issue_id in promotable_children) + if promotable_children + else ((epic_issue,) if not changesets and not epic_missing else ()) + ) + for issue in validation_targets: + if (trycycle_error := _trycycle_validation_error(issue)) is not None: + problems.append(trycycle_error) if problems: raise RuntimeError("; ".join(problems)) @@ -296,6 +405,20 @@ def main() -> None: ) ) promoted_children.append(child_id) + operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "operator").strip() or "operator" + approval_targets = list(executable_targets) + for issue in approval_targets: + readiness = trycycle_contract.evaluate_issue_trycycle_readiness( + _issue_metadata_payload(issue) + ) + if readiness.targeted: + _record_trycycle_approval( + store=store, + issue=issue, + beads_root=beads_root, + repo_root=repo_root, + operator_id=operator_id, + ) except Exception as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 780e4da0..6cd89ee4 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import sys from pathlib import Path from types import SimpleNamespace @@ -47,6 +48,332 @@ def _issue( ) +def _valid_trycycle_contract_json() -> str: + return json.dumps( + { + "objective": "Enforce fail-closed trycycle promotion gate", + "non_goals": ["Do not alter non-trycycle promotion"], + "acceptance_criteria": [ + {"statement": "Reject invalid targeted payloads", "evidence": ["pytest"]} + ], + "scope": { + "includes": ["plan-promote-epic"], + "excludes": ["worker runtime redesign"], + }, + "verification_plan": ["uv run pytest tests/atelier/skills -k trycycle -v"], + "risks": [{"risk": "over-blocking", "mitigation": "targeted-only checks"}], + "escalation_conditions": ["validator disagreement"], + "completion_definition": { + "requires_terminal_pr_state": True, + "allowed_terminal_pr_states": ["merged", "closed"], + "allows_integrated_sha_proof": True, + "allow_close_without_terminal_or_integrated_sha": False, + }, + }, + separators=(",", ":"), + ) + + +def _targeted_description(*, contract_json: str, stage: str = "planning_in_review") -> str: + return ( + "related_context: at-context\n" + "changeset_strategy: Keep review scope small.\n" + "trycycle.targeted: true\n" + f"trycycle.plan_stage: {stage}\n" + f"trycycle.contract_json: {contract_json}\n" + ) + + +def test_promote_epic_blocks_trycycle_target_without_valid_contract( + 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.\n" + "related_context: at-context\n" + "promotion_note: ready for confirmation\n" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=_targeted_description(contract_json="{bad-json}"), + ) + + 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 "trycycle readiness failed" in capsys.readouterr().err + + +def test_promote_epic_records_trycycle_approval_metadata_for_child_changeset( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + transitions: list[object] = [] + created_messages: list[object] = [] + metadata_updates: dict[str, dict[str, str | None]] = {} + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + monkeypatch.setenv("ATELIER_AGENT_ID", "atelier/planner/codex/p1") + + 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" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + notes="child notes", + ) + + 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 create_message(self, request): + created_messages.append(request) + return SimpleNamespace(id="at-msg.1") + + async def append_notes(self, request): + return request + + 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": child_issue}[request.issue_id] + + def _record_metadata( + issue_id: str, + fields: dict[str, str | None], + *, + beads_root: Path, + cwd: Path, + ) -> dict[str, object]: + del beads_root, cwd + metadata_updates[issue_id] = fields + return {"id": issue_id} + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(module.beads, "update_issue_description_fields", _record_metadata) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) + + module.main() + + assert [request.issue_id for request in transitions] == ["at-epic", "at-epic.1"] + assert created_messages + assert metadata_updates["at-epic.1"]["trycycle.plan_stage"] == "approved" + assert metadata_updates["at-epic.1"]["trycycle.approved_by"] == "atelier/planner/codex/p1" + assert metadata_updates["at-epic.1"]["trycycle.approval_message_id"] == "at-msg.1" + assert metadata_updates["at-epic.1"]["trycycle.approved_at"] is not None + + +def test_promote_epic_records_trycycle_approval_metadata_for_epic_single_unit( + monkeypatch, + tmp_path: Path, +) -> None: + module = _load_script_module() + created_messages: list[object] = [] + metadata_updates: dict[str, dict[str, str | None]] = {} + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + monkeypatch.setenv("ATELIER_AGENT_ID", "atelier/planner/codex/p1") + + epic_issue = _issue( + "at-epic", + title="Epic", + description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + notes="epic notes", + ) + + 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 create_message(self, request): + created_messages.append(request) + return SimpleNamespace(id="at-msg.2") + + async def append_notes(self, request): + return request + + async def transition_lifecycle(self, request): + return request + + class FakeClient: + async def show(self, request): + assert request.issue_id == "at-epic" + return epic_issue + + def _record_metadata( + issue_id: str, + fields: dict[str, str | None], + *, + beads_root: Path, + cwd: Path, + ) -> dict[str, object]: + del beads_root, cwd + metadata_updates[issue_id] = fields + return {"id": issue_id} + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr(module.beads, "update_issue_description_fields", _record_metadata) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) + + module.main() + + assert created_messages + assert metadata_updates["at-epic"]["trycycle.plan_stage"] == "approved" + assert metadata_updates["at-epic"]["trycycle.approval_message_id"] == "at-msg.2" + + +def test_promote_epic_non_targeted_changesets_do_not_write_trycycle_approval( + 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" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description="related_context: at-context\n", + notes="child notes", + ) + + 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 create_message(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + async def append_notes(self, request): # pragma: no cover - defensive + raise AssertionError(request) + + 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": child_issue}[request.issue_id] + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr( + module.beads, + "update_issue_description_fields", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError((args, kwargs))), + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) + + module.main() + + assert [request.issue_id for request in transitions] == ["at-epic", "at-epic.1"] + + def test_promote_epic_preview_requires_confirmation(monkeypatch, capsys, tmp_path: Path) -> None: module = _load_script_module() transitions: list[object] = [] From c69b9d12a038b6c4c68bc55225ea6041e560987f Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:42:31 -0700 Subject: [PATCH 07/16] feat(worker): fail closed on non-approved trycycle changesets - add startup claim gate for trycycle contract readiness across all claim paths - preserve existing non-trycycle claim behavior --- src/atelier/worker/session/startup.py | 108 +++++++++-- src/atelier/worker/work_startup_runtime.py | 29 ++- tests/atelier/worker/test_lifecycle_matrix.py | 106 ++++++++++- .../worker/test_session_next_changeset.py | 27 +++ tests/atelier/worker/test_session_startup.py | 180 ++++++++++++++++++ .../worker/test_work_startup_runtime.py | 40 ++++ 6 files changed, 467 insertions(+), 23 deletions(-) diff --git a/src/atelier/worker/session/startup.py b/src/atelier/worker/session/startup.py index 732d8eb1..8039bcee 100644 --- a/src/atelier/worker/session/startup.py +++ b/src/atelier/worker/session/startup.py @@ -86,6 +86,11 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: ... + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: ... + def _issue_id(issue: dict[str, object]) -> str | None: issue_id = issue.get("id") @@ -229,6 +234,10 @@ def review_resume_allowed(issue: dict[str, object]) -> bool: git_path=context.git_path, ) + def trycycle_eligible(issue: dict[str, object]) -> bool: + eligible, _reason = service.trycycle_claim_eligible(issue) + return eligible + target = service.show_issue(context.epic_id) if target: issue = target @@ -280,7 +289,9 @@ def review_resume_allowed(issue: dict[str, object]) -> bool: or target_recovery_candidate ) ): - if not service.has_open_descendant_changesets(context.epic_id): + if not service.has_open_descendant_changesets(context.epic_id) and trycycle_eligible( + issue + ): return issue if ( isinstance(issue_id, str) @@ -292,7 +303,9 @@ def review_resume_allowed(issue: dict[str, object]) -> bool: return None if isinstance(issue_id, str) and issue_id == context.epic_id and claimability.role.is_epic: if not explicit_descendants: - return issue + if trycycle_eligible(issue): + return issue + return None descendants = service.list_descendant_changesets(context.epic_id, include_closed=False) descendants_by_id = { @@ -335,6 +348,8 @@ def review_resume_allowed(issue: dict[str, object]) -> bool: ), ) for issue in prioritized: + if not trycycle_eligible(issue): + continue issue_id = issue.get("id") if isinstance(issue_id, str) and issue_id: if not service.has_open_descendant_changesets(issue_id): @@ -485,6 +500,11 @@ def emit(self, message: str) -> None: ... def die(self, message: str) -> None: ... + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: ... + def run_startup_contract_service( *, context: StartupContractContext, service: StartupContractService @@ -520,6 +540,21 @@ def stage_call(stage: str, callback: Callable[[], _StageResult]) -> _StageResult ) return result + def trycycle_selection_allowed( + *, + changeset_id: str, + stage: str, + ) -> bool: + eligible, reason = service.trycycle_claim_eligible({"id": changeset_id}) + if eligible: + return True + detail = reason or "targeted changeset is not trycycle claim-eligible" + service.emit(f"Skipping {stage} changeset {changeset_id}: {detail}") + atelier_log.warning( + f"startup skipping {stage} changeset={changeset_id} reason=trycycle_ineligible" + ) + return False + """Apply startup-contract skill ordering to select the next epic.""" selected_epic: str | None = None if explicit_epic_id is not None: @@ -612,13 +647,17 @@ def stage_call(stage: str, callback: Callable[[], _StageResult]) -> _StageResult ), ) if explicit_conflict is not None: - return StartupContractResult( - epic_id=explicit_conflict.epic_id, + if trycycle_selection_allowed( changeset_id=explicit_conflict.changeset_id, - should_exit=False, - reason="merge_conflict", - reassign_from=explicit_reassign_from, - ) + stage="explicit merge-conflict", + ): + return StartupContractResult( + epic_id=explicit_conflict.epic_id, + changeset_id=explicit_conflict.changeset_id, + should_exit=False, + reason="merge_conflict", + reassign_from=explicit_reassign_from, + ) explicit_feedback = stage_call( "explicit.review-feedback", lambda: service.select_review_feedback_changeset( @@ -627,13 +666,17 @@ def stage_call(stage: str, callback: Callable[[], _StageResult]) -> _StageResult ), ) if explicit_feedback is not None: - return StartupContractResult( - epic_id=explicit_feedback.epic_id, + if trycycle_selection_allowed( changeset_id=explicit_feedback.changeset_id, - should_exit=False, - reason="review_feedback", - reassign_from=explicit_reassign_from, - ) + stage="explicit review-feedback", + ): + return StartupContractResult( + epic_id=explicit_feedback.epic_id, + changeset_id=explicit_feedback.changeset_id, + should_exit=False, + reason="review_feedback", + reassign_from=explicit_reassign_from, + ) explicit_next_changeset = stage_call( "explicit.next-changeset", lambda: service.next_changeset( @@ -644,6 +687,13 @@ def stage_call(stage: str, callback: Callable[[], _StageResult]) -> _StageResult resume_review=resume_review, ), ) + if explicit_next_changeset is not None: + explicit_issue_id = _issue_id(explicit_next_changeset) + if explicit_issue_id is not None and not trycycle_selection_allowed( + changeset_id=explicit_issue_id, + stage="explicit next-changeset", + ): + explicit_next_changeset = None if explicit_next_changeset is None: if status in {"in_progress", "hooked"}: service.emit( @@ -863,9 +913,13 @@ def select_conflict_candidate( ), ) if selection is not None: - if select_first_eligible: - return selection - conflict_candidates.append(selection) + if trycycle_selection_allowed( + changeset_id=selection.changeset_id, + stage="merge-conflict", + ): + if select_first_eligible: + return selection + conflict_candidates.append(selection) if not conflict_candidates: return None conflict_candidates.sort( @@ -918,9 +972,13 @@ def select_feedback_candidate( ), ) if feedback_selection is not None: - if select_first_eligible: - return feedback_selection - feedback_candidates.append(feedback_selection) + if trycycle_selection_allowed( + changeset_id=feedback_selection.changeset_id, + stage="review-feedback", + ): + if select_first_eligible: + return feedback_selection + feedback_candidates.append(feedback_selection) if not feedback_candidates: return None feedback_candidates.sort( @@ -1016,6 +1074,11 @@ def resume_feedback(selection: ReviewFeedbackSelection) -> StartupContractResult global_conflict.epic_id, stage="global-merge-conflict" ) or is_excluded(global_conflict.epic_id, stage="global-merge-conflict"): global_conflict = None + elif not trycycle_selection_allowed( + changeset_id=global_conflict.changeset_id, + stage="global merge-conflict", + ): + global_conflict = None if global_conflict is not None: return resume_conflict(global_conflict) feedback = select_feedback_candidate(unhooked_epics) @@ -1032,6 +1095,11 @@ def resume_feedback(selection: ReviewFeedbackSelection) -> StartupContractResult global_feedback = None elif not is_claimable(global_feedback.epic_id, stage="global-review-feedback"): global_feedback = None + elif not trycycle_selection_allowed( + changeset_id=global_feedback.changeset_id, + stage="global review-feedback", + ): + global_feedback = None if global_feedback is not None: return resume_feedback(global_feedback) diff --git a/src/atelier/worker/work_startup_runtime.py b/src/atelier/worker/work_startup_runtime.py index 0e07b3db..4ba6e73d 100644 --- a/src/atelier/worker/work_startup_runtime.py +++ b/src/atelier/worker/work_startup_runtime.py @@ -6,7 +6,7 @@ from collections.abc import Callable from pathlib import Path -from .. import agent_home, beads, changeset_fields, prs, work_feedback +from .. import agent_home, beads, changeset_fields, prs, trycycle_contract, work_feedback from ..io import die, prompt, say, select from ..work_feedback import ReviewFeedbackSnapshot from ..worker import prompts as worker_prompts @@ -166,6 +166,10 @@ def startup_finalize_preflight( ) +def _trycycle_claim_eligibility(issue: dict[str, object]) -> tuple[bool, str | None]: + return trycycle_contract.trycycle_claim_eligible(issue) + + class _NextChangesetService(worker_startup.NextChangesetService): """Concrete next-changeset service implementation for worker startup.""" @@ -277,6 +281,23 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return is_changeset_in_progress(issue) + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: + payload = issue + if not isinstance(issue.get("description"), str): + issue_id = issue.get("id") + if isinstance(issue_id, str) and issue_id.strip(): + hydrated = worker_store.show_issue( + issue_id, + beads_root=self._beads_root, + repo_root=self._repo_root, + ) + if hydrated is not None: + payload = hydrated + return _trycycle_claim_eligibility(payload) + def _no_eligible_epics_summary( *, @@ -914,6 +935,12 @@ def select_global_review_feedback_changeset( ) -> ReviewFeedbackSelection | None: return self._global_startup_candidates(repo_slug=repo_slug).feedback + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: + return _trycycle_claim_eligibility(issue) + def check_inbox_before_claim(self, agent_id: str) -> bool: return check_inbox_before_claim( agent_id, beads_root=self._beads_root, repo_root=self._repo_root diff --git a/tests/atelier/worker/test_lifecycle_matrix.py b/tests/atelier/worker/test_lifecycle_matrix.py index 245c82b7..216399ad 100644 --- a/tests/atelier/worker/test_lifecycle_matrix.py +++ b/tests/atelier/worker/test_lifecycle_matrix.py @@ -1,19 +1,27 @@ from __future__ import annotations import datetime as dt +import json +from collections.abc import Callable from pathlib import Path from unittest.mock import patch import pytest -from atelier import config, lifecycle, planner_overview +from atelier import config, lifecycle, planner_overview, trycycle_contract from atelier.worker import finalize_pipeline, reconcile, selection from atelier.worker.session import startup class _NextChangesetMatrixService(startup.NextChangesetService): - def __init__(self, issue: dict[str, object]) -> None: + def __init__( + self, + issue: dict[str, object], + *, + claim_eligibility: Callable[[dict[str, object]], tuple[bool, str | None]] | None = None, + ) -> None: self._issue = issue + self._claim_eligibility = claim_eligibility or (lambda _issue: (True, None)) def show_issue(self, issue_id: str) -> dict[str, object] | None: if issue_id == "at-epic": @@ -69,6 +77,12 @@ def list_descendant_changesets( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return lifecycle.canonical_lifecycle_status(issue.get("status")) == "in_progress" + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: + return self._claim_eligibility(issue) + class _FinalizeMatrixService(finalize_pipeline.FinalizePipelineService): def __init__(self) -> None: @@ -351,3 +365,91 @@ def test_lifecycle_matrix_reconcile_ignores_terminal_labels_on_active_status() - ) assert candidates == {"at-1": ["at-1.7"]} + + +def _valid_trycycle_contract_json() -> str: + return json.dumps( + { + "objective": "Protect worker startup from unapproved targeted claims", + "non_goals": ["Do not alter non-trycycle flows"], + "acceptance_criteria": [ + {"statement": "Skip unapproved targeted work", "evidence": ["startup tests"]} + ], + "scope": { + "includes": ["worker startup"], + "excludes": ["planner UX redesign"], + }, + "verification_plan": ["uv run pytest tests/atelier/worker -k trycycle -v"], + "risks": [{"risk": "over-blocking", "mitigation": "targeted-only checks"}], + "escalation_conditions": ["validator disagreement"], + "completion_definition": { + "requires_terminal_pr_state": True, + "allowed_terminal_pr_states": ["merged", "closed"], + "allows_integrated_sha_proof": True, + "allow_close_without_terminal_or_integrated_sha": False, + }, + }, + separators=(",", ":"), + ) + + +def test_lifecycle_matrix_trycycle_unapproved_target_is_not_selected() -> None: + issue = { + "id": "at-epic", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {_valid_trycycle_contract_json()}\n" + ), + } + startup_context = startup.NextChangesetContext( + epic_id="at-epic", + repo_slug=None, + branch_pr=False, + git_path=None, + ) + + selected = startup.next_changeset_service( + context=startup_context, + service=_NextChangesetMatrixService( + issue, claim_eligibility=trycycle_contract.trycycle_claim_eligible + ), + ) + + assert selected is None + + +def test_lifecycle_matrix_trycycle_approved_target_is_selected() -> None: + issue = { + "id": "at-epic", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_trycycle_contract_json()}\n" + "trycycle.approved_by: atelier/planner/codex/p1\n" + "trycycle.approved_at: 2026-03-26T18:00:00Z\n" + "trycycle.approval_message_id: at-msg.1\n" + ), + } + startup_context = startup.NextChangesetContext( + epic_id="at-epic", + repo_slug=None, + branch_pr=False, + git_path=None, + ) + + selected = startup.next_changeset_service( + context=startup_context, + service=_NextChangesetMatrixService( + issue, claim_eligibility=trycycle_contract.trycycle_claim_eligible + ), + ) + + assert selected is not None + assert selected["id"] == "at-epic" diff --git a/tests/atelier/worker/test_session_next_changeset.py b/tests/atelier/worker/test_session_next_changeset.py index efb3179e..ded48ef2 100644 --- a/tests/atelier/worker/test_session_next_changeset.py +++ b/tests/atelier/worker/test_session_next_changeset.py @@ -16,6 +16,7 @@ def __init__( waiting_by_id: dict[str, bool] | None = None, integrated_by_id: dict[str, bool] | None = None, work_children_by_id: dict[str, list[dict[str, object]]] | None = None, + trycycle_eligibility_by_id: dict[str, tuple[bool, str | None]] | None = None, ) -> None: self._issues_by_id = issues_by_id self._ready_changesets = ready_changesets @@ -24,6 +25,7 @@ def __init__( self._waiting_by_id = waiting_by_id or {} self._integrated_by_id = integrated_by_id or {} self._work_children_by_id = work_children_by_id or {} + self._trycycle_eligibility_by_id = trycycle_eligibility_by_id or {} def show_issue(self, issue_id: str) -> dict[str, object] | None: return self._issues_by_id.get(issue_id) @@ -120,6 +122,15 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return str(issue.get("status") or "").strip().lower() == "in_progress" + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: + issue_id = issue.get("id") + if not isinstance(issue_id, str): + return True, None + return self._trycycle_eligibility_by_id.get(issue_id, (True, None)) + def _changeset( issue_id: str, @@ -623,3 +634,19 @@ def test_next_changeset_service_rehydrates_sparse_ready_candidates() -> None: assert selected is not None assert selected["id"] == "at-epic.1" + + +def test_next_changeset_service_skips_unapproved_trycycle_changeset() -> None: + blocked = _changeset("at-epic.1", status="open", work_branch="feat/at-epic.1") + allowed = _changeset("at-epic.2", status="open", work_branch="feat/at-epic.2") + service = FakeNextChangesetService( + issues_by_id={"at-epic": _epic(), blocked["id"]: blocked, allowed["id"]: allowed}, + ready_changesets=[], + descendants=[blocked, allowed], + trycycle_eligibility_by_id={"at-epic.1": (False, "missing trycycle approval")}, + ) + + selected = startup.next_changeset_service(context=_context(), service=service) + + assert selected is not None + assert selected["id"] == "at-epic.2" diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index ef6ad5e0..082b8b4c 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -69,6 +69,9 @@ def __init__(self, **overrides: Any) -> None: self._select_global_review_feedback_changeset = overrides.pop( "select_global_review_feedback_changeset", lambda **_kwargs: None ) + self._trycycle_claim_eligible = overrides.pop( + "trycycle_claim_eligible", lambda _issue: (True, None) + ) self._check_inbox_before_claim = overrides.pop( "check_inbox_before_claim", lambda *_args: False ) @@ -217,6 +220,12 @@ def select_global_review_feedback_changeset( ) -> ReviewFeedbackSelection | None: return self._select_global_review_feedback_changeset(repo_slug=repo_slug) + def trycycle_claim_eligible( + self, + issue: dict[str, object], + ) -> tuple[bool, str | None]: + return self._trycycle_claim_eligible(issue) + def check_inbox_before_claim(self, agent_id: str) -> bool: return self._check_inbox_before_claim(agent_id) @@ -412,6 +421,94 @@ def test_run_startup_contract_explicit_epic_prioritizes_merge_conflict() -> None assert result.changeset_id == "at-explicit" +def test_run_startup_contract_explicit_epic_rejects_unapproved_trycycle_next_changeset() -> None: + emitted: list[str] = [] + + result = _run_startup( + explicit_epic_id="at-explicit", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-explicit" else [], + "issue_type": "task" if issue_id != "at-explicit" else "epic", + }, + next_changeset=lambda **_kwargs: {"id": "at-explicit.1", "status": "open", "labels": []}, + trycycle_claim_eligible=lambda issue: ( + (False, "missing trycycle approval") + if issue.get("id") == "at-explicit.1" + else (True, None) + ), + emit=lambda message: emitted.append(message), + ) + + assert result.should_exit is True + assert result.reason == "explicit_epic_not_actionable" + assert any("missing trycycle approval" in message for message in emitted) + + +def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_trycycle() -> None: + feedback = ReviewFeedbackSelection( + epic_id="at-explicit", + changeset_id="at-explicit.1", + feedback_at="2026-02-20T00:00:00Z", + ) + emitted: list[str] = [] + + result = _run_startup( + explicit_epic_id="at-explicit", + branch_pr=True, + repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-explicit" else [], + }, + select_review_feedback_changeset=lambda **_kwargs: feedback, + next_changeset=lambda **_kwargs: None, + trycycle_claim_eligible=lambda issue: ( + (False, "missing trycycle approval") + if issue.get("id") == "at-explicit.1" + else (True, None) + ), + emit=lambda message: emitted.append(message), + ) + + assert result.reason == "explicit_epic_not_actionable" + assert any("missing trycycle approval" in message for message in emitted) + + +def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycle() -> None: + conflict = MergeConflictSelection( + epic_id="at-explicit", + changeset_id="at-explicit.1", + observed_at="2026-02-20T00:00:00Z", + pr_url="https://github.com/org/repo/pull/110", + ) + emitted: list[str] = [] + + result = _run_startup( + explicit_epic_id="at-explicit", + branch_pr=True, + repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-explicit" else [], + }, + select_conflicted_changeset=lambda **_kwargs: conflict, + next_changeset=lambda **_kwargs: None, + trycycle_claim_eligible=lambda issue: ( + (False, "missing trycycle approval") + if issue.get("id") == "at-explicit.1" + else (True, None) + ), + emit=lambda message: emitted.append(message), + ) + + assert result.reason == "explicit_epic_not_actionable" + assert any("missing trycycle approval" in message for message in emitted) + + def test_run_startup_contract_explicit_epic_completed_exits_cleanly() -> None: emitted: list[str] = [] result = _run_startup( @@ -1256,6 +1353,89 @@ def test_run_startup_contract_skips_unclaimable_global_review_feedback() -> None assert result.epic_id == "at-claimable" +def test_run_startup_contract_global_review_feedback_rejects_unapproved_trycycle() -> None: + blocked_feedback = ReviewFeedbackSelection( + epic_id="at-blocked", + changeset_id="at-blocked.1", + feedback_at="2026-02-19T00:00:00Z", + ) + emitted: list[str] = [] + + result = _run_startup( + branch_pr=True, + repo_slug="org/repo", + list_epics=lambda: [ + { + "id": "at-claimable", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "created_at": "2026-02-21T00:00:00Z", + } + ], + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-blocked" else [], + }, + next_changeset=lambda **kwargs: {"id": f"{kwargs['epic_id']}.1"}, + select_review_feedback_changeset=lambda **_kwargs: None, + select_global_review_feedback_changeset=lambda **_kwargs: blocked_feedback, + trycycle_claim_eligible=lambda issue: ( + (False, "missing trycycle approval") + if issue.get("id") == "at-blocked.1" + else (True, None) + ), + emit=lambda message: emitted.append(message), + ) + + assert result.reason == "selected_auto" + assert result.epic_id == "at-claimable" + assert any("missing trycycle approval" in message for message in emitted) + + +def test_run_startup_contract_global_merge_conflict_rejects_unapproved_trycycle() -> None: + blocked_conflict = MergeConflictSelection( + epic_id="at-blocked", + changeset_id="at-blocked.1", + observed_at="2026-02-19T00:00:00Z", + pr_url="https://github.com/org/repo/pull/404", + ) + emitted: list[str] = [] + + result = _run_startup( + branch_pr=True, + repo_slug="org/repo", + list_epics=lambda: [ + { + "id": "at-claimable", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "created_at": "2026-02-21T00:00:00Z", + } + ], + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-blocked" else [], + }, + next_changeset=lambda **kwargs: {"id": f"{kwargs['epic_id']}.1"}, + select_conflicted_changeset=lambda **_kwargs: None, + select_global_conflicted_changeset=lambda **_kwargs: blocked_conflict, + trycycle_claim_eligible=lambda issue: ( + (False, "missing trycycle approval") + if issue.get("id") == "at-blocked.1" + else (True, None) + ), + emit=lambda message: emitted.append(message), + ) + + assert result.reason == "selected_auto" + assert result.epic_id == "at-claimable" + assert any("missing trycycle approval" in message for message in emitted) + + def test_run_startup_contract_claims_global_feedback_standalone_identity() -> None: standalone_feedback = ReviewFeedbackSelection( epic_id="at-bmu", diff --git a/tests/atelier/worker/test_work_startup_runtime.py b/tests/atelier/worker/test_work_startup_runtime.py index 48228317..91dcf106 100644 --- a/tests/atelier/worker/test_work_startup_runtime.py +++ b/tests/atelier/worker/test_work_startup_runtime.py @@ -146,3 +146,43 @@ def _raise_on_raw_beads(*_args, **_kwargs): service.update_changeset_integrated_sha("at-epic.1", "abc1234") assert calls == [("at-epic.1", "abc1234", Path("/beads"), Path("/repo"), True)] + + +def test_next_changeset_service_trycycle_eligibility_uses_shared_helper(monkeypatch) -> None: + calls: list[dict[str, object]] = [] + issue = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + + monkeypatch.setattr( + work_startup_runtime, + "_trycycle_claim_eligibility", + lambda candidate: (calls.append(candidate), (False, "blocked"))[1], + ) + service = work_startup_runtime._NextChangesetService( # pyright: ignore[reportPrivateUsage] + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + eligible, reason = service.trycycle_claim_eligible(issue) + + assert (eligible, reason) == (False, "blocked") + assert calls == [issue] + + +def test_startup_contract_service_trycycle_eligibility_uses_shared_helper(monkeypatch) -> None: + calls: list[dict[str, object]] = [] + issue = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + + monkeypatch.setattr( + work_startup_runtime, + "_trycycle_claim_eligibility", + lambda candidate: (calls.append(candidate), (True, None))[1], + ) + service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + eligible, reason = service.trycycle_claim_eligible(issue) + + assert (eligible, reason) == (True, None) + assert calls == [issue] From 2d174fb283e2ecc81f9481842f1f7c741076786a Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:45:17 -0700 Subject: [PATCH 08/16] docs(planner): codify trycycle-ready approval workflow - align planner template and skill docs with trycycle gating contract - update bootstrap and docs coverage for new shared contract usage --- docs/behavior.md | 18 ++++++++ .../skills/plan-changeset-guardrails/SKILL.md | 7 +++ src/atelier/skills/plan-changesets/SKILL.md | 6 +++ src/atelier/skills/plan-promote-epic/SKILL.md | 13 ++++++ src/atelier/templates/AGENTS.planner.md.tmpl | 12 +++++ .../test_projected_skill_runtime_bootstrap.py | 44 +++++++++++++++++++ tests/atelier/test_planner_agents_template.py | 5 +++ tests/atelier/test_skills.py | 8 ++++ 8 files changed, 113 insertions(+) diff --git a/docs/behavior.md b/docs/behavior.md index 61b30fbb..bd66620c 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -133,6 +133,24 @@ documented in `docs/beads-prefix-migration.md`. Repository-local Beads stores (for example `/.beads`) are not used for Atelier planning state. They are treated as external ticket sources. +## Trycycle-targeted readiness contract + +- A changeset is trycycle-targeted only when metadata includes + `trycycle.targeted: true`. +- Targeted changesets must include a typed `trycycle.contract_json` payload. +- Planner guardrails fail closed when targeted contract data is missing, + malformed, or conflicts with finalize semantics. +- Targeted planning remains in review with + `trycycle.plan_stage: planning_in_review` until operator approval. +- Promotion approval persists auditable metadata: + `trycycle.plan_stage=approved`, `trycycle.approved_by`, + `trycycle.approved_at`, and `trycycle.approval_message_id`. +- Worker startup applies the same shared validator across next-changeset, + explicit review-feedback, merge-conflict, and global selector paths. +- Startup claim selection fails closed for targeted changesets that are not + approved or have invalid contract evidence. +- Non-targeted changesets keep existing startup/promotion behavior. + ## Command behavior (high level) - `atelier init` diff --git a/src/atelier/skills/plan-changeset-guardrails/SKILL.md b/src/atelier/skills/plan-changeset-guardrails/SKILL.md index 663e89a7..d3c3a58d 100644 --- a/src/atelier/skills/plan-changeset-guardrails/SKILL.md +++ b/src/atelier/skills/plan-changeset-guardrails/SKILL.md @@ -35,6 +35,11 @@ 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. +- For trycycle-targeted executable units, require + `trycycle.contract_json` and `trycycle.plan_stage: planning_in_review`. +- Surface deterministic errors from the shared trycycle validator, including + malformed JSON, missing required payload fields, and completion-definition + lifecycle conflicts. ## Steps @@ -52,6 +57,8 @@ description: >- - If a large estimate is found (>800), ensure approval is recorded. - When lifecycle/contract invariant terms are present, verify the invariant impact map and re-split guidance fields are present. + - If `trycycle.targeted: true`, run shared trycycle validation and require + `trycycle.plan_stage: planning_in_review` before promotion. 1. If an epic has exactly one child changeset, require explicit decomposition rationale in the epic or child notes/description. 1. Summarize any violations and send a message to the planner/overseer with diff --git a/src/atelier/skills/plan-changesets/SKILL.md b/src/atelier/skills/plan-changesets/SKILL.md index 1d524614..a9e7e473 100644 --- a/src/atelier/skills/plan-changesets/SKILL.md +++ b/src/atelier/skills/plan-changesets/SKILL.md @@ -31,6 +31,12 @@ create/edit deferred work. - Ensure the executable path for each changeset (the child plus inherited epic context) explicitly records `intent`, `rationale`, `non_goals`, `constraints`, `edge_cases`, `related_context`, and a done definition. +- When a changeset is trycycle-targeted, record: + - `trycycle.targeted: true` + - `trycycle.contract_json: ` + - `trycycle.plan_stage: planning_in_review` +- Do not mark a trycycle-targeted changeset runnable until explicit promotion + approval records `trycycle.plan_stage: approved` and approval metadata. - Shared context can live on the epic; child changesets should add only the delta needed for worker execution. - For lifecycle/contract invariant bugs, record an upfront invariant impact map diff --git a/src/atelier/skills/plan-promote-epic/SKILL.md b/src/atelier/skills/plan-promote-epic/SKILL.md index 4513687a..fb4a3591 100644 --- a/src/atelier/skills/plan-promote-epic/SKILL.md +++ b/src/atelier/skills/plan-promote-epic/SKILL.md @@ -34,6 +34,12 @@ description: >- - Do not require child changesets when the epic itself is guardrail-sized. - If the epic has exactly one child changeset, explicit decomposition rationale must be recorded before promotion. +- For trycycle-targeted executable units (`trycycle.targeted: true`), contract + validation must pass before promotion. Required planning-stage metadata: + - `trycycle.contract_json` + - `trycycle.plan_stage: planning_in_review` +- Worker startup fails closed for targeted units unless approval evidence is + recorded. ## Steps @@ -83,6 +89,13 @@ description: >- 1. Promote each fully-defined child changeset from `deferred` to `open` regardless of current dependency blockers (dependency graph still gates runnability). +1. For each promoted trycycle-targeted executable unit, persist approval + evidence: + - `trycycle.plan_stage: approved` + - `trycycle.approved_by` + - `trycycle.approved_at` + - `trycycle.approval_message_id` + and record an approval evidence note/thread message. 1. Let dependency resolution determine runnability (`bd ready`) at worker time. ## Verification diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index df5044ad..632768af 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -159,6 +159,11 @@ If code changes are needed, create beads and leave implementation to workers. - Capture new executable work as deferred changesets first, then promote when fully defined. - Record estimated size and any approval notes. +- For trycycle-targeted work, set `trycycle.targeted: true` and include a + typed `trycycle.contract_json` payload on the executable unit. +- Keep targeted plans in review with + `trycycle.plan_stage: planning_in_review` until explicit operator approval. +- Do not promote targeted work unless guardrails validate the contract payload. 4. External tickets: - {{ external_auto_export_guidance }} @@ -180,6 +185,13 @@ If code changes are needed, create beads and leave implementation to workers. before you ask for confirmation. - Do not ask for promotion while ambiguity remains. Resolve it in the bead or capture the operator's explicit decision about the remaining uncertainty. +- For trycycle-targeted executable units, promotion approval must persist: + - `trycycle.plan_stage: approved` + - `trycycle.approved_by` + - `trycycle.approved_at` + - `trycycle.approval_message_id` +- Worker startup must fail closed on targeted work that is missing contract + data or approval evidence. ## Bead Quality Standard diff --git a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py index 0cb51eb7..f434c2b5 100644 --- a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py +++ b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py @@ -583,6 +583,17 @@ def test_projected_check_guardrails_reorders_repo_src_ahead_of_installed_package "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), + "trycycle_contract.py": ( + "class _Readiness:\n" + " def __init__(self):\n" + " self.targeted = False\n" + " self.contract_present = False\n" + " self.stage = None\n" + " self.errors = ()\n" + "\n" + "def evaluate_issue_trycycle_readiness(_issue):\n" + " return _Readiness()\n" + ), }, ) installed_root = _fake_installed_package( @@ -610,6 +621,17 @@ def test_projected_check_guardrails_reorders_repo_src_ahead_of_installed_package "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), + "trycycle_contract.py": ( + "class _Readiness:\n" + " def __init__(self):\n" + " self.targeted = False\n" + " self.contract_present = False\n" + " self.stage = None\n" + " self.errors = ()\n" + "\n" + "def evaluate_issue_trycycle_readiness(_issue):\n" + " return _Readiness()\n" + ), }, ) sentinel_path = tmp_path / "check-guardrails-sentinel.txt" @@ -662,6 +684,17 @@ def test_projected_check_guardrails_ignores_inherited_pythonpath_when_repo_runti "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), + "trycycle_contract.py": ( + "class _Readiness:\n" + " def __init__(self):\n" + " self.targeted = False\n" + " self.contract_present = False\n" + " self.stage = None\n" + " self.errors = ()\n" + "\n" + "def evaluate_issue_trycycle_readiness(_issue):\n" + " return _Readiness()\n" + ), }, ) _link_repo_python(repo_root) @@ -680,6 +713,17 @@ def test_projected_check_guardrails_ignores_inherited_pythonpath_when_repo_runti "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), + "trycycle_contract.py": ( + "class _Readiness:\n" + " def __init__(self):\n" + " self.targeted = False\n" + " self.contract_present = False\n" + " self.stage = None\n" + " self.errors = ()\n" + "\n" + "def evaluate_issue_trycycle_readiness(_issue):\n" + " return _Readiness()\n" + ), }, ) sentinel_path = tmp_path / "check-guardrails-pydantic-sentinel.txt" diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index e3376825..b2edfbab 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -60,3 +60,8 @@ 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 "trycycle.targeted: true" in content + assert "trycycle.contract_json" in content + assert "trycycle.plan_stage: planning_in_review" in content + assert "trycycle.plan_stage: approved" in content + assert "fail closed" in content diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 230d316f..0da4d090 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -194,6 +194,14 @@ def test_github_issues_skill_mentions_list_script() -> None: assert "list_issues.py" in text +def test_plan_promote_epic_skill_mentions_trycycle_approval_gate() -> None: + skill = skills.load_packaged_skills()["plan-promote-epic"] + text = skill.files["SKILL.md"].decode("utf-8") + assert "trycycle.plan_stage" in text + assert "trycycle.approved_by" in text + assert "trycycle.approval_message_id" in text + + def test_ensure_project_skills_installs_if_missing() -> None: with tempfile.TemporaryDirectory() as tmp: project_dir = Path(tmp) From 5f9fc205949e4d642aef6bc4add9490b71f00bd1 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 18:56:30 -0700 Subject: [PATCH 09/16] test(planner): finalize trycycle-ready claim-gate coverage - close verification gaps across planner validation and worker gating - ensure format/lint/test gates pass with fail-closed invariants --- .../skills/plan-changeset-guardrails/SKILL.md | 4 +- src/atelier/skills/plan-promote-epic/SKILL.md | 4 +- .../plan-promote-epic/scripts/promote_epic.py | 76 +++++++++++++++++-- .../skills/test_plan_promote_epic_script.py | 26 +++---- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/atelier/skills/plan-changeset-guardrails/SKILL.md b/src/atelier/skills/plan-changeset-guardrails/SKILL.md index d3c3a58d..e7829eaa 100644 --- a/src/atelier/skills/plan-changeset-guardrails/SKILL.md +++ b/src/atelier/skills/plan-changeset-guardrails/SKILL.md @@ -35,8 +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. -- For trycycle-targeted executable units, require - `trycycle.contract_json` and `trycycle.plan_stage: planning_in_review`. +- For trycycle-targeted executable units, require `trycycle.contract_json` and + `trycycle.plan_stage: planning_in_review`. - Surface deterministic errors from the shared trycycle validator, including malformed JSON, missing required payload fields, and completion-definition lifecycle conflicts. diff --git a/src/atelier/skills/plan-promote-epic/SKILL.md b/src/atelier/skills/plan-promote-epic/SKILL.md index fb4a3591..42005ae0 100644 --- a/src/atelier/skills/plan-promote-epic/SKILL.md +++ b/src/atelier/skills/plan-promote-epic/SKILL.md @@ -94,8 +94,8 @@ description: >- - `trycycle.plan_stage: approved` - `trycycle.approved_by` - `trycycle.approved_at` - - `trycycle.approval_message_id` - and record an approval evidence note/thread message. + - `trycycle.approval_message_id` and record an approval evidence note/thread + message. 1. Let dependency resolution determine runnability (`bd ready`) at worker time. ## Verification 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 721cb842..f5a1e79d 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -8,6 +8,7 @@ import datetime as dt import os import sys +from collections.abc import Mapping from pathlib import Path from typing import Protocol @@ -25,11 +26,12 @@ require_runtime_health=__name__ == "__main__", ) -from atelier import beads, trycycle_contract # noqa: E402 +from atelier import trycycle_contract # noqa: E402 from atelier.beads_context import ( # noqa: E402 resolve_runtime_repo_dir_hint, resolve_skill_beads_context, ) +from atelier.lib.beads import ShowIssueRequest, UpdateIssueRequest # noqa: E402 from atelier.lib.beads import description_fields as bead_fields # noqa: E402 from atelier.store import AppendNotesRequest, CreateMessageRequest # noqa: E402 @@ -42,6 +44,14 @@ async def create_message(self, request: CreateMessageRequest) -> object: ... async def append_notes(self, request: AppendNotesRequest) -> object: ... +class _ApprovalClient(Protocol): + """Typed client boundary for description-field persistence.""" + + async def show(self, request: ShowIssueRequest) -> object: ... + + async def update(self, request: UpdateIssueRequest) -> object: ... + + def _build_store_and_client(*, beads_root: Path, repo_root: Path): from atelier.lib.beads import SubprocessBeadsClient from atelier.store import build_atelier_store @@ -234,6 +244,7 @@ def _approval_timestamp() -> str: def _record_trycycle_approval( *, store: _ApprovalStore, + client: _ApprovalClient, issue: object, beads_root: Path, repo_root: Path, @@ -264,16 +275,15 @@ def _record_trycycle_approval( raise RuntimeError(f"{issue_id} trycycle approval message id missing") approved_at = _approval_timestamp() - updated = beads.update_issue_description_fields( - issue_id, - { + updated = _update_description_metadata_fields( + client=client, + issue_id=issue_id, + fields={ "trycycle.plan_stage": "approved", "trycycle.approved_by": operator_id, "trycycle.approved_at": approved_at, "trycycle.approval_message_id": approval_message_id, }, - beads_root=beads_root, - cwd=repo_root, ) updated_evidence = trycycle_contract.approval_evidence_summary(updated) asyncio.run( @@ -287,6 +297,59 @@ def _record_trycycle_approval( return approval_message_id +def _issue_mapping(issue: object) -> dict[str, object]: + if isinstance(issue, Mapping): + return {str(key): value for key, value in issue.items()} + return {"description": _issue_text(issue, "description") or ""} + + +def _render_description_with_updates( + description: str | None, + *, + fields: Mapping[str, str], +) -> str: + existing_lines = (description or "").splitlines() + update_keys = tuple(fields.keys()) + preserved_lines = [ + line + for line in existing_lines + if not any(line.strip().startswith(f"{key}:") for key in update_keys) + ] + update_lines = [f"{key}: {value}" for key, value in fields.items()] + merged = [*preserved_lines, *update_lines] + return ("\n".join(merged).rstrip("\n") + "\n") if merged else "" + + +def _update_description_metadata_fields( + *, + client: _ApprovalClient, + issue_id: str, + fields: Mapping[str, str], +) -> dict[str, object]: + for _ in range(3): + current = asyncio.run(client.show(ShowIssueRequest(issue_id=issue_id))) + current_description = _issue_text(current, "description") + next_description = _render_description_with_updates( + current_description, + fields=fields, + ) + asyncio.run( + client.update( + UpdateIssueRequest( + issue_id=issue_id, + description=next_description, + ) + ) + ) + refreshed = asyncio.run(client.show(ShowIssueRequest(issue_id=issue_id))) + refreshed_fields = bead_fields.parse_description_fields( + _issue_text(refreshed, "description") or "" + ) + if all(refreshed_fields.get(key) == value for key, value in fields.items()): + return _issue_mapping(refreshed) + raise RuntimeError(f"{issue_id} metadata update could not be verified") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--epic-id", required=True, help="Deferred epic bead id") @@ -414,6 +477,7 @@ def main() -> None: if readiness.targeted: _record_trycycle_approval( store=store, + client=client, issue=issue, beads_root=beads_root, repo_root=repo_root, diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 6cd89ee4..3a2a2937 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -205,20 +205,19 @@ async def show(self, request): return {"at-epic": epic_issue, "at-epic.1": child_issue}[request.issue_id] def _record_metadata( - issue_id: str, - fields: dict[str, str | None], *, - beads_root: Path, - cwd: Path, + client: object, + issue_id: str, + fields: dict[str, str], ) -> dict[str, object]: - del beads_root, cwd + del client metadata_updates[issue_id] = fields return {"id": issue_id} monkeypatch.setattr( module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) ) - monkeypatch.setattr(module.beads, "update_issue_description_fields", _record_metadata) + monkeypatch.setattr(module, "_update_description_metadata_fields", _record_metadata) monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) module.main() @@ -280,20 +279,19 @@ async def show(self, request): return epic_issue def _record_metadata( - issue_id: str, - fields: dict[str, str | None], *, - beads_root: Path, - cwd: Path, + client: object, + issue_id: str, + fields: dict[str, str], ) -> dict[str, object]: - del beads_root, cwd + del client metadata_updates[issue_id] = fields return {"id": issue_id} monkeypatch.setattr( module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) ) - monkeypatch.setattr(module.beads, "update_issue_description_fields", _record_metadata) + monkeypatch.setattr(module, "_update_description_metadata_fields", _record_metadata) monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) module.main() @@ -363,8 +361,8 @@ async def show(self, request): module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) ) monkeypatch.setattr( - module.beads, - "update_issue_description_fields", + module, + "_update_description_metadata_fields", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError((args, kwargs))), ) monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) From 98af688ba8e381068286f382a311ca6fd270abdc Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 19:16:48 -0700 Subject: [PATCH 10/16] fix(trycycle): close startup and promotion gate bypasses - hydrate startup claim candidates before trycycle eligibility checks across review-feedback and merge-conflict paths - hydrate sparse startup-runtime payloads before delegating to shared trycycle validator - persist trycycle approval evidence before lifecycle transitions so failed metadata writes do not partially promote work - extend worker and promotion tests for shared-validator parity and fail-closed transition sequencing --- .../plan-promote-epic/scripts/promote_epic.py | 31 +-- src/atelier/worker/session/startup.py | 4 +- src/atelier/worker/work_startup_runtime.py | 43 +++-- .../skills/test_plan_promote_epic_script.py | 78 ++++++++ tests/atelier/worker/test_session_startup.py | 178 ++++++++++++++++++ .../worker/test_work_startup_runtime.py | 55 ++++++ 6 files changed, 361 insertions(+), 28 deletions(-) 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 f5a1e79d..3fe2c2bd 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -447,6 +447,22 @@ def main() -> None: print("confirmation_required: rerun with --yes after explicit operator confirmation") return + operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "operator").strip() or "operator" + approval_targets = list(executable_targets) + for issue in approval_targets: + readiness = trycycle_contract.evaluate_issue_trycycle_readiness( + _issue_metadata_payload(issue) + ) + if readiness.targeted: + _record_trycycle_approval( + store=store, + client=client, + issue=issue, + beads_root=beads_root, + repo_root=repo_root, + operator_id=operator_id, + ) + asyncio.run( store.transition_lifecycle( LifecycleTransitionRequest( @@ -468,21 +484,6 @@ def main() -> None: ) ) promoted_children.append(child_id) - operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "operator").strip() or "operator" - approval_targets = list(executable_targets) - for issue in approval_targets: - readiness = trycycle_contract.evaluate_issue_trycycle_readiness( - _issue_metadata_payload(issue) - ) - if readiness.targeted: - _record_trycycle_approval( - store=store, - client=client, - issue=issue, - beads_root=beads_root, - repo_root=repo_root, - operator_id=operator_id, - ) except Exception as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/src/atelier/worker/session/startup.py b/src/atelier/worker/session/startup.py index 8039bcee..bb308da7 100644 --- a/src/atelier/worker/session/startup.py +++ b/src/atelier/worker/session/startup.py @@ -545,7 +545,9 @@ def trycycle_selection_allowed( changeset_id: str, stage: str, ) -> bool: - eligible, reason = service.trycycle_claim_eligible({"id": changeset_id}) + issue = service.show_issue(changeset_id) + payload: dict[str, object] = issue if issue is not None else {"id": changeset_id} + eligible, reason = service.trycycle_claim_eligible(payload) if eligible: return True detail = reason or "targeted changeset is not trycycle claim-eligible" diff --git a/src/atelier/worker/work_startup_runtime.py b/src/atelier/worker/work_startup_runtime.py index 4ba6e73d..823f50b8 100644 --- a/src/atelier/worker/work_startup_runtime.py +++ b/src/atelier/worker/work_startup_runtime.py @@ -170,6 +170,26 @@ def _trycycle_claim_eligibility(issue: dict[str, object]) -> tuple[bool, str | N return trycycle_contract.trycycle_claim_eligible(issue) +def _hydrate_trycycle_issue_payload( + issue: dict[str, object], + *, + beads_root: Path, + repo_root: Path, +) -> dict[str, object]: + payload = issue + if not isinstance(issue.get("description"), str): + issue_id = issue.get("id") + if isinstance(issue_id, str) and issue_id.strip(): + hydrated = worker_store.show_issue( + issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) + if hydrated is not None: + payload = hydrated + return payload + + class _NextChangesetService(worker_startup.NextChangesetService): """Concrete next-changeset service implementation for worker startup.""" @@ -285,17 +305,11 @@ def trycycle_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: - payload = issue - if not isinstance(issue.get("description"), str): - issue_id = issue.get("id") - if isinstance(issue_id, str) and issue_id.strip(): - hydrated = worker_store.show_issue( - issue_id, - beads_root=self._beads_root, - repo_root=self._repo_root, - ) - if hydrated is not None: - payload = hydrated + payload = _hydrate_trycycle_issue_payload( + issue, + beads_root=self._beads_root, + repo_root=self._repo_root, + ) return _trycycle_claim_eligibility(payload) @@ -939,7 +953,12 @@ def trycycle_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: - return _trycycle_claim_eligibility(issue) + payload = _hydrate_trycycle_issue_payload( + issue, + beads_root=self._beads_root, + repo_root=self._repo_root, + ) + return _trycycle_claim_eligibility(payload) def check_inbox_before_claim(self, agent_id: str) -> bool: return check_inbox_before_claim( diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 3a2a2937..586157a9 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -230,6 +230,84 @@ def _record_metadata( assert metadata_updates["at-epic.1"]["trycycle.approved_at"] is not None +def test_promote_epic_does_not_transition_lifecycle_when_trycycle_approval_fails( + monkeypatch, + capsys: pytest.CaptureFixture[str], + 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), + ) + monkeypatch.setenv("ATELIER_AGENT_ID", "atelier/planner/codex/p1") + + 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" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + notes="child notes", + ) + + 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 create_message(self, request): + del request + return SimpleNamespace(id="at-msg.1") + + async def append_notes(self, request): + del request + return SimpleNamespace(id="at-note.1") + + 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": child_issue}[request.issue_id] + + monkeypatch.setattr( + module, "_build_store_and_client", lambda **_kwargs: (FakeStore(), FakeClient()) + ) + monkeypatch.setattr( + module, + "_update_description_metadata_fields", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("metadata update failed")), + ) + monkeypatch.setattr(sys, "argv", ["promote_epic.py", "--epic-id", "at-epic", "--yes"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "metadata update failed" in capsys.readouterr().err + assert transitions == [] + + def test_promote_epic_records_trycycle_approval_metadata_for_epic_single_unit( monkeypatch, tmp_path: Path, diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index 082b8b4c..122a80e7 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json from collections.abc import Callable from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import patch +from atelier import trycycle_contract from atelier.worker import integration, work_startup_runtime from atelier.worker.models_boundary import parse_issue_boundary from atelier.worker.review import MergeConflictSelection, ReviewFeedbackSelection @@ -309,6 +311,48 @@ def _run_startup(**overrides: Any) -> startup.StartupContractResult: return startup.run_startup_contract_service(context=context, service=service) +def _valid_trycycle_contract_json() -> str: + return json.dumps( + { + "objective": "Gate all startup claim paths with shared trycycle checks", + "non_goals": ["Do not alter non-trycycle selection"], + "acceptance_criteria": [ + {"statement": "Reject unapproved targeted work", "evidence": ["pytest"]} + ], + "scope": {"includes": ["worker startup"], "excludes": ["planner workflows"]}, + "verification_plan": ["uv run pytest tests/atelier/worker/test_session_startup.py -v"], + "risks": [{"risk": "selection drift", "mitigation": "shared validator parity"}], + "escalation_conditions": ["claim-path disagreement"], + "completion_definition": { + "requires_terminal_pr_state": True, + "allowed_terminal_pr_states": ["merged", "closed"], + "allows_integrated_sha_proof": True, + "allow_close_without_terminal_or_integrated_sha": False, + }, + }, + separators=(",", ":"), + ) + + +def _trycycle_target_description(*, approved: bool) -> str: + lines = [ + "trycycle.targeted: true", + f"trycycle.contract_json: {_valid_trycycle_contract_json()}", + ] + if approved: + lines.extend( + [ + "trycycle.plan_stage: approved", + "trycycle.approved_by: atelier/planner/codex/p1", + "trycycle.approved_at: 2026-03-26T18:00:00Z", + "trycycle.approval_message_id: at-msg.1", + ] + ) + else: + lines.append("trycycle.plan_stage: planning_in_review") + return "\n".join(lines) + "\n" + + def test_run_startup_contract_service_supports_typed_context() -> None: context, service = _startup_context_service( explicit_epic_id="at-explicit", @@ -509,6 +553,63 @@ def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycl assert any("missing trycycle approval" in message for message in emitted) +def test_run_startup_contract_explicit_trycycle_paths_use_shared_validator() -> None: + scenarios: tuple[tuple[str, bool, str], ...] = ( + ("review", False, "explicit_epic_not_actionable"), + ("review", True, "review_feedback"), + ("conflict", False, "explicit_epic_not_actionable"), + ("conflict", True, "merge_conflict"), + ) + for selector, approved, expected_reason in scenarios: + emitted: list[str] = [] + feedback = ( + ReviewFeedbackSelection( + epic_id="at-explicit", + changeset_id="at-explicit.1", + feedback_at="2026-02-20T00:00:00Z", + ) + if selector == "review" + else None + ) + conflict = ( + MergeConflictSelection( + epic_id="at-explicit", + changeset_id="at-explicit.1", + observed_at="2026-02-20T00:00:00Z", + pr_url="https://github.com/org/repo/pull/110", + ) + if selector == "conflict" + else None + ) + + result = _run_startup( + explicit_epic_id="at-explicit", + branch_pr=True, + repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-explicit" else [], + "description": ( + _trycycle_target_description(approved=approved) + if issue_id == "at-explicit.1" + else "" + ), + }, + select_conflicted_changeset=lambda **_kwargs: conflict, + select_review_feedback_changeset=lambda **_kwargs: feedback, + next_changeset=lambda **_kwargs: None, + trycycle_claim_eligible=trycycle_contract.trycycle_claim_eligible, + emit=lambda message: emitted.append(message), + ) + + assert result.reason == expected_reason + if not approved: + assert any("plan_stage=approved" in message for message in emitted) + else: + assert result.changeset_id == "at-explicit.1" + + def test_run_startup_contract_explicit_epic_completed_exits_cleanly() -> None: emitted: list[str] = [] result = _run_startup( @@ -1269,6 +1370,14 @@ def test_run_startup_contract_resumes_unassigned_draft_pr_review_followup() -> N "atelier.worker.work_startup_runtime.worker_selection.stale_family_assigned_epics", return_value=[], ), + patch( + "atelier.worker.work_startup_runtime.worker_store.show_issue", + side_effect=lambda issue_id, *, beads_root, repo_root: ( + epic + if issue_id == "at-v1se7" + else (changeset if issue_id == "at-v1se7.1" else None) + ), + ), patch( "atelier.worker.work_startup_runtime.select_conflicted_changeset", return_value=None, @@ -1436,6 +1545,75 @@ def test_run_startup_contract_global_merge_conflict_rejects_unapproved_trycycle( assert any("missing trycycle approval" in message for message in emitted) +def test_run_startup_contract_global_trycycle_paths_use_shared_validator() -> None: + scenarios: tuple[tuple[str, bool, str], ...] = ( + ("review", False, "selected_auto"), + ("review", True, "review_feedback"), + ("conflict", False, "selected_auto"), + ("conflict", True, "merge_conflict"), + ) + for selector, approved, expected_reason in scenarios: + emitted: list[str] = [] + feedback = ( + ReviewFeedbackSelection( + epic_id="at-blocked", + changeset_id="at-blocked.1", + feedback_at="2026-02-19T00:00:00Z", + ) + if selector == "review" + else None + ) + conflict = ( + MergeConflictSelection( + epic_id="at-blocked", + changeset_id="at-blocked.1", + observed_at="2026-02-19T00:00:00Z", + pr_url="https://github.com/org/repo/pull/404", + ) + if selector == "conflict" + else None + ) + + result = _run_startup( + branch_pr=True, + repo_slug="org/repo", + list_epics=lambda: [ + { + "id": "at-claimable", + "status": "open", + "labels": ["at:epic"], + "assignee": None, + "created_at": "2026-02-21T00:00:00Z", + } + ], + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id in {"at-blocked", "at-claimable"} else [], + "description": ( + _trycycle_target_description(approved=approved) + if issue_id == "at-blocked.1" + else "" + ), + }, + next_changeset=lambda **kwargs: {"id": f"{kwargs['epic_id']}.1"}, + select_conflicted_changeset=lambda **_kwargs: None, + select_global_conflicted_changeset=lambda **_kwargs: conflict, + select_review_feedback_changeset=lambda **_kwargs: None, + select_global_review_feedback_changeset=lambda **_kwargs: feedback, + trycycle_claim_eligible=trycycle_contract.trycycle_claim_eligible, + emit=lambda message: emitted.append(message), + ) + + assert result.reason == expected_reason + if not approved: + assert result.epic_id == "at-claimable" + assert any("plan_stage=approved" in message for message in emitted) + else: + assert result.epic_id == "at-blocked" + assert result.changeset_id == "at-blocked.1" + + def test_run_startup_contract_claims_global_feedback_standalone_identity() -> None: standalone_feedback = ReviewFeedbackSelection( epic_id="at-bmu", diff --git a/tests/atelier/worker/test_work_startup_runtime.py b/tests/atelier/worker/test_work_startup_runtime.py index 91dcf106..a634bfd7 100644 --- a/tests/atelier/worker/test_work_startup_runtime.py +++ b/tests/atelier/worker/test_work_startup_runtime.py @@ -1,4 +1,5 @@ import asyncio +import json from pathlib import Path from atelier.lib.beads import SyncBeadsClient @@ -186,3 +187,57 @@ def test_startup_contract_service_trycycle_eligibility_uses_shared_helper(monkey assert (eligible, reason) == (True, None) assert calls == [issue] + + +def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( + monkeypatch, +) -> None: + contract_json = json.dumps( + { + "objective": "Gate startup candidates", + "non_goals": ["Do not alter non-targeted startup paths"], + "acceptance_criteria": [{"statement": "Reject unapproved", "evidence": ["pytest"]}], + "scope": {"includes": ["startup"], "excludes": ["planner workflow"]}, + "verification_plan": ["uv run pytest tests/atelier/worker -k trycycle -v"], + "risks": [{"risk": "claim drift", "mitigation": "shared validator"}], + "escalation_conditions": ["validator mismatch"], + "completion_definition": { + "requires_terminal_pr_state": True, + "allowed_terminal_pr_states": ["merged", "closed"], + "allows_integrated_sha_proof": True, + "allow_close_without_terminal_or_integrated_sha": False, + }, + }, + separators=(",", ":"), + ) + hydrated_issue = { + "id": "at-epic.1", + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {contract_json}\n" + ), + } + calls: list[str] = [] + + monkeypatch.setattr( + worker_store, + "show_issue", + lambda issue_id, *, beads_root, repo_root: ( + calls.append(f"{issue_id}|{beads_root}|{repo_root}"), + hydrated_issue, + )[1], + ) + + service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + eligible, reason = service.trycycle_claim_eligible({"id": "at-epic.1"}) + + assert (eligible, reason) == ( + False, + "targeted changesets require trycycle.plan_stage=approved before worker claim", + ) + assert calls == ["at-epic.1|/beads|/repo"] From 8080153d3182b0721844e6932c4a78903b6ac7b6 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 19:34:24 -0700 Subject: [PATCH 11/16] fix(trycycle): harden fail-closed startup and approval audits - fail closed when startup claim candidates cannot load metadata before trycycle eligibility checks - require explicit ATELIER_AGENT_ID for targeted approval recording in promotion flow - validate malformed trycycle approval message identifiers in shared contract checks - add differential parity and regression tests across evaluator, guardrails, promotion preflight, and worker adapters --- .../plan-promote-epic/scripts/promote_epic.py | 22 +- src/atelier/trycycle_contract.py | 4 + src/atelier/worker/session/startup.py | 10 +- src/atelier/worker/work_startup_runtime.py | 31 +-- .../skills/test_plan_promote_epic_script.py | 74 +++++++ tests/atelier/test_trycycle_contract.py | 205 ++++++++++++++++++ tests/atelier/worker/test_session_startup.py | 65 ++++++ .../worker/test_work_startup_runtime.py | 26 +++ 8 files changed, 417 insertions(+), 20 deletions(-) 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 3fe2c2bd..afac712e 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -241,6 +241,13 @@ def _approval_timestamp() -> str: ) +def _required_operator_id() -> str: + operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "").strip() + if not operator_id: + raise RuntimeError("ATELIER_AGENT_ID must be set for trycycle approvals") + return operator_id + + def _record_trycycle_approval( *, store: _ApprovalStore, @@ -447,13 +454,16 @@ def main() -> None: print("confirmation_required: rerun with --yes after explicit operator confirmation") return - operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "operator").strip() or "operator" - approval_targets = list(executable_targets) - for issue in approval_targets: - readiness = trycycle_contract.evaluate_issue_trycycle_readiness( + approval_targets = [ + issue + for issue in executable_targets + if trycycle_contract.evaluate_issue_trycycle_readiness( _issue_metadata_payload(issue) - ) - if readiness.targeted: + ).targeted + ] + if approval_targets: + operator_id = _required_operator_id() + for issue in approval_targets: _record_trycycle_approval( store=store, client=client, diff --git a/src/atelier/trycycle_contract.py b/src/atelier/trycycle_contract.py index c776a049..92741a06 100644 --- a/src/atelier/trycycle_contract.py +++ b/src/atelier/trycycle_contract.py @@ -4,6 +4,7 @@ import datetime as dt import json +import re from collections.abc import Mapping from typing import Literal @@ -17,6 +18,7 @@ _APPROVED_BY_FIELD = "trycycle.approved_by" _APPROVED_AT_FIELD = "trycycle.approved_at" _APPROVAL_MESSAGE_ID_FIELD = "trycycle.approval_message_id" +_APPROVAL_MESSAGE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._:-]*$") _PLANNING_IN_REVIEW = "planning_in_review" _APPROVED = "approved" @@ -341,6 +343,8 @@ def _approval_errors(fields: Mapping[str, str]) -> list[str]: approval_message_id = _normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) if approval_message_id is None: errors.append("approved stage requires trycycle.approval_message_id") + elif not _APPROVAL_MESSAGE_ID_PATTERN.fullmatch(approval_message_id): + errors.append("trycycle.approval_message_id must be an identifier") return errors diff --git a/src/atelier/worker/session/startup.py b/src/atelier/worker/session/startup.py index bb308da7..454c458b 100644 --- a/src/atelier/worker/session/startup.py +++ b/src/atelier/worker/session/startup.py @@ -546,7 +546,15 @@ def trycycle_selection_allowed( stage: str, ) -> bool: issue = service.show_issue(changeset_id) - payload: dict[str, object] = issue if issue is not None else {"id": changeset_id} + if issue is None: + detail = "unable to load changeset metadata for trycycle claim gate" + service.emit(f"Skipping {stage} changeset {changeset_id}: {detail}") + atelier_log.warning( + "startup skipping " + f"{stage} changeset={changeset_id} reason=trycycle_metadata_unavailable" + ) + return False + payload: dict[str, object] = issue eligible, reason = service.trycycle_claim_eligible(payload) if eligible: return True diff --git a/src/atelier/worker/work_startup_runtime.py b/src/atelier/worker/work_startup_runtime.py index 823f50b8..cf5d8d7e 100644 --- a/src/atelier/worker/work_startup_runtime.py +++ b/src/atelier/worker/work_startup_runtime.py @@ -175,19 +175,20 @@ def _hydrate_trycycle_issue_payload( *, beads_root: Path, repo_root: Path, -) -> dict[str, object]: - payload = issue - if not isinstance(issue.get("description"), str): - issue_id = issue.get("id") - if isinstance(issue_id, str) and issue_id.strip(): - hydrated = worker_store.show_issue( - issue_id, - beads_root=beads_root, - repo_root=repo_root, - ) - if hydrated is not None: - payload = hydrated - return payload +) -> dict[str, object] | None: + if isinstance(issue.get("description"), str): + return issue + issue_id = issue.get("id") + if not isinstance(issue_id, str) or not issue_id.strip(): + return None + hydrated = worker_store.show_issue( + issue_id, + beads_root=beads_root, + repo_root=repo_root, + ) + if hydrated is None or not isinstance(hydrated.get("description"), str): + return None + return hydrated class _NextChangesetService(worker_startup.NextChangesetService): @@ -310,6 +311,8 @@ def trycycle_claim_eligible( beads_root=self._beads_root, repo_root=self._repo_root, ) + if payload is None: + return False, "unable to load changeset metadata for trycycle claim gate" return _trycycle_claim_eligibility(payload) @@ -958,6 +961,8 @@ def trycycle_claim_eligible( beads_root=self._beads_root, repo_root=self._repo_root, ) + if payload is None: + return False, "unable to load changeset metadata for trycycle claim gate" return _trycycle_claim_eligibility(payload) def check_inbox_before_claim(self, agent_id: str) -> bool: diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 586157a9..0b47f1cc 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -230,6 +230,80 @@ def _record_metadata( assert metadata_updates["at-epic.1"]["trycycle.approved_at"] is not None +def test_promote_epic_requires_explicit_operator_identity_for_trycycle_approval( + monkeypatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + module = _load_script_module() + transitions: list[object] = [] + created_messages: list[object] = [] + + monkeypatch.setattr( + module, + "_resolve_context", + lambda **_kwargs: (tmp_path / ".beads", tmp_path / "repo", None), + ) + monkeypatch.delenv("ATELIER_AGENT_ID", raising=False) + + 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" + ), + ) + child_issue = _issue( + "at-epic.1", + title="Child", + description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + notes="child notes", + ) + + 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 create_message(self, request): + created_messages.append(request) + return SimpleNamespace(id="at-msg.1") + + async def append_notes(self, request): + return request + + 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": 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"]) + + with pytest.raises(SystemExit) as excinfo: + module.main() + + assert excinfo.value.code == 1 + assert "ATELIER_AGENT_ID must be set for trycycle approvals" in capsys.readouterr().err + assert created_messages == [] + assert transitions == [] + + def test_promote_epic_does_not_transition_lifecycle_when_trycycle_approval_fails( monkeypatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/atelier/test_trycycle_contract.py b/tests/atelier/test_trycycle_contract.py index db3d06ed..9006e245 100644 --- a/tests/atelier/test_trycycle_contract.py +++ b/tests/atelier/test_trycycle_contract.py @@ -1,6 +1,11 @@ +import importlib.util import json +import sys +from pathlib import Path +from types import SimpleNamespace from atelier import trycycle_contract +from atelier.worker import work_startup_runtime def _valid_contract_json( @@ -32,6 +37,87 @@ def _valid_contract_json( ) +def _load_guardrails_script_module(): + script_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "atelier" + / "skills" + / "plan-changeset-guardrails" + / "scripts" + / "check_guardrails.py" + ) + spec = importlib.util.spec_from_file_location("check_guardrails_parity", 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 _load_promote_epic_script_module(): + script_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "atelier" + / "skills" + / "plan-promote-epic" + / "scripts" + / "promote_epic.py" + ) + spec = importlib.util.spec_from_file_location("promote_epic_parity", 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 _planner_contract_text() -> str: + return ( + "intent: Keep targeted startup selections fail-closed.\n" + "rationale: Workers need deterministic claim eligibility.\n" + "non_goals: Do not alter non-trycycle startup behavior.\n" + "constraints: Preserve lifecycle invariants and explicit audits.\n" + "edge_cases: Missing metadata must not silently bypass gates.\n" + "related_context: at-719.\n" + "LOC estimate: 220\n" + "done_definition: Done when startup/promotion parity checks pass.\n" + ) + + +def _targeted_description( + *, + contract_json: str, + stage: str, + approved_by: str | None = None, + approved_at: str | None = None, + approval_message_id: str | None = None, +) -> str: + lines = [ + "trycycle.targeted: true", + f"trycycle.plan_stage: {stage}", + f"trycycle.contract_json: {contract_json}", + ] + if approved_by is not None: + lines.append(f"trycycle.approved_by: {approved_by}") + if approved_at is not None: + lines.append(f"trycycle.approved_at: {approved_at}") + if approval_message_id is not None: + lines.append(f"trycycle.approval_message_id: {approval_message_id}") + return _planner_contract_text() + "\n".join(lines) + "\n" + + +def _guardrails_issue(issue_id: str, description: str) -> dict[str, object]: + return { + "id": issue_id, + "title": issue_id, + "description": description, + "notes": "notes: parity harness", + "acceptance_criteria": "Done when parity checks are deterministic.", + } + + def test_validate_contract_accepts_complete_payload() -> None: issue = { "description": ( @@ -127,3 +213,122 @@ def test_validate_contract_approved_stage_requires_full_evidence_fields() -> Non assert result.ok is False assert "trycycle.approved_at" in result.summary assert "trycycle.approval_message_id" in result.summary + + +def test_validate_contract_rejects_malformed_approved_timestamp() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + "trycycle.approved_by: atelier/planner/codex/p1\n" + "trycycle.approved_at: yesterday\n" + "trycycle.approval_message_id: at-msg.1\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + + +def test_validate_contract_rejects_malformed_approval_message_id() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + "trycycle.approved_by: atelier/planner/codex/p1\n" + "trycycle.approved_at: 2026-03-27T01:00:00Z\n" + "trycycle.approval_message_id: bad id\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.approval_message_id must be an identifier" in result.summary + + +def test_trycycle_readiness_parity_across_entrypoints() -> None: + guardrails = _load_guardrails_script_module() + promote_epic = _load_promote_epic_script_module() + worker_service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + conflict_payload = json.loads(_valid_contract_json()) + conflict_payload["completion_definition"]["allow_close_without_terminal_or_integrated_sha"] = ( + True + ) + cases = ( + ( + "valid_approved", + _targeted_description( + contract_json=_valid_contract_json(), + stage="approved", + approved_by="atelier/planner/codex/p1", + approved_at="2026-03-27T01:00:00Z", + approval_message_id="at-msg.1", + ), + ), + ( + "valid_unapproved", + _targeted_description( + contract_json=_valid_contract_json(), + stage="planning_in_review", + ), + ), + ( + "malformed_json", + _targeted_description( + contract_json="{bad-json}", + stage="planning_in_review", + ), + ), + ( + "missing_contract", + _planner_contract_text() + + "trycycle.targeted: true\ntrycycle.plan_stage: planning_in_review\n", + ), + ( + "completion_conflict", + _targeted_description( + contract_json=json.dumps(conflict_payload, separators=(",", ":")), + stage="planning_in_review", + ), + ), + ) + + for label, description in cases: + issue_id = f"at-parity.{label}" + issue_payload = {"id": issue_id, "description": description} + readiness = trycycle_contract.evaluate_issue_trycycle_readiness(issue_payload) + promotion_error = promote_epic._trycycle_validation_error( # pyright: ignore[reportPrivateUsage] + SimpleNamespace(id=issue_id, description=description) + ) + worker_eligible, worker_reason = worker_service.trycycle_claim_eligible(issue_payload) + report = guardrails._evaluate_guardrails( # pyright: ignore[reportPrivateUsage] + epic_issue=None, + child_changesets=[], + target_changesets=[_guardrails_issue(issue_id, description)], + ) + trycycle_violations = tuple( + violation + for violation in report.violations + if violation.startswith(f"{issue_id}:") + and ("trycycle." in violation or "completion_definition conflicts" in violation) + ) + + assert (promotion_error is None) is readiness.ok + assert worker_eligible is readiness.claim_eligible + if not worker_eligible: + assert worker_reason is not None + if readiness.ok and readiness.stage == "planning_in_review": + assert trycycle_violations == () + elif readiness.ok and readiness.stage == "approved": + assert any("planning_in_review" in violation for violation in trycycle_violations) + else: + assert trycycle_violations diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index 122a80e7..778a9899 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -521,6 +521,35 @@ def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_trycyc assert any("missing trycycle approval" in message for message in emitted) +def test_run_startup_contract_explicit_review_feedback_fails_closed_on_missing_metadata() -> None: + feedback = ReviewFeedbackSelection( + epic_id="at-explicit", + changeset_id="at-explicit.1", + feedback_at="2026-02-20T00:00:00Z", + ) + emitted: list[str] = [] + + result = _run_startup( + explicit_epic_id="at-explicit", + branch_pr=True, + repo_slug="org/repo", + show_issue=lambda issue_id: ( + {"id": issue_id, "status": "open", "labels": ["at:epic"]} + if issue_id == "at-explicit" + else None + ), + select_review_feedback_changeset=lambda **_kwargs: feedback, + next_changeset=lambda **_kwargs: None, + emit=lambda message: emitted.append(message), + ) + + assert result.reason == "explicit_epic_not_actionable" + assert any( + "unable to load changeset metadata for trycycle claim gate" in message + for message in emitted + ) + + def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycle() -> None: conflict = MergeConflictSelection( epic_id="at-explicit", @@ -973,6 +1002,12 @@ def next_changeset(**_kwargs: Any) -> dict[str, object] | None: branch_pr=True, repo_slug="org/repo", resolve_hooked_epic=lambda *_args: "at-epic", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-epic" else [], + "description": "", + }, select_review_feedback_changeset=lambda **_kwargs: feedback, next_changeset=next_changeset, list_epics=lambda: [ @@ -1099,6 +1134,12 @@ def select_review_feedback_changeset( select="first-eligible", branch_pr=True, repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id in {"at-early", "at-oldest"} else [], + "description": "", + }, list_epics=lambda: [ { "id": "at-early", @@ -1150,6 +1191,12 @@ def select_review_feedback_changeset( select="oldest-feedback", branch_pr=True, repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id in {"at-early", "at-oldest"} else [], + "description": "", + }, list_epics=lambda: [ { "id": "at-early", @@ -1193,6 +1240,12 @@ def next_changeset(**_kwargs: Any) -> dict[str, object] | None: branch_pr=True, repo_slug="org/repo", resolve_hooked_epic=lambda *_args: "at-epic", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-epic" else [], + "description": "", + }, select_conflicted_changeset=lambda **_kwargs: conflict, next_changeset=next_changeset, list_epics=lambda: [{"id": "at-epic", "assignee": "atelier/worker/codex/p010"}], @@ -1229,6 +1282,12 @@ def select_feedback(*, epic_id: str, repo_slug: str | None) -> ReviewFeedbackSel result = _run_startup( branch_pr=True, repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id in {"at-blocked", "at-claimable"} else [], + "description": "", + }, list_epics=lambda: [ { "id": "at-blocked", @@ -1314,6 +1373,12 @@ def test_run_startup_contract_selects_stale_reclaimable_review_feedback() -> Non result = _run_startup( branch_pr=True, repo_slug="org/repo", + show_issue=lambda issue_id: { + "id": issue_id, + "status": "open", + "labels": ["at:epic"] if issue_id == "at-stale" else [], + "description": "", + }, list_epics=lambda: [stale_issue], stale_family_assigned_epics=lambda _issues, agent_id: [stale_issue], next_changeset=lambda **_kwargs: {"id": "at-stale.2"}, diff --git a/tests/atelier/worker/test_work_startup_runtime.py b/tests/atelier/worker/test_work_startup_runtime.py index a634bfd7..ee73098b 100644 --- a/tests/atelier/worker/test_work_startup_runtime.py +++ b/tests/atelier/worker/test_work_startup_runtime.py @@ -241,3 +241,29 @@ def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( "targeted changesets require trycycle.plan_stage=approved before worker claim", ) assert calls == ["at-epic.1|/beads|/repo"] + + +def test_startup_contract_service_trycycle_eligibility_fails_closed_when_hydration_missing( + monkeypatch, +) -> None: + calls: list[str] = [] + monkeypatch.setattr( + worker_store, + "show_issue", + lambda issue_id, *, beads_root, repo_root: ( + calls.append(f"{issue_id}|{beads_root}|{repo_root}"), + None, + )[1], + ) + service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] + beads_root=Path("/beads"), + repo_root=Path("/repo"), + ) + + eligible, reason = service.trycycle_claim_eligible({"id": "at-missing.1"}) + + assert (eligible, reason) == ( + False, + "unable to load changeset metadata for trycycle claim gate", + ) + assert calls == ["at-missing.1|/beads|/repo"] From 0902caedb455f576b52d7a255b0e484905336a90 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Thu, 26 Mar 2026 19:49:56 -0700 Subject: [PATCH 12/16] fix(trycycle): enforce auditable approval timestamp evidence - require to be a timezone-aware datetime with a time component - add red/green readiness tests for date-only and naive approval timestamps - add projected runtime execution coverage proving prefers repo imports for Fixes #719 --- src/atelier/trycycle_contract.py | 6 +- .../test_projected_skill_runtime_bootstrap.py | 117 ++++++++++++++++++ tests/atelier/test_trycycle_contract.py | 36 ++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/atelier/trycycle_contract.py b/src/atelier/trycycle_contract.py index 92741a06..08b07d0e 100644 --- a/src/atelier/trycycle_contract.py +++ b/src/atelier/trycycle_contract.py @@ -349,10 +349,14 @@ def _approval_errors(fields: Mapping[str, str]) -> list[str]: def _is_valid_iso_timestamp(value: str) -> bool: + if "t" not in value: + return False try: - dt.datetime.fromisoformat(value.replace("z", "+00:00")) + parsed = dt.datetime.fromisoformat(value.replace("z", "+00:00")) except ValueError: return False + if parsed.tzinfo is None or parsed.utcoffset() is None: + return False return True diff --git a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py index f434c2b5..25cccc58 100644 --- a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py +++ b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py @@ -753,6 +753,123 @@ def test_projected_check_guardrails_ignores_inherited_pythonpath_when_repo_runti ) +def test_projected_promote_epic_reorders_repo_src_ahead_of_installed_package( + tmp_path: Path, +) -> None: + agent_home, projected_script = _install_projected_script( + tmp_path, + skill_name="plan-promote-epic", + script_name="promote_epic.py", + ) + repo_root = _fake_repo( + tmp_path, + sentinel_import="trycycle_contract", + extra_modules={ + "beads_context.py": ( + "class _Context:\n" + " def __init__(self, repo_dir):\n" + " self.beads_root = repo_dir\n" + " self.repo_root = repo_dir\n" + " self.override_warning = None\n" + "\n" + "def resolve_runtime_repo_dir_hint(*, repo_dir=None, cwd=None, env=None):\n" + " return (repo_dir, None)\n" + "\n" + "def resolve_skill_beads_context(*, beads_dir=None, repo_dir=None):\n" + " return _Context(repo_dir)\n" + ), + "lib/beads.py": ( + "class ShowIssueRequest:\n" + " def __init__(self, issue_id):\n" + " self.issue_id = issue_id\n" + "\n" + "class UpdateIssueRequest:\n" + " def __init__(self, issue_id, description):\n" + " self.issue_id = issue_id\n" + " self.description = description\n" + "\n" + "class description_fields:\n" + " @staticmethod\n" + " def parse_description_fields(_description):\n" + " return {}\n" + ), + "store.py": ( + "class AppendNotesRequest:\n" + " def __init__(self, issue_id, notes):\n" + " self.issue_id = issue_id\n" + " self.notes = notes\n" + "\n" + "class CreateMessageRequest:\n" + " def __init__(self, **kwargs):\n" + " self.kwargs = kwargs\n" + ), + }, + ) + installed_root = _fake_installed_package( + tmp_path, + modules={ + "trycycle_contract.py": ( + "from pathlib import Path\n" + "import os\n" + "\n" + "Path(os.environ['BOOTSTRAP_SENTINEL']).write_text(__file__, encoding='utf-8')\n" + ), + "beads_context.py": ( + "class _Context:\n" + " def __init__(self, repo_dir):\n" + " self.beads_root = repo_dir\n" + " self.repo_root = repo_dir\n" + " self.override_warning = None\n" + "\n" + "def resolve_runtime_repo_dir_hint(*, repo_dir=None, cwd=None, env=None):\n" + " return (repo_dir, None)\n" + "\n" + "def resolve_skill_beads_context(*, beads_dir=None, repo_dir=None):\n" + " return _Context(repo_dir)\n" + ), + "lib/beads.py": ( + "class ShowIssueRequest:\n" + " pass\n" + "\n" + "class UpdateIssueRequest:\n" + " pass\n" + "\n" + "class description_fields:\n" + " @staticmethod\n" + " def parse_description_fields(_description):\n" + " return {}\n" + ), + "store.py": ( + "class AppendNotesRequest:\n pass\n\nclass CreateMessageRequest:\n pass\n" + ), + }, + ) + sentinel_path = tmp_path / "promote-epic-sentinel.txt" + + completed = subprocess.run( + [ + sys.executable, + str(projected_script), + "--repo-dir", + str(repo_root), + "--help", + ], + check=False, + capture_output=True, + text=True, + cwd=agent_home, + env={ + "BOOTSTRAP_SENTINEL": str(sentinel_path), + "PYTHONPATH": os.pathsep.join([str(installed_root), str(repo_root / "src")]), + }, + ) + + assert completed.returncode == 0 + assert sentinel_path.read_text(encoding="utf-8") == str( + repo_root / "src" / "atelier" / "trycycle_contract.py" + ) + + def test_projected_render_tickets_section_reorders_repo_src_ahead_of_installed_package( tmp_path: Path, ) -> None: diff --git a/tests/atelier/test_trycycle_contract.py b/tests/atelier/test_trycycle_contract.py index 9006e245..312792aa 100644 --- a/tests/atelier/test_trycycle_contract.py +++ b/tests/atelier/test_trycycle_contract.py @@ -233,6 +233,42 @@ def test_validate_contract_rejects_malformed_approved_timestamp() -> None: assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary +def test_validate_contract_rejects_date_only_approved_timestamp() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + "trycycle.approved_by: atelier/planner/codex/p1\n" + "trycycle.approved_at: 2026-03-27\n" + "trycycle.approval_message_id: at-msg.1\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + + +def test_validate_contract_rejects_naive_approved_timestamp() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: approved\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + "trycycle.approved_by: atelier/planner/codex/p1\n" + "trycycle.approved_at: 2026-03-27T01:00:00\n" + "trycycle.approval_message_id: at-msg.1\n" + ) + } + + result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + + assert result.ok is False + assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + + def test_validate_contract_rejects_malformed_approval_message_id() -> None: issue = { "description": ( From 24c15e56c0d474ac00bda9da3415c4dad6166a92 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Fri, 27 Mar 2026 08:40:13 -0700 Subject: [PATCH 13/16] refactor(planner): rename strategy label to refined - rename the shared planner/worker contract module and APIs to refined terminology\n- switch metadata defaults to execution.strategy/planning.* while preserving legacy trycycle key compatibility reads\n- update planner skills, template guidance, and worker startup claim eligibility wiring\n- add the plan-refined-deliberation skill and refresh coverage/docs for refined planning semantics --- docs/behavior.md | 24 +- ...ycycle-ready-planner-contract-test-plan.md | 427 ++++++++---------- ...6-03-26-trycycle-ready-planner-contract.md | 124 +++-- ...ntract.py => refined_planning_contract.py} | 181 +++++--- .../skills/plan-changeset-guardrails/SKILL.md | 10 +- .../scripts/check_guardrails.py | 24 +- src/atelier/skills/plan-changesets/SKILL.md | 12 +- src/atelier/skills/plan-promote-epic/SKILL.md | 19 +- .../plan-promote-epic/scripts/promote_epic.py | 51 ++- .../skills/plan-refined-deliberation/SKILL.md | 63 +++ src/atelier/templates/AGENTS.planner.md.tmpl | 24 +- src/atelier/worker/session/startup.py | 44 +- src/atelier/worker/work_startup_runtime.py | 24 +- .../test_plan_changeset_guardrails_script.py | 52 +-- .../skills/test_plan_promote_epic_script.py | 58 +-- .../test_projected_skill_runtime_bootstrap.py | 30 +- tests/atelier/test_planner_agents_template.py | 9 +- ...t.py => test_refined_planning_contract.py} | 182 ++++---- tests/atelier/test_skills.py | 10 +- tests/atelier/worker/test_lifecycle_matrix.py | 36 +- .../worker/test_session_next_changeset.py | 12 +- tests/atelier/worker/test_session_startup.py | 91 ++-- .../worker/test_work_startup_runtime.py | 36 +- 23 files changed, 823 insertions(+), 720 deletions(-) rename src/atelier/{trycycle_contract.py => refined_planning_contract.py} (65%) create mode 100644 src/atelier/skills/plan-refined-deliberation/SKILL.md rename tests/atelier/{test_trycycle_contract.py => test_refined_planning_contract.py} (60%) diff --git a/docs/behavior.md b/docs/behavior.md index bd66620c..69cc89aa 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -133,23 +133,23 @@ documented in `docs/beads-prefix-migration.md`. Repository-local Beads stores (for example `/.beads`) are not used for Atelier planning state. They are treated as external ticket sources. -## Trycycle-targeted readiness contract +## Refined Planning Readiness Contract -- A changeset is trycycle-targeted only when metadata includes - `trycycle.targeted: true`. -- Targeted changesets must include a typed `trycycle.contract_json` payload. -- Planner guardrails fail closed when targeted contract data is missing, +- A changeset is refined only when metadata includes + `execution.strategy: refined`. +- Refined changesets must include a typed `planning.contract_json` payload. +- Planner guardrails fail closed when refined contract data is missing, malformed, or conflicts with finalize semantics. -- Targeted planning remains in review with - `trycycle.plan_stage: planning_in_review` until operator approval. -- Promotion approval persists auditable metadata: - `trycycle.plan_stage=approved`, `trycycle.approved_by`, - `trycycle.approved_at`, and `trycycle.approval_message_id`. +- Refined planning remains in review with `planning.stage: planning_in_review` + until operator approval. +- Promotion approval persists auditable metadata: `planning.stage=approved`, + `planning.approved_by`, `planning.approved_at`, and + `planning.approval_message_id`. - Worker startup applies the same shared validator across next-changeset, explicit review-feedback, merge-conflict, and global selector paths. -- Startup claim selection fails closed for targeted changesets that are not +- Startup claim selection fails closed for refined changesets that are not approved or have invalid contract evidence. -- Non-targeted changesets keep existing startup/promotion behavior. +- Non-refined changesets keep existing startup/promotion behavior. ## Command behavior (high level) diff --git a/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md b/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md index f002d7b3..06718228 100644 --- a/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md +++ b/docs/plans/2026-03-26-trycycle-ready-planner-contract-test-plan.md @@ -1,6 +1,7 @@ # Trycycle-Ready Planner Contract Test Plan ## Strategy reconciliation + The transcript goal and the implementation plan are aligned: ship a fail-closed planner/worker contract for trycycle-targeted changesets and record auditable approval evidence before work becomes runnable. @@ -10,15 +11,15 @@ No strategy changes requiring user approval were identified. Adjustments made during reconciliation: 1. Expanded worker claim-gate coverage to include global review-feedback and - global merge-conflict startup paths, not only explicit-epic paths. - Why: the implementation plan requires all startup claim sources to share the - same fail-closed gate. + global merge-conflict startup paths, not only explicit-epic paths. Why: the + implementation plan requires all startup claim sources to share the same + fail-closed gate. 1. Added differential drift checks that compare planner and worker readiness - outcomes through the shared validator boundary. - Why: the implementation plan requires one reused validator and deterministic - readiness semantics. + outcomes through the shared validator boundary. Why: the implementation plan + requires one reused validator and deterministic readiness semantics. ## Harness requirements + 1. **Trycycle metadata fixture builder (new, low complexity)** - What it does: builds issue payloads with description-field metadata for `trycycle.targeted`, `trycycle.contract_json`, `trycycle.plan_stage`, and @@ -47,309 +48,241 @@ Adjustments made during reconciliation: - Depends-on tests: 19. ## Test plan -1. **Name:** Targeted changeset without `trycycle.contract_json` is rejected - by readiness evaluation - **Type:** regression - **Disposition:** new - **Harness:** Trycycle metadata fixture builder - **Preconditions:** Issue description contains `trycycle.targeted: true` and - omits `trycycle.contract_json`. + +1. **Name:** Targeted changeset without `trycycle.contract_json` is rejected by + readiness evaluation **Type:** regression **Disposition:** new **Harness:** + Trycycle metadata fixture builder **Preconditions:** Issue description + contains `trycycle.targeted: true` and omits `trycycle.contract_json`. **Actions:** Call `evaluate_issue_trycycle_readiness(issue)` from - `src/atelier/trycycle_contract.py`. - **Expected outcome:** Result is fail-closed (`ok=False`, targeted true) with - a deterministic error mentioning missing `trycycle.contract_json`. - Source: implementation plan, User-Visible Behavior Contract items 1-3 and 6. - **Interactions:** Description-field parser and contract JSON edge parsing. + `src/atelier/trycycle_contract.py`. **Expected outcome:** Result is + fail-closed (`ok=False`, targeted true) with a deterministic error mentioning + missing `trycycle.contract_json`. Source: implementation plan, User-Visible + Behavior Contract items 1-3 and 6. **Interactions:** Description-field parser + and contract JSON edge parsing. 1. **Name:** Valid contract in `planning_in_review` is planner-valid but not - worker-claim-eligible - **Type:** invariant - **Disposition:** new - **Harness:** Trycycle metadata fixture builder - **Preconditions:** Targeted issue has valid typed contract JSON and - `trycycle.plan_stage: planning_in_review` with no approval fields. - **Actions:** Call readiness evaluator and claim-eligibility helper used by - worker startup. - **Expected outcome:** Contract validation passes, but claim eligibility is - false until explicit approval metadata exists. - Source: implementation plan, User-Visible Behavior Contract items 3-5 and - Contracts and Invariants item 5. - **Interactions:** Shared validator output consumed by planner and worker. - -1. **Name:** Approval evidence requires all audit fields - **Type:** boundary - **Disposition:** new - **Harness:** Trycycle metadata fixture builder + worker-claim-eligible **Type:** invariant **Disposition:** new **Harness:** + Trycycle metadata fixture builder **Preconditions:** Targeted issue has valid + typed contract JSON and `trycycle.plan_stage: planning_in_review` with no + approval fields. **Actions:** Call readiness evaluator and claim-eligibility + helper used by worker startup. **Expected outcome:** Contract validation + passes, but claim eligibility is false until explicit approval metadata + exists. Source: implementation plan, User-Visible Behavior Contract items 3-5 + and Contracts and Invariants item 5. **Interactions:** Shared validator + output consumed by planner and worker. + +1. **Name:** Approval evidence requires all audit fields **Type:** boundary + **Disposition:** new **Harness:** Trycycle metadata fixture builder **Preconditions:** Targeted issue has valid contract and stage `approved`, but one of `approved_by`, `approved_at`, or `approval_message_id` is - missing/malformed. - **Actions:** Evaluate readiness for each missing or malformed field case. - **Expected outcome:** Each case is non-eligible with deterministic - diagnostics naming the missing evidence field. - Source: implementation plan, User-Visible Behavior Contract item 4. - **Interactions:** Field normalization and timestamp parsing logic. - -1. **Name:** Completion-definition lifecycle conflicts are rejected - **Type:** boundary - **Disposition:** new - **Harness:** Trycycle metadata fixture builder + missing/malformed. **Actions:** Evaluate readiness for each missing or + malformed field case. **Expected outcome:** Each case is non-eligible with + deterministic diagnostics naming the missing evidence field. Source: + implementation plan, User-Visible Behavior Contract item 4. **Interactions:** + Field normalization and timestamp parsing logic. + +1. **Name:** Completion-definition lifecycle conflicts are rejected **Type:** + boundary **Disposition:** new **Harness:** Trycycle metadata fixture builder **Preconditions:** Contract completion definition conflicts with finalize semantics (for example, allows close without terminal PR state or integrated - SHA proof). - **Actions:** Evaluate readiness on conflicting completion definitions. - **Expected outcome:** Readiness fails with conflict diagnostics; compliant - definitions pass. - Source: implementation plan, Contracts and Invariants item 7. - **Interactions:** Shared completion-definition checker with lifecycle rules. + SHA proof). **Actions:** Evaluate readiness on conflicting completion + definitions. **Expected outcome:** Readiness fails with conflict diagnostics; + compliant definitions pass. Source: implementation plan, Contracts and + Invariants item 7. **Interactions:** Shared completion-definition checker + with lifecycle rules. 1. **Name:** Guardrails report fails targeted changesets missing contract - payload - **Type:** scenario - **Disposition:** extend - **Harness:** Planner script harness (`check_guardrails.py` module entrypoint) - **Preconditions:** Guardrails target list contains at least one targeted - changeset without `trycycle.contract_json`. - **Actions:** Run `_evaluate_guardrails(...)` and `main()` paths. - **Expected outcome:** Report includes targeted-contract violation text and - retains existing guardrail reporting format. - Source: implementation plan Task 2 and User-Visible Behavior Contract items - 2-3. + payload **Type:** scenario **Disposition:** extend **Harness:** Planner + script harness (`check_guardrails.py` module entrypoint) **Preconditions:** + Guardrails target list contains at least one targeted changeset without + `trycycle.contract_json`. **Actions:** Run `_evaluate_guardrails(...)` and + `main()` paths. **Expected outcome:** Report includes targeted-contract + violation text and retains existing guardrail reporting format. Source: + implementation plan Task 2 and User-Visible Behavior Contract items 2-3. **Interactions:** Planner contract checks plus trycycle validator reuse. -1. **Name:** Guardrails accept fully valid targeted planning payload - **Type:** integration - **Disposition:** extend - **Harness:** Planner script harness (`check_guardrails.py`) - **Preconditions:** Targeted issue has valid contract payload with required - plan-stage semantics for planner readiness. +1. **Name:** Guardrails accept fully valid targeted planning payload **Type:** + integration **Disposition:** extend **Harness:** Planner script harness + (`check_guardrails.py`) **Preconditions:** Targeted issue has valid contract + payload with required plan-stage semantics for planner readiness. **Actions:** Run guardrail evaluation for targeted child and epic-as-single - paths. - **Expected outcome:** No trycycle-targeted violations are emitted for valid - payloads. - Source: implementation plan Task 2 and Strategy Gate Decision 3. - **Interactions:** Cross-check between authoring-contract and trycycle checks. + paths. **Expected outcome:** No trycycle-targeted violations are emitted for + valid payloads. Source: implementation plan Task 2 and Strategy Gate Decision + 3\. **Interactions:** Cross-check between authoring-contract and trycycle + checks. 1. **Name:** Promotion blocks targeted work when contract validation fails - **Type:** scenario - **Disposition:** extend - **Harness:** Promotion approval-audit harness (`promote_epic.py` main path) - **Preconditions:** Deferred epic has targeted executable unit with invalid or - missing trycycle contract. - **Actions:** Run promotion script with and without `--yes`. + **Type:** scenario **Disposition:** extend **Harness:** Promotion + approval-audit harness (`promote_epic.py` main path) **Preconditions:** + Deferred epic has targeted executable unit with invalid or missing trycycle + contract. **Actions:** Run promotion script with and without `--yes`. **Expected outcome:** Script exits fail-closed before lifecycle transitions; - stderr includes deterministic trycycle validation failure. - Source: implementation plan Task 3 and User-Visible Behavior Contract item 3. + stderr includes deterministic trycycle validation failure. Source: + implementation plan Task 3 and User-Visible Behavior Contract item 3. **Interactions:** Store transition API, preview renderer, shared validator. 1. **Name:** Promotion records complete approval audit metadata for targeted - child changesets - **Type:** integration - **Disposition:** extend - **Harness:** Promotion approval-audit harness - **Preconditions:** Deferred epic with deferred targeted child changeset, - valid contract, explicit `--yes`. + child changesets **Type:** integration **Disposition:** extend **Harness:** + Promotion approval-audit harness **Preconditions:** Deferred epic with + deferred targeted child changeset, valid contract, explicit `--yes`. **Actions:** Run `promote_epic.py --yes`, then inspect persisted metadata. **Expected outcome:** Promoted targeted child includes `trycycle.plan_stage=approved`, `approved_by`, `approved_at`, and - `approval_message_id`, and audit evidence is persisted/readable. - Source: implementation plan User-Visible Behavior Contract item 4 and Task 3. + `approval_message_id`, and audit evidence is persisted/readable. Source: + implementation plan User-Visible Behavior Contract item 4 and Task 3. **Interactions:** Description-field updates, message-thread persistence, lifecycle transition sequencing. 1. **Name:** Promotion records identical approval metadata for epic-as-single - executable path - **Type:** integration - **Disposition:** extend - **Harness:** Promotion approval-audit harness - **Preconditions:** Deferred targeted epic has no child changesets and valid - contract payload. - **Actions:** Run `promote_epic.py --yes` for single-unit epic path. - **Expected outcome:** Epic record receives the same approval audit fields and - evidence message behavior as child-changeset path. - Source: implementation plan Task 3 and File Structure note for - epic-as-single-unit coverage. - **Interactions:** Single-unit execution path in promotion script and store. + executable path **Type:** integration **Disposition:** extend **Harness:** + Promotion approval-audit harness **Preconditions:** Deferred targeted epic + has no child changesets and valid contract payload. **Actions:** Run + `promote_epic.py --yes` for single-unit epic path. **Expected outcome:** Epic + record receives the same approval audit fields and evidence message behavior + as child-changeset path. Source: implementation plan Task 3 and File + Structure note for epic-as-single-unit coverage. **Interactions:** + Single-unit execution path in promotion script and store. 1. **Name:** Next-changeset selection skips unapproved targeted candidates - **Type:** scenario - **Disposition:** extend - **Harness:** Startup protocol fake-service extension - **Preconditions:** Descendant candidate list contains targeted unapproved - candidate first, followed by eligible non-targeted candidate. - **Actions:** Run `next_changeset_service(...)`. - **Expected outcome:** First candidate is rejected by claim gate; second is - returned. + **Type:** scenario **Disposition:** extend **Harness:** Startup protocol + fake-service extension **Preconditions:** Descendant candidate list contains + targeted unapproved candidate first, followed by eligible non-targeted + candidate. **Actions:** Run `next_changeset_service(...)`. **Expected + outcome:** First candidate is rejected by claim gate; second is returned. Source: implementation plan User-Visible Behavior Contract item 5. **Interactions:** Dependency checks, review-state checks, and trycycle gate order. 1. **Name:** Explicit-epic startup path fails closed when only targeted - unapproved work exists - **Type:** scenario - **Disposition:** extend - **Harness:** Startup protocol fake-service extension - **Preconditions:** `explicit_epic_id` points to claimable epic; next - executable candidate is targeted and unapproved. - **Actions:** Run `run_startup_contract_service(...)` with explicit epic mode. - **Expected outcome:** Startup does not return a runnable changeset and exits - with deterministic fail-closed reason/output. + unapproved work exists **Type:** scenario **Disposition:** extend + **Harness:** Startup protocol fake-service extension **Preconditions:** + `explicit_epic_id` points to claimable epic; next executable candidate is + targeted and unapproved. **Actions:** Run `run_startup_contract_service(...)` + with explicit epic mode. **Expected outcome:** Startup does not return a + runnable changeset and exits with deterministic fail-closed reason/output. Source: implementation plan User-Visible Behavior Contract item 5 and - Contracts and Invariants item 6. - **Interactions:** Explicit epic branch, claimability checks, emission path. + Contracts and Invariants item 6. **Interactions:** Explicit epic branch, + claimability checks, emission path. 1. **Name:** Explicit merge-conflict selection cannot bypass trycycle approval - gate - **Type:** scenario - **Disposition:** extend - **Harness:** Startup protocol fake-service extension - **Preconditions:** PR startup context enabled; merge-conflict selector - returns targeted unapproved changeset. - **Actions:** Run startup contract explicit path with - `select_conflicted_changeset` returning that candidate. - **Expected outcome:** Startup rejects the candidate and continues scanning or - exits non-claimable; it does not return the blocked changeset. - Source: implementation plan User-Visible Behavior Contract item 5. + gate **Type:** scenario **Disposition:** extend **Harness:** Startup protocol + fake-service extension **Preconditions:** PR startup context enabled; + merge-conflict selector returns targeted unapproved changeset. **Actions:** + Run startup contract explicit path with `select_conflicted_changeset` + returning that candidate. **Expected outcome:** Startup rejects the candidate + and continues scanning or exits non-claimable; it does not return the blocked + changeset. Source: implementation plan User-Visible Behavior Contract item 5. **Interactions:** Merge-conflict selector, startup reason routing. 1. **Name:** Explicit review-feedback selection cannot bypass trycycle approval - gate - **Type:** scenario - **Disposition:** extend - **Harness:** Startup protocol fake-service extension - **Preconditions:** PR startup context enabled; review-feedback selector - returns targeted unapproved changeset. - **Actions:** Run startup contract explicit path with - `select_review_feedback_changeset`. + gate **Type:** scenario **Disposition:** extend **Harness:** Startup protocol + fake-service extension **Preconditions:** PR startup context enabled; + review-feedback selector returns targeted unapproved changeset. **Actions:** + Run startup contract explicit path with `select_review_feedback_changeset`. **Expected outcome:** Candidate is rejected by the shared gate; startup does - not claim it. - Source: implementation plan User-Visible Behavior Contract item 5. - **Interactions:** Review-feedback selector and claim gating. + not claim it. Source: implementation plan User-Visible Behavior Contract item + 5\. **Interactions:** Review-feedback selector and claim gating. 1. **Name:** Global review-feedback and merge-conflict selectors are gated with - the same trycycle check - **Type:** integration - **Disposition:** extend - **Harness:** Startup protocol fake-service extension - **Preconditions:** Global selectors return targeted unapproved candidates - from other epic families. - **Actions:** Run non-explicit startup contract flow through global selector - stages. - **Expected outcome:** Global candidates are rejected identically to explicit - selectors; no bypass path remains. - Source: implementation plan Contracts and Invariants item 4 and Task 4. + the same trycycle check **Type:** integration **Disposition:** extend + **Harness:** Startup protocol fake-service extension **Preconditions:** + Global selectors return targeted unapproved candidates from other epic + families. **Actions:** Run non-explicit startup contract flow through global + selector stages. **Expected outcome:** Global candidates are rejected + identically to explicit selectors; no bypass path remains. Source: + implementation plan Contracts and Invariants item 4 and Task 4. **Interactions:** Global selector cache, claimability checks, fallback paths. 1. **Name:** Approved targeted changesets remain selectable across all claim - sources - **Type:** invariant - **Disposition:** new - **Harness:** Startup protocol fake-service extension - **Preconditions:** Targeted candidates include complete approved metadata and - valid contract payload. - **Actions:** Exercise explicit next-changeset, explicit review-feedback, - explicit merge-conflict, and global selector paths. - **Expected outcome:** All paths accept approved targeted candidates when - other lifecycle constraints are satisfied. - Source: implementation plan User-Visible Behavior Contract items 4-5. - **Interactions:** Shared gate with existing claimability predicates. - -1. **Name:** Non-trycycle startup behavior remains unchanged - **Type:** regression - **Disposition:** extend - **Harness:** Existing startup/lifecycle matrix harnesses - **Preconditions:** Issues do not carry `trycycle.targeted: true`. - **Actions:** Re-run existing startup and lifecycle matrix scenarios. - **Expected outcome:** Existing non-trycycle selection and lifecycle outcomes - remain unchanged. - Source: implementation plan User-Visible Behavior Contract item 6 and - Contracts and Invariants item 1. + sources **Type:** invariant **Disposition:** new **Harness:** Startup + protocol fake-service extension **Preconditions:** Targeted candidates + include complete approved metadata and valid contract payload. **Actions:** + Exercise explicit next-changeset, explicit review-feedback, explicit + merge-conflict, and global selector paths. **Expected outcome:** All paths + accept approved targeted candidates when other lifecycle constraints are + satisfied. Source: implementation plan User-Visible Behavior Contract items + 4-5. **Interactions:** Shared gate with existing claimability predicates. + +1. **Name:** Non-trycycle startup behavior remains unchanged **Type:** + regression **Disposition:** extend **Harness:** Existing startup/lifecycle + matrix harnesses **Preconditions:** Issues do not carry + `trycycle.targeted: true`. **Actions:** Re-run existing startup and lifecycle + matrix scenarios. **Expected outcome:** Existing non-trycycle selection and + lifecycle outcomes remain unchanged. Source: implementation plan User-Visible + Behavior Contract item 6 and Contracts and Invariants item 1. **Interactions:** Baseline startup selection, lifecycle matrix predicates. 1. **Name:** Shared-validator differential parity across planner and worker - entrypoints - **Type:** differential - **Disposition:** new - **Harness:** Trycycle metadata fixture builder + startup fake extensions - **Preconditions:** Matrix of targeted payloads: valid approved, valid - unapproved, malformed JSON, missing fields, completion conflict. - **Actions:** Evaluate each payload through - `trycycle_contract.evaluate_issue_trycycle_readiness`, guardrails integration - wrapper, promotion preflight path, and worker claim-eligibility adapter. - **Expected outcome:** All entrypoints agree on eligible vs non-eligible and - deterministic summary class. - Source: implementation plan Strategy Gate Decision 3. - **Interactions:** Cross-module contract reuse and error-summary formatting. + entrypoints **Type:** differential **Disposition:** new **Harness:** Trycycle + metadata fixture builder + startup fake extensions **Preconditions:** Matrix + of targeted payloads: valid approved, valid unapproved, malformed JSON, + missing fields, completion conflict. **Actions:** Evaluate each payload + through `trycycle_contract.evaluate_issue_trycycle_readiness`, guardrails + integration wrapper, promotion preflight path, and worker claim-eligibility + adapter. **Expected outcome:** All entrypoints agree on eligible vs + non-eligible and deterministic summary class. Source: implementation plan + Strategy Gate Decision 3. **Interactions:** Cross-module contract reuse and + error-summary formatting. 1. **Name:** Runtime adapters call shared validator (no ad hoc parsing) - **Type:** integration - **Disposition:** extend - **Harness:** `test_work_startup_runtime.py` adapter harness - **Preconditions:** Monkeypatched validator helper records calls; adapter - service receives targeted and non-targeted issues. - **Actions:** Call `_NextChangesetService.trycycle_claim_eligible(...)` and - `_StartupContractService.trycycle_claim_eligible(...)`. - **Expected outcome:** Both adapters delegate to shared validator and return - normalized `(eligible, reason)` tuples. - Source: implementation plan Task 4 and File Structure notes. - **Interactions:** Runtime adapter boundary to core validator module. + **Type:** integration **Disposition:** extend **Harness:** + `test_work_startup_runtime.py` adapter harness **Preconditions:** + Monkeypatched validator helper records calls; adapter service receives + targeted and non-targeted issues. **Actions:** Call + `_NextChangesetService.trycycle_claim_eligible(...)` and + `_StartupContractService.trycycle_claim_eligible(...)`. **Expected outcome:** + Both adapters delegate to shared validator and return normalized + `(eligible, reason)` tuples. Source: implementation plan Task 4 and File + Structure notes. **Interactions:** Runtime adapter boundary to core validator + module. 1. **Name:** Projected planner scripts keep bootstrap compatibility after adding - shared trycycle contract import - **Type:** integration - **Disposition:** extend - **Harness:** Projected skill bootstrap shim - **Preconditions:** Projected guardrails and promotion scripts run in agent - home with repo/src precedence test setup. - **Actions:** Execute projected script `--help` runs under bootstrap tests. - **Expected outcome:** Scripts import successfully and still resolve repo - source modules before installed packages. - Source: implementation plan Cutover and Regression Risks item 3. - **Interactions:** projected bootstrap, Python path ordering, skill packaging. + shared trycycle contract import **Type:** integration **Disposition:** extend + **Harness:** Projected skill bootstrap shim **Preconditions:** Projected + guardrails and promotion scripts run in agent home with repo/src precedence + test setup. **Actions:** Execute projected script `--help` runs under + bootstrap tests. **Expected outcome:** Scripts import successfully and still + resolve repo source modules before installed packages. Source: implementation + plan Cutover and Regression Risks item 3. **Interactions:** projected + bootstrap, Python path ordering, skill packaging. 1. **Name:** Planner template and skill docs encode trycycle approval gate - contract - **Type:** regression - **Disposition:** extend - **Harness:** `tests/atelier/test_skills.py` and - `tests/atelier/test_planner_agents_template.py` - **Preconditions:** Updated `AGENTS.planner.md.tmpl`, - `plan-changesets/SKILL.md`, `plan-changeset-guardrails/SKILL.md`, and - `plan-promote-epic/SKILL.md`. + contract **Type:** regression **Disposition:** extend **Harness:** + `tests/atelier/test_skills.py` and + `tests/atelier/test_planner_agents_template.py` **Preconditions:** Updated + `AGENTS.planner.md.tmpl`, `plan-changesets/SKILL.md`, + `plan-changeset-guardrails/SKILL.md`, and `plan-promote-epic/SKILL.md`. **Actions:** Run skill/template content assertions against packaged files. **Expected outcome:** Guidance explicitly references trycycle fields, - approval gate, and worker fail-closed behavior. - Source: implementation plan Task 5 and User-Visible Behavior Contract items - 3-5. - **Interactions:** Packaged skill sync/install and template rendering paths. + approval gate, and worker fail-closed behavior. Source: implementation plan + Task 5 and User-Visible Behavior Contract items 3-5. **Interactions:** + Packaged skill sync/install and template rendering paths. ## Coverage summary + Covered action space: -1. Planner guardrail CLI actions: - `--epic-id`, `--changeset-id`, targeted contract validation reporting. -1. Promotion CLI actions: - preview vs `--yes` promotion, targeted validation gate, approval evidence - persistence for child and epic-as-single paths. -1. Worker startup claim actions: - explicit epic, next-changeset, review-feedback, merge-conflict, global - review-feedback, global merge-conflict, and no-bypass fail-closed behavior. -1. Shared validator parity: - planner, promotion, and worker paths all consume one deterministic contract - checker. -1. Packaging and documentation surface: - projected skill bootstrap compatibility plus planner guidance updates. +1. Planner guardrail CLI actions: `--epic-id`, `--changeset-id`, targeted + contract validation reporting. +1. Promotion CLI actions: preview vs `--yes` promotion, targeted validation + gate, approval evidence persistence for child and epic-as-single paths. +1. Worker startup claim actions: explicit epic, next-changeset, review-feedback, + merge-conflict, global review-feedback, global merge-conflict, and no-bypass + fail-closed behavior. +1. Shared validator parity: planner, promotion, and worker paths all consume one + deterministic contract checker. +1. Packaging and documentation surface: projected skill bootstrap compatibility + plus planner guidance updates. Explicit exclusions (per current implementation strategy): 1. End-to-end networked GitHub review API behavior is not exercised directly in - these tests. - Risk: integration bugs in upstream review fetchers could still affect startup - candidate discovery before gating. + these tests. Risk: integration bugs in upstream review fetchers could still + affect startup candidate discovery before gating. 1. Full `atelier work` subprocess E2E is not expanded for this slice; startup - contract/service tests remain the primary behavior proof. - Risk: CLI wiring regressions outside startup contract adapters could require - follow-up command-level tests. + contract/service tests remain the primary behavior proof. Risk: CLI wiring + regressions outside startup contract adapters could require follow-up + command-level tests. 1. No performance benchmark suite is added because this change is contract - gating, not throughput-sensitive logic. - Risk: minimal; catastrophic regressions are still caught by normal test - runtime and CI gates. + gating, not throughput-sensitive logic. Risk: minimal; catastrophic + regressions are still caught by normal test runtime and CI gates. diff --git a/docs/plans/2026-03-26-trycycle-ready-planner-contract.md b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md index a6c19a14..e93462d3 100644 --- a/docs/plans/2026-03-26-trycycle-ready-planner-contract.md +++ b/docs/plans/2026-03-26-trycycle-ready-planner-contract.md @@ -1,6 +1,8 @@ # Trycycle-Ready Planner Contract and Approval Gate Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. **Goal:** Add a fail-closed planner+worker contract so trycycle-targeted changesets cannot be claimed until a validated plan package and explicit @@ -17,7 +19,7 @@ existing lifecycle logic. **Tech Stack:** Python 3.11, Pydantic models, Atelier store/beads adapters, planner skill scripts, worker startup pipeline, pytest. ---- +______________________________________________________________________ ## User-Visible Behavior Contract @@ -32,8 +34,8 @@ planner skill scripts, worker startup pipeline, pytest. - `trycycle.approved_by` - `trycycle.approved_at` - `trycycle.approval_message_id` -1. Worker startup refuses to select or resume trycycle-targeted changesets - whose contract is invalid, missing, or unapproved (including explicit-epic, +1. Worker startup refuses to select or resume trycycle-targeted changesets whose + contract is invalid, missing, or unapproved (including explicit-epic, next-changeset, merge-conflict, and review-feedback selection paths). 1. Non-trycycle changesets retain current behavior with no new gating. @@ -58,18 +60,17 @@ planner skill scripts, worker startup pipeline, pytest. ## Strategy Gate Decisions (Locked) -1. Use namespaced metadata fields, not a new lifecycle status. - Reason: adding lifecycle states would touch broad store/lifecycle/finalize - logic and risks regressions unrelated to #719. +1. Use namespaced metadata fields, not a new lifecycle status. Reason: adding + lifecycle states would touch broad store/lifecycle/finalize logic and risks + regressions unrelated to #719. 1. Use one typed JSON payload field (`trycycle.contract_json`) plus small, - auditable metadata fields for stage/approval. - Reason: typed payload gives strict schema guarantees; small scalar fields - keep approval evidence easy to inspect with current description-field - tooling. -1. Reuse one shared core validator for planner and worker paths. - Reason: avoids drift where planner and worker disagree on readiness. -1. Make worker gate selective (only `trycycle.targeted: true`). - Reason: preserves existing execution behavior for non-trycycle work. + auditable metadata fields for stage/approval. Reason: typed payload gives + strict schema guarantees; small scalar fields keep approval evidence easy to + inspect with current description-field tooling. +1. Reuse one shared core validator for planner and worker paths. Reason: avoids + drift where planner and worker disagree on readiness. +1. Make worker gate selective (only `trycycle.targeted: true`). Reason: + preserves existing execution behavior for non-trycycle work. ## File Structure @@ -79,10 +80,10 @@ planner skill scripts, worker startup pipeline, pytest. - Create: `tests/atelier/test_trycycle_contract.py` - Unit tests for parsing, schema validation, quality checks, and lifecycle completion-definition conflict detection. -- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` - - Invoke shared trycycle validator, surface deterministic violations. - Modify: - `tests/atelier/skills/test_plan_changeset_guardrails_script.py` + `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + - Invoke shared trycycle validator, surface deterministic violations. +- Modify: `tests/atelier/skills/test_plan_changeset_guardrails_script.py` - Add trycycle-targeted valid/invalid/missing-field coverage. - Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` - Enforce trycycle validation before promotion, persist approval metadata, @@ -120,26 +121,28 @@ planner skill scripts, worker startup pipeline, pytest. ## Cutover and Regression Risks -1. Risk: worker refuses all work due to over-broad gating. - Mitigation: gate only when `trycycle.targeted: true`; add explicit tests for - unchanged non-trycycle flow. +1. Risk: worker refuses all work due to over-broad gating. Mitigation: gate only + when `trycycle.targeted: true`; add explicit tests for unchanged non-trycycle + flow. 1. Risk: startup bypasses gate via review-feedback/merge-conflict selection. Mitigation: apply one shared `trycycle_claim_eligible` check before every startup return path that yields a concrete changeset id. 1. Risk: planner script import drift in projected skill runtime tests. Mitigation: update bootstrap fixture module stubs when introducing `trycycle_contract` import. -1. Risk: approval metadata write succeeds partially. - Mitigation: use store/beads update + read-after-write verification (existing - atomic verification pattern used in store adapters). -1. Risk: completion-definition checker blocks valid plans. - Mitigation: codify allowed forms aligned to current worker prompt/finalize - contract and cover with focused tests. +1. Risk: approval metadata write succeeds partially. Mitigation: use store/beads + update + read-after-write verification (existing atomic verification pattern + used in store adapters). +1. Risk: completion-definition checker blocks valid plans. Mitigation: codify + allowed forms aligned to current worker prompt/finalize contract and cover + with focused tests. ### Task 1: Add Typed Trycycle Contract Model and Shared Validator **Files:** + - Create: `src/atelier/trycycle_contract.py` + - Test: `tests/atelier/test_trycycle_contract.py` - [ ] **Step 1: Identify or write the failing test** @@ -163,8 +166,8 @@ def test_validate_contract_accepts_complete_payload() -> None: - [ ] **Step 2: Run test to verify it fails** -Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` -Expected: FAIL (`ImportError` / missing module or missing API). +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` Expected: FAIL +(`ImportError` / missing module or missing API). - [ ] **Step 3: Write minimal implementation** @@ -190,19 +193,19 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR - [ ] **Step 4: Run test to verify it passes** -Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` -Expected: PASS. +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` Expected: PASS. - [ ] **Step 5: Refactor and verify** Tighten parse/normalization boundaries and add dedicated tests for: + - malformed JSON - missing escalation conditions - non-testable acceptance criteria (missing evidence) - conflicting completion definition -Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` -Expected: all PASS. +Run: `uv run pytest tests/atelier/test_trycycle_contract.py -v` Expected: all +PASS. - [ ] **Step 6: Commit** @@ -215,7 +218,10 @@ git commit -m "feat(planner): add typed trycycle contract validator" \ ### Task 2: Enforce Trycycle Contract in Planner Guardrail Checks **Files:** -- Modify: `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + +- Modify: + `src/atelier/skills/plan-changeset-guardrails/scripts/check_guardrails.py` + - Test: `tests/atelier/skills/test_plan_changeset_guardrails_script.py` - [ ] **Step 1: Identify or write the failing test** @@ -260,11 +266,13 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** Add test coverage for: + - valid targeted payload (no violations) - completion-definition conflict violation - explicit `planning_in_review` stage requirement -Run: `uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v` +Run: +`uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v` Expected: all PASS. - [ ] **Step 6: Commit** @@ -279,7 +287,9 @@ git commit -m "feat(planner): validate trycycle-ready contracts in guardrails" \ ### Task 3: Add Promotion-Time Approval Capture and Evidence Persistence **Files:** + - Modify: `src/atelier/skills/plan-promote-epic/scripts/promote_epic.py` + - Test: `tests/atelier/skills/test_plan_promote_epic_script.py` - [ ] **Step 1: Identify or write the failing test** @@ -336,6 +346,7 @@ Expected: PASS. - [ ] **Step 5: Refactor and verify** Add coverage for: + - non-targeted changesets unchanged - target with `planning_in_review` stage transitions to `approved` - persisted approval message id is included in metadata @@ -356,11 +367,17 @@ git commit -m "feat(planner): require and record trycycle operator approval" \ ### Task 4: Gate Worker Claim Selection on Trycycle Readiness **Files:** + - Modify: `src/atelier/worker/session/startup.py` + - Modify: `src/atelier/worker/work_startup_runtime.py` + - Test: `tests/atelier/worker/test_session_startup.py` + - Test: `tests/atelier/worker/test_session_next_changeset.py` + - Test: `tests/atelier/worker/test_work_startup_runtime.py` + - Test: `tests/atelier/worker/test_lifecycle_matrix.py` - [ ] **Step 1: Identify or write the failing test** @@ -389,8 +406,7 @@ def test_merge_conflict_selection_rejects_unapproved_trycycle_changeset() -> Non - [ ] **Step 2: Run test to verify it fails** -Run: -`uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` +Run: `uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` `uv run pytest tests/atelier/worker/test_session_next_changeset.py -k trycycle -v` Expected: FAIL. @@ -432,13 +448,13 @@ def trycycle_claim_eligible(self, issue): - [ ] **Step 4: Run test to verify it passes** -Run: -`uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` +Run: `uv run pytest tests/atelier/worker/test_session_startup.py -k trycycle -v` Expected: PASS. - [ ] **Step 5: Refactor and verify** Add matrix/regression coverage: + - targeted+approved is selectable - targeted+missing approval is not selectable - explicit/global review-feedback and merge-conflict selections cannot bypass @@ -447,11 +463,15 @@ Add matrix/regression coverage: - fake/test services implement the new protocol method Run: + - `uv run pytest tests/atelier/worker/test_session_startup.py -v` + - `uv run pytest tests/atelier/worker/test_session_next_changeset.py -v` + - `uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v` -- `uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v` -Expected: all PASS. + +- `uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v` Expected: all + PASS. - [ ] **Step 6: Commit** @@ -468,13 +488,21 @@ git commit -m "feat(worker): fail closed on non-approved trycycle changesets" \ ### Task 5: Keep Skill Runtime Bootstrap and Planner Guidance Consistent **Files:** + - Modify: `tests/atelier/skills/test_projected_skill_runtime_bootstrap.py` + - Modify: `src/atelier/templates/AGENTS.planner.md.tmpl` + - Modify: `src/atelier/skills/plan-changesets/SKILL.md` + - Modify: `src/atelier/skills/plan-changeset-guardrails/SKILL.md` + - Modify: `src/atelier/skills/plan-promote-epic/SKILL.md` + - Modify: `docs/behavior.md` + - Test: `tests/atelier/test_planner_agents_template.py` + - Test: `tests/atelier/test_skills.py` - [ ] **Step 1: Identify or write the failing test** @@ -496,8 +524,11 @@ Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Update planner guidance docs/templates to require: + - `@plan-changeset-guardrails` validation for trycycle-targeted work + - explicit operator confirmation and metadata fields + - worker fail-closed expectation for unapproved trycycle changesets - [ ] **Step 4: Run test to verify it passes** @@ -530,11 +561,13 @@ git commit -m "docs(planner): codify trycycle-ready approval workflow" \ ### Task 6: Run Full Verification and Land **Files:** + - Modify: any files touched by fixes discovered during verification. - [ ] **Step 1: Run focused regression suite** Run: + ```bash uv run pytest tests/atelier/test_trycycle_contract.py -v uv run pytest tests/atelier/skills/test_plan_changeset_guardrails_script.py -v @@ -544,16 +577,19 @@ uv run pytest tests/atelier/worker/test_session_next_changeset.py -v uv run pytest tests/atelier/worker/test_work_startup_runtime.py -v uv run pytest tests/atelier/worker/test_lifecycle_matrix.py -v ``` + Expected: all PASS. - [ ] **Step 2: Run canonical project gates** Run: + ```bash just format just lint just test ``` + Expected: all PASS. - [ ] **Step 3: If any gate fails, fix root cause (not tests)** @@ -563,14 +599,16 @@ Prefer production/contract fixes over weakening assertions. - [ ] **Step 4: Re-run failing gate(s) and full required gates** Re-run whichever command failed, then re-run: -`just format && just lint && just test` -Expected: all PASS. +`just format && just lint && just test` Expected: all PASS. - [ ] **Step 5: Final review of behavior invariants** Confirm before merge: + - non-trycycle flows unchanged + - trycycle-targeted flows fail closed unless approved + - approval/evidence metadata is auditable from beads/messages - [ ] **Step 6: Commit verification fixes** diff --git a/src/atelier/trycycle_contract.py b/src/atelier/refined_planning_contract.py similarity index 65% rename from src/atelier/trycycle_contract.py rename to src/atelier/refined_planning_contract.py index 08b07d0e..243ed5e0 100644 --- a/src/atelier/trycycle_contract.py +++ b/src/atelier/refined_planning_contract.py @@ -1,4 +1,4 @@ -"""Trycycle planner/worker contract models and readiness validators.""" +"""Planner/worker refined-planning contract models and readiness validators.""" from __future__ import annotations @@ -12,19 +12,26 @@ from . import beads -_TARGETED_FIELD = "trycycle.targeted" -_CONTRACT_JSON_FIELD = "trycycle.contract_json" -_PLAN_STAGE_FIELD = "trycycle.plan_stage" -_APPROVED_BY_FIELD = "trycycle.approved_by" -_APPROVED_AT_FIELD = "trycycle.approved_at" -_APPROVAL_MESSAGE_ID_FIELD = "trycycle.approval_message_id" +_EXECUTION_STRATEGY_FIELD = "execution.strategy" +_LEGACY_TARGETED_FIELD = "trycycle.targeted" +_CONTRACT_JSON_FIELD = "planning.contract_json" +_LEGACY_CONTRACT_JSON_FIELD = "trycycle.contract_json" +_PLAN_STAGE_FIELD = "planning.stage" +_LEGACY_PLAN_STAGE_FIELD = "trycycle.plan_stage" +_APPROVED_BY_FIELD = "planning.approved_by" +_LEGACY_APPROVED_BY_FIELD = "trycycle.approved_by" +_APPROVED_AT_FIELD = "planning.approved_at" +_LEGACY_APPROVED_AT_FIELD = "trycycle.approved_at" +_APPROVAL_MESSAGE_ID_FIELD = "planning.approval_message_id" +_LEGACY_APPROVAL_MESSAGE_ID_FIELD = "trycycle.approval_message_id" _APPROVAL_MESSAGE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._:-]*$") _PLANNING_IN_REVIEW = "planning_in_review" _APPROVED = "approved" +_REFINED_STRATEGY = "refined" class AcceptanceCriterion(BaseModel): - """One measurable acceptance criterion for trycycle planning.""" + """One measurable acceptance criterion for refined planning.""" model_config = ConfigDict(extra="forbid") @@ -103,8 +110,8 @@ def _validate_terminal_states( return value -class TrycycleContract(BaseModel): - """Typed trycycle contract payload stored under ``trycycle.contract_json``.""" +class RefinedPlanningContract(BaseModel): + """Typed refined contract payload stored under ``planning.contract_json``.""" model_config = ConfigDict(extra="forbid") @@ -139,7 +146,7 @@ class ReadinessResult(BaseModel): model_config = ConfigDict(frozen=True) - targeted: bool + refined: bool contract_present: bool stage: str | None ok: bool @@ -148,6 +155,12 @@ class ReadinessResult(BaseModel): errors: tuple[str, ...] claim_blockers: tuple[str, ...] + @property + def targeted(self) -> bool: + """Compatibility alias for older call sites.""" + + return self.refined + @property def summary(self) -> str: """Return deterministic human-readable readiness diagnostics.""" @@ -157,14 +170,14 @@ def summary(self) -> str: return "; ".join(diagnostics) -def parse_contract_json(raw_contract_json: str) -> TrycycleContract: - """Parse and validate a serialized ``trycycle.contract_json`` payload. +def parse_contract_json(raw_contract_json: str) -> RefinedPlanningContract: + """Parse and validate a serialized ``planning.contract_json`` payload. Args: raw_contract_json: Raw JSON string captured in issue metadata. Returns: - Parsed and validated ``TrycycleContract`` model. + Parsed and validated ``RefinedPlanningContract`` model. Raises: ValueError: Raised when JSON is malformed or schema validation fails. @@ -173,19 +186,19 @@ def parse_contract_json(raw_contract_json: str) -> TrycycleContract: try: payload = json.loads(raw_contract_json) except json.JSONDecodeError as exc: # pragma: no cover - exercised via wrapper path - raise ValueError("trycycle.contract_json must be valid JSON") from exc + raise ValueError("planning.contract_json must be valid JSON") from exc try: - return TrycycleContract.model_validate(payload) + return RefinedPlanningContract.model_validate(payload) except ValidationError as exc: # pragma: no cover - exercised via wrapper path first_error = exc.errors(include_url=False)[0] location = ".".join(str(part) for part in first_error.get("loc", ()) if part is not None) detail = first_error.get("msg", "invalid contract payload") if location: - raise ValueError(f"trycycle.contract_json invalid at {location}: {detail}") from exc - raise ValueError(f"trycycle.contract_json invalid: {detail}") from exc + raise ValueError(f"planning.contract_json invalid at {location}: {detail}") from exc + raise ValueError(f"planning.contract_json invalid: {detail}") from exc -def serialize_contract(contract: TrycycleContract) -> str: +def serialize_contract(contract: RefinedPlanningContract) -> str: """Serialize a validated contract for deterministic metadata persistence. Args: @@ -198,8 +211,8 @@ def serialize_contract(contract: TrycycleContract) -> str: return json.dumps(contract.model_dump(mode="json"), separators=(",", ":"), sort_keys=True) -def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessResult: - """Evaluate planner/worker trycycle readiness for one issue payload. +def evaluate_issue_refined_planning_readiness(issue: Mapping[str, object]) -> ReadinessResult: + """Evaluate planner/worker refined readiness for one issue payload. Args: issue: Issue-like mapping containing at least an optional description. @@ -210,10 +223,10 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR description = issue.get("description") fields = beads.parse_description_fields(description if isinstance(description, str) else "") - targeted = _parse_bool(fields.get(_TARGETED_FIELD)) - if not targeted: + refined = _is_refined_strategy(fields) + if not refined: return ReadinessResult( - targeted=False, + refined=False, contract_present=False, stage=None, ok=True, @@ -226,20 +239,23 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR errors: list[str] = [] claim_blockers: list[str] = [] - stage_raw = fields.get(_PLAN_STAGE_FIELD) + stage_raw = _field_value(fields, primary=_PLAN_STAGE_FIELD, legacy=_LEGACY_PLAN_STAGE_FIELD) stage = _normalize_field(stage_raw) if stage not in {_PLANNING_IN_REVIEW, _APPROVED}: errors.append( - "targeted changesets require trycycle.plan_stage set to " - "'planning_in_review' or 'approved'" + "refined changesets require planning.stage set to 'planning_in_review' or 'approved'" ) - contract_raw = fields.get(_CONTRACT_JSON_FIELD) + contract_raw = _field_value( + fields, + primary=_CONTRACT_JSON_FIELD, + legacy=_LEGACY_CONTRACT_JSON_FIELD, + ) contract_text = contract_raw.strip() if isinstance(contract_raw, str) else "" contract_present = bool(contract_text) - contract: TrycycleContract | None = None + contract: RefinedPlanningContract | None = None if not contract_present: - errors.append("targeted changesets require trycycle.contract_json") + errors.append("refined changesets require planning.contract_json") else: try: contract = parse_contract_json(contract_text) @@ -254,7 +270,7 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR errors.extend(approval_errors) else: claim_blockers.append( - "targeted changesets require trycycle.plan_stage=approved before worker claim" + "refined changesets require planning.stage=approved before worker claim" ) ok = not errors @@ -263,7 +279,7 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR claim_blockers.extend(errors) return ReadinessResult( - targeted=True, + refined=True, contract_present=contract_present, stage=stage, ok=ok, @@ -274,7 +290,7 @@ def evaluate_issue_trycycle_readiness(issue: Mapping[str, object]) -> ReadinessR ) -def trycycle_claim_eligible(issue: Mapping[str, object]) -> tuple[bool, str | None]: +def refined_planning_claim_eligible(issue: Mapping[str, object]) -> tuple[bool, str | None]: """Return worker-claim eligibility and rejection reason for one issue. Args: @@ -282,13 +298,13 @@ def trycycle_claim_eligible(issue: Mapping[str, object]) -> tuple[bool, str | No Returns: Tuple ``(eligible, reason)`` where reason is populated only for blocked - trycycle-targeted changesets. + refined changesets. """ - readiness = evaluate_issue_trycycle_readiness(issue) - if not readiness.targeted or readiness.claim_eligible: + readiness = evaluate_issue_refined_planning_readiness(issue) + if not readiness.refined or readiness.claim_eligible: return True, None - reason = readiness.summary or "targeted changeset is not claim-eligible" + reason = readiness.summary or "refined changeset is not claim-eligible" return False, reason @@ -296,7 +312,7 @@ def approval_evidence_summary(issue: Mapping[str, object]) -> str: """Render a concise audit summary for persisted approval metadata. Args: - issue: Issue payload that may contain trycycle approval metadata fields. + issue: Issue payload that may contain refined approval metadata fields. Returns: Human-readable approval evidence summary string. @@ -304,21 +320,56 @@ def approval_evidence_summary(issue: Mapping[str, object]) -> str: description = issue.get("description") fields = beads.parse_description_fields(description if isinstance(description, str) else "") + stage = _normalize_field( + _field_value( + fields, + primary=_PLAN_STAGE_FIELD, + legacy=_LEGACY_PLAN_STAGE_FIELD, + ) + ) + approved_by = _normalize_field( + _field_value(fields, primary=_APPROVED_BY_FIELD, legacy=_LEGACY_APPROVED_BY_FIELD) + ) + approved_at = _normalize_field( + _field_value(fields, primary=_APPROVED_AT_FIELD, legacy=_LEGACY_APPROVED_AT_FIELD) + ) + approval_message_id = _normalize_field( + _field_value( + fields, + primary=_APPROVAL_MESSAGE_ID_FIELD, + legacy=_LEGACY_APPROVAL_MESSAGE_ID_FIELD, + ) + ) parts = [ - f"stage={_normalize_field(fields.get(_PLAN_STAGE_FIELD)) or '(missing)'}", - f"approved_by={_normalize_field(fields.get(_APPROVED_BY_FIELD)) or '(missing)'}", - f"approved_at={_normalize_field(fields.get(_APPROVED_AT_FIELD)) or '(missing)'}", - "approval_message_id=" - f"{_normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) or '(missing)'}", + f"stage={stage or '(missing)'}", + f"approved_by={approved_by or '(missing)'}", + f"approved_at={approved_at or '(missing)'}", + f"approval_message_id={approval_message_id or '(missing)'}", ] - return "trycycle approval evidence: " + ", ".join(parts) + return "refined approval evidence: " + ", ".join(parts) -def _parse_bool(raw: str | None) -> bool: - value = _normalize_field(raw) - if value is None: - return False - return value in {"1", "true", "yes", "on"} +def _is_refined_strategy(fields: Mapping[str, str]) -> bool: + strategy = _normalize_field(fields.get(_EXECUTION_STRATEGY_FIELD)) + if strategy == _REFINED_STRATEGY: + return True + legacy_targeted = _normalize_field(fields.get(_LEGACY_TARGETED_FIELD)) + return legacy_targeted in {"1", "true", "yes", "on"} + + +def _field_value( + fields: Mapping[str, str], + *, + primary: str, + legacy: str, +) -> str | None: + primary_value = fields.get(primary) + if isinstance(primary_value, str) and primary_value.strip(): + return primary_value + legacy_value = fields.get(legacy) + if isinstance(legacy_value, str) and legacy_value.strip(): + return legacy_value + return None def _normalize_field(raw: str | None) -> str | None: @@ -332,19 +383,29 @@ def _normalize_field(raw: str | None) -> str | None: def _approval_errors(fields: Mapping[str, str]) -> list[str]: errors: list[str] = [] - approved_by = _normalize_field(fields.get(_APPROVED_BY_FIELD)) + approved_by = _normalize_field( + _field_value(fields, primary=_APPROVED_BY_FIELD, legacy=_LEGACY_APPROVED_BY_FIELD) + ) if approved_by is None: - errors.append("approved stage requires trycycle.approved_by") - approved_at_raw = _normalize_field(fields.get(_APPROVED_AT_FIELD)) + errors.append("approved stage requires planning.approved_by") + approved_at_raw = _normalize_field( + _field_value(fields, primary=_APPROVED_AT_FIELD, legacy=_LEGACY_APPROVED_AT_FIELD) + ) if approved_at_raw is None: - errors.append("approved stage requires trycycle.approved_at") + errors.append("approved stage requires planning.approved_at") elif not _is_valid_iso_timestamp(approved_at_raw): - errors.append("trycycle.approved_at must be an ISO-8601 timestamp") - approval_message_id = _normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) + errors.append("planning.approved_at must be an ISO-8601 timestamp") + approval_message_id = _normalize_field( + _field_value( + fields, + primary=_APPROVAL_MESSAGE_ID_FIELD, + legacy=_LEGACY_APPROVAL_MESSAGE_ID_FIELD, + ) + ) if approval_message_id is None: - errors.append("approved stage requires trycycle.approval_message_id") + errors.append("approved stage requires planning.approval_message_id") elif not _APPROVAL_MESSAGE_ID_PATTERN.fullmatch(approval_message_id): - errors.append("trycycle.approval_message_id must be an identifier") + errors.append("planning.approval_message_id must be an identifier") return errors @@ -380,12 +441,12 @@ def _completion_definition_conflicts(definition: CompletionDefinition) -> list[s "AcceptanceCriterion", "CompletionDefinition", "ReadinessResult", + "RefinedPlanningContract", "RiskItem", "ScopeBoundary", - "TrycycleContract", "approval_evidence_summary", - "evaluate_issue_trycycle_readiness", + "evaluate_issue_refined_planning_readiness", "parse_contract_json", + "refined_planning_claim_eligible", "serialize_contract", - "trycycle_claim_eligible", ] diff --git a/src/atelier/skills/plan-changeset-guardrails/SKILL.md b/src/atelier/skills/plan-changeset-guardrails/SKILL.md index e7829eaa..2404cce8 100644 --- a/src/atelier/skills/plan-changeset-guardrails/SKILL.md +++ b/src/atelier/skills/plan-changeset-guardrails/SKILL.md @@ -35,9 +35,9 @@ 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. -- For trycycle-targeted executable units, require `trycycle.contract_json` and - `trycycle.plan_stage: planning_in_review`. -- Surface deterministic errors from the shared trycycle validator, including +- For refined executable units, require `planning.contract_json` and + `planning.stage: planning_in_review`. +- Surface deterministic errors from the shared refined validator, including malformed JSON, missing required payload fields, and completion-definition lifecycle conflicts. @@ -57,8 +57,8 @@ description: >- - If a large estimate is found (>800), ensure approval is recorded. - When lifecycle/contract invariant terms are present, verify the invariant impact map and re-split guidance fields are present. - - If `trycycle.targeted: true`, run shared trycycle validation and require - `trycycle.plan_stage: planning_in_review` before promotion. + - If `execution.strategy: refined`, run shared refined validation and require + `planning.stage: planning_in_review` before promotion. 1. If an epic has exactly one child changeset, require explicit decomposition rationale in the epic or child notes/description. 1. Summarize any violations and send a message to the planner/overseer with 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 8d0305af..62f80ced 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,9 @@ 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.trycycle_contract import evaluate_issue_trycycle_readiness # noqa: E402 +from atelier.refined_planning_contract import ( + evaluate_issue_refined_planning_readiness, # noqa: E402 +) _LOC_TRIGGER = re.compile(r"\b(?:loc|estimate)\b", re.IGNORECASE) _NUMBER = re.compile(r"\b\d{2,5}\b") @@ -200,7 +202,7 @@ def _cross_cutting_corpus( return "\n".join( line for line in corpus.splitlines() - if not line.strip().lower().startswith("trycycle.contract_json:") + if not line.strip().lower().startswith("planning.contract_json:") ) @@ -287,18 +289,16 @@ def _append_violation(message: str) -> None: for issue in target_changesets: issue_id = _issue_id(issue) or "(unknown)" - trycycle_readiness = evaluate_issue_trycycle_readiness(issue) - if trycycle_readiness.targeted: - if not trycycle_readiness.contract_present: - _append_violation( - f"{issue_id}: targeted changesets require trycycle.contract_json." - ) - if trycycle_readiness.stage != "planning_in_review": + refined_readiness = evaluate_issue_refined_planning_readiness(issue) + if refined_readiness.refined: + if not refined_readiness.contract_present: + _append_violation(f"{issue_id}: refined changesets require planning.contract_json.") + if refined_readiness.stage != "planning_in_review": _append_violation( - f"{issue_id}: targeted planner payload must set " - "trycycle.plan_stage: planning_in_review." + f"{issue_id}: refined planner payload must set " + "planning.stage: planning_in_review." ) - for error in trycycle_readiness.errors: + for error in refined_readiness.errors: _append_violation(f"{issue_id}: {error}") inherited_context = ( diff --git a/src/atelier/skills/plan-changesets/SKILL.md b/src/atelier/skills/plan-changesets/SKILL.md index a9e7e473..d9a958a3 100644 --- a/src/atelier/skills/plan-changesets/SKILL.md +++ b/src/atelier/skills/plan-changesets/SKILL.md @@ -31,12 +31,12 @@ create/edit deferred work. - Ensure the executable path for each changeset (the child plus inherited epic context) explicitly records `intent`, `rationale`, `non_goals`, `constraints`, `edge_cases`, `related_context`, and a done definition. -- When a changeset is trycycle-targeted, record: - - `trycycle.targeted: true` - - `trycycle.contract_json: ` - - `trycycle.plan_stage: planning_in_review` -- Do not mark a trycycle-targeted changeset runnable until explicit promotion - approval records `trycycle.plan_stage: approved` and approval metadata. +- When a changeset is refined, record: + - `execution.strategy: refined` + - `planning.contract_json: ` + - `planning.stage: planning_in_review` +- Do not mark a refined changeset runnable until explicit promotion approval + records `planning.stage: approved` and approval metadata. - Shared context can live on the epic; child changesets should add only the delta needed for worker execution. - For lifecycle/contract invariant bugs, record an upfront invariant impact map diff --git a/src/atelier/skills/plan-promote-epic/SKILL.md b/src/atelier/skills/plan-promote-epic/SKILL.md index 42005ae0..d4b4aa05 100644 --- a/src/atelier/skills/plan-promote-epic/SKILL.md +++ b/src/atelier/skills/plan-promote-epic/SKILL.md @@ -34,11 +34,11 @@ description: >- - Do not require child changesets when the epic itself is guardrail-sized. - If the epic has exactly one child changeset, explicit decomposition rationale must be recorded before promotion. -- For trycycle-targeted executable units (`trycycle.targeted: true`), contract +- For refined executable units (`execution.strategy: refined`), contract validation must pass before promotion. Required planning-stage metadata: - - `trycycle.contract_json` - - `trycycle.plan_stage: planning_in_review` -- Worker startup fails closed for targeted units unless approval evidence is + - `planning.contract_json` + - `planning.stage: planning_in_review` +- Worker startup fails closed for refined units unless approval evidence is recorded. ## Steps @@ -89,12 +89,11 @@ description: >- 1. Promote each fully-defined child changeset from `deferred` to `open` regardless of current dependency blockers (dependency graph still gates runnability). -1. For each promoted trycycle-targeted executable unit, persist approval - evidence: - - `trycycle.plan_stage: approved` - - `trycycle.approved_by` - - `trycycle.approved_at` - - `trycycle.approval_message_id` and record an approval evidence note/thread +1. For each promoted refined executable unit, persist approval evidence: + - `planning.stage: approved` + - `planning.approved_by` + - `planning.approved_at` + - `planning.approval_message_id` and record an approval evidence note/thread message. 1. Let dependency resolution determine runnability (`bd ready`) at worker time. 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 afac712e..1420e65f 100644 --- a/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py +++ b/src/atelier/skills/plan-promote-epic/scripts/promote_epic.py @@ -26,7 +26,7 @@ require_runtime_health=__name__ == "__main__", ) -from atelier import trycycle_contract # noqa: E402 +from atelier import refined_planning_contract # noqa: E402 from atelier.beads_context import ( # noqa: E402 resolve_runtime_repo_dir_hint, resolve_skill_beads_context, @@ -37,7 +37,7 @@ class _ApprovalStore(Protocol): - """Minimal typed boundary for trycycle approval persistence helpers.""" + """Minimal typed boundary for refined approval persistence helpers.""" async def create_message(self, request: CreateMessageRequest) -> object: ... @@ -218,11 +218,13 @@ def _issue_metadata_payload(issue: object) -> dict[str, object]: } -def _trycycle_validation_error(issue: object) -> str | None: +def _refined_validation_error(issue: object) -> str | None: issue_id = _issue_text(issue, "id") or "(issue)" - readiness = trycycle_contract.evaluate_issue_trycycle_readiness(_issue_metadata_payload(issue)) - if readiness.targeted and not readiness.ok: - return f"{issue_id} trycycle readiness failed: {readiness.summary}" + readiness = refined_planning_contract.evaluate_issue_refined_planning_readiness( + _issue_metadata_payload(issue) + ) + if readiness.refined and not readiness.ok: + return f"{issue_id} refined readiness failed: {readiness.summary}" return None @@ -244,11 +246,11 @@ def _approval_timestamp() -> str: def _required_operator_id() -> str: operator_id = str(os.environ.get("ATELIER_AGENT_ID") or "").strip() if not operator_id: - raise RuntimeError("ATELIER_AGENT_ID must be set for trycycle approvals") + raise RuntimeError("ATELIER_AGENT_ID must be set for refined approvals") return operator_id -def _record_trycycle_approval( +def _record_refined_approval( *, store: _ApprovalStore, client: _ApprovalClient, @@ -259,17 +261,16 @@ def _record_trycycle_approval( ) -> str: issue_id = _issue_text(issue, "id") if issue_id is None: - raise RuntimeError("targeted trycycle issue is missing id") + raise RuntimeError("refined issue is missing id") issue_payload = _issue_metadata_payload(issue) - evidence = trycycle_contract.approval_evidence_summary(issue_payload) + evidence = refined_planning_contract.approval_evidence_summary(issue_payload) message = asyncio.run( store.create_message( CreateMessageRequest( - title=f"Trycycle approval: {issue_id}", + title=f"Refined approval: {issue_id}", body=( - f"Operator {operator_id} approved trycycle promotion for {issue_id}.\n" - f"{evidence}" + f"Operator {operator_id} approved refined promotion for {issue_id}.\n{evidence}" ), sender=operator_id, thread_id=issue_id, @@ -279,25 +280,25 @@ def _record_trycycle_approval( ) approval_message_id = str(getattr(message, "id", "")).strip() if not approval_message_id: - raise RuntimeError(f"{issue_id} trycycle approval message id missing") + raise RuntimeError(f"{issue_id} refined approval message id missing") approved_at = _approval_timestamp() updated = _update_description_metadata_fields( client=client, issue_id=issue_id, fields={ - "trycycle.plan_stage": "approved", - "trycycle.approved_by": operator_id, - "trycycle.approved_at": approved_at, - "trycycle.approval_message_id": approval_message_id, + "planning.stage": "approved", + "planning.approved_by": operator_id, + "planning.approved_at": approved_at, + "planning.approval_message_id": approval_message_id, }, ) - updated_evidence = trycycle_contract.approval_evidence_summary(updated) + updated_evidence = refined_planning_contract.approval_evidence_summary(updated) asyncio.run( store.append_notes( AppendNotesRequest( issue_id=issue_id, - notes=(f"Trycycle approval recorded. {updated_evidence}",), + notes=(f"Refined approval recorded. {updated_evidence}",), ) ) ) @@ -444,8 +445,8 @@ def main() -> None: else ((epic_issue,) if not changesets and not epic_missing else ()) ) for issue in validation_targets: - if (trycycle_error := _trycycle_validation_error(issue)) is not None: - problems.append(trycycle_error) + if (refined_error := _refined_validation_error(issue)) is not None: + problems.append(refined_error) if problems: raise RuntimeError("; ".join(problems)) @@ -457,14 +458,14 @@ def main() -> None: approval_targets = [ issue for issue in executable_targets - if trycycle_contract.evaluate_issue_trycycle_readiness( + if refined_planning_contract.evaluate_issue_refined_planning_readiness( _issue_metadata_payload(issue) - ).targeted + ).refined ] if approval_targets: operator_id = _required_operator_id() for issue in approval_targets: - _record_trycycle_approval( + _record_refined_approval( store=store, client=client, issue=issue, 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..814e3c69 --- /dev/null +++ b/src/atelier/skills/plan-refined-deliberation/SKILL.md @@ -0,0 +1,63 @@ +--- +name: plan-refined-deliberation +description: >- + Create rigorous planning contracts for refined execution strategy. Use when a + changeset needs explicit objective, scope, evidence, and approval metadata + before workers can claim it. +--- + +# Plan Refined Deliberation + +Use this skill to define *how* planning should be done for high-risk or +cross-cutting work. This is an Atelier-native workflow and does not depend on +external planning skills. + +## Inputs + +- issue_id: Epic or changeset bead id being planned. +- objective: One clear outcome statement. +- scope: Explicit includes/excludes. +- acceptance_criteria: Measurable outcomes plus evidence artifacts. +- verification_plan: Concrete checks/tests to run. +- risks: Risk plus mitigation pairs. +- escalation_conditions: Conditions that require planner/operator intervention. +- beads_dir: Optional Beads store path. +- repo_dir: Optional repo root override. + +## Strategy Contract + +For each executable unit that should use refined planning, record: + +- `execution.strategy: refined` +- `planning.contract_json: ` +- `planning.stage: planning_in_review` + +Contract JSON should include: + +- `objective` +- `non_goals` +- `acceptance_criteria` (with evidence) +- `scope.includes` and `scope.excludes` +- `verification_plan` +- `risks` +- `escalation_conditions` +- `completion_definition` + +## Steps + +1. Draft the contract payload with explicit boundaries and testable evidence. +1. Write the metadata fields on the executable unit. +1. Run `plan-changeset-guardrails` and resolve violations. +1. Keep stage at `planning_in_review` until operator promotion approval. +1. Promote with `plan-promote-epic`; approval writes `planning.stage: approved` + plus planning approval evidence fields. +1. If scope grows during review, create deferred follow-on changesets instead of + inflating the active unit. + +## Verification + +- Refined units include `execution.strategy: refined` and valid + `planning.contract_json`. +- Stage is `planning_in_review` before promotion, then `approved` after explicit + operator approval. +- Worker startup skips refined units lacking valid approval evidence. diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index 632768af..b38498cb 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -18,6 +18,8 @@ which version is local, ask the overseer before using a global fallback. The CLI does not enforce planning correctness. You do, via skills. - Run `planner-startup-check` before any planning work. +- Use `plan-refined-deliberation` when a changeset needs rigorous, evidence-led + planning before execution. - Run `plan-changeset-guardrails` after creating or updating changesets. - Use `plan-promote-epic` to promote epics, with explicit user confirmation. - Use `mail-send` for planner-to-worker dispatch on the epic/changeset thread; @@ -159,11 +161,11 @@ If code changes are needed, create beads and leave implementation to workers. - Capture new executable work as deferred changesets first, then promote when fully defined. - Record estimated size and any approval notes. -- For trycycle-targeted work, set `trycycle.targeted: true` and include a - typed `trycycle.contract_json` payload on the executable unit. -- Keep targeted plans in review with - `trycycle.plan_stage: planning_in_review` until explicit operator approval. -- Do not promote targeted work unless guardrails validate the contract payload. +- For refined work, set `execution.strategy: refined` and include a + typed `planning.contract_json` payload on the executable unit. +- Keep refined plans in review with + `planning.stage: planning_in_review` until explicit operator approval. +- Do not promote refined work unless guardrails validate the contract payload. 4. External tickets: - {{ external_auto_export_guidance }} @@ -185,12 +187,12 @@ If code changes are needed, create beads and leave implementation to workers. before you ask for confirmation. - Do not ask for promotion while ambiguity remains. Resolve it in the bead or capture the operator's explicit decision about the remaining uncertainty. -- For trycycle-targeted executable units, promotion approval must persist: - - `trycycle.plan_stage: approved` - - `trycycle.approved_by` - - `trycycle.approved_at` - - `trycycle.approval_message_id` -- Worker startup must fail closed on targeted work that is missing contract +- For refined executable units, promotion approval must persist: + - `planning.stage: approved` + - `planning.approved_by` + - `planning.approved_at` + - `planning.approval_message_id` +- Worker startup must fail closed on refined work that is missing contract data or approval evidence. ## Bead Quality Standard diff --git a/src/atelier/worker/session/startup.py b/src/atelier/worker/session/startup.py index 454c458b..fdc4f6cc 100644 --- a/src/atelier/worker/session/startup.py +++ b/src/atelier/worker/session/startup.py @@ -86,7 +86,7 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: ... - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: ... @@ -234,8 +234,8 @@ def review_resume_allowed(issue: dict[str, object]) -> bool: git_path=context.git_path, ) - def trycycle_eligible(issue: dict[str, object]) -> bool: - eligible, _reason = service.trycycle_claim_eligible(issue) + def refined_planning_eligible(issue: dict[str, object]) -> bool: + eligible, _reason = service.refined_planning_claim_eligible(issue) return eligible target = service.show_issue(context.epic_id) @@ -289,9 +289,9 @@ def trycycle_eligible(issue: dict[str, object]) -> bool: or target_recovery_candidate ) ): - if not service.has_open_descendant_changesets(context.epic_id) and trycycle_eligible( - issue - ): + if not service.has_open_descendant_changesets( + context.epic_id + ) and refined_planning_eligible(issue): return issue if ( isinstance(issue_id, str) @@ -303,7 +303,7 @@ def trycycle_eligible(issue: dict[str, object]) -> bool: return None if isinstance(issue_id, str) and issue_id == context.epic_id and claimability.role.is_epic: if not explicit_descendants: - if trycycle_eligible(issue): + if refined_planning_eligible(issue): return issue return None @@ -348,7 +348,7 @@ def trycycle_eligible(issue: dict[str, object]) -> bool: ), ) for issue in prioritized: - if not trycycle_eligible(issue): + if not refined_planning_eligible(issue): continue issue_id = issue.get("id") if isinstance(issue_id, str) and issue_id: @@ -500,7 +500,7 @@ def emit(self, message: str) -> None: ... def die(self, message: str) -> None: ... - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: ... @@ -540,28 +540,28 @@ def stage_call(stage: str, callback: Callable[[], _StageResult]) -> _StageResult ) return result - def trycycle_selection_allowed( + def refined_planning_selection_allowed( *, changeset_id: str, stage: str, ) -> bool: issue = service.show_issue(changeset_id) if issue is None: - detail = "unable to load changeset metadata for trycycle claim gate" + detail = "unable to load changeset metadata for refined claim gate" service.emit(f"Skipping {stage} changeset {changeset_id}: {detail}") atelier_log.warning( "startup skipping " - f"{stage} changeset={changeset_id} reason=trycycle_metadata_unavailable" + f"{stage} changeset={changeset_id} reason=refined_metadata_unavailable" ) return False payload: dict[str, object] = issue - eligible, reason = service.trycycle_claim_eligible(payload) + eligible, reason = service.refined_planning_claim_eligible(payload) if eligible: return True - detail = reason or "targeted changeset is not trycycle claim-eligible" + detail = reason or "refined changeset is not claim-eligible" service.emit(f"Skipping {stage} changeset {changeset_id}: {detail}") atelier_log.warning( - f"startup skipping {stage} changeset={changeset_id} reason=trycycle_ineligible" + f"startup skipping {stage} changeset={changeset_id} reason=refined_ineligible" ) return False @@ -657,7 +657,7 @@ def trycycle_selection_allowed( ), ) if explicit_conflict is not None: - if trycycle_selection_allowed( + if refined_planning_selection_allowed( changeset_id=explicit_conflict.changeset_id, stage="explicit merge-conflict", ): @@ -676,7 +676,7 @@ def trycycle_selection_allowed( ), ) if explicit_feedback is not None: - if trycycle_selection_allowed( + if refined_planning_selection_allowed( changeset_id=explicit_feedback.changeset_id, stage="explicit review-feedback", ): @@ -699,7 +699,7 @@ def trycycle_selection_allowed( ) if explicit_next_changeset is not None: explicit_issue_id = _issue_id(explicit_next_changeset) - if explicit_issue_id is not None and not trycycle_selection_allowed( + if explicit_issue_id is not None and not refined_planning_selection_allowed( changeset_id=explicit_issue_id, stage="explicit next-changeset", ): @@ -923,7 +923,7 @@ def select_conflict_candidate( ), ) if selection is not None: - if trycycle_selection_allowed( + if refined_planning_selection_allowed( changeset_id=selection.changeset_id, stage="merge-conflict", ): @@ -982,7 +982,7 @@ def select_feedback_candidate( ), ) if feedback_selection is not None: - if trycycle_selection_allowed( + if refined_planning_selection_allowed( changeset_id=feedback_selection.changeset_id, stage="review-feedback", ): @@ -1084,7 +1084,7 @@ def resume_feedback(selection: ReviewFeedbackSelection) -> StartupContractResult global_conflict.epic_id, stage="global-merge-conflict" ) or is_excluded(global_conflict.epic_id, stage="global-merge-conflict"): global_conflict = None - elif not trycycle_selection_allowed( + elif not refined_planning_selection_allowed( changeset_id=global_conflict.changeset_id, stage="global merge-conflict", ): @@ -1105,7 +1105,7 @@ def resume_feedback(selection: ReviewFeedbackSelection) -> StartupContractResult global_feedback = None elif not is_claimable(global_feedback.epic_id, stage="global-review-feedback"): global_feedback = None - elif not trycycle_selection_allowed( + elif not refined_planning_selection_allowed( changeset_id=global_feedback.changeset_id, stage="global review-feedback", ): diff --git a/src/atelier/worker/work_startup_runtime.py b/src/atelier/worker/work_startup_runtime.py index cf5d8d7e..cc65d4b0 100644 --- a/src/atelier/worker/work_startup_runtime.py +++ b/src/atelier/worker/work_startup_runtime.py @@ -6,7 +6,7 @@ from collections.abc import Callable from pathlib import Path -from .. import agent_home, beads, changeset_fields, prs, trycycle_contract, work_feedback +from .. import agent_home, beads, changeset_fields, prs, refined_planning_contract, work_feedback from ..io import die, prompt, say, select from ..work_feedback import ReviewFeedbackSnapshot from ..worker import prompts as worker_prompts @@ -166,11 +166,11 @@ def startup_finalize_preflight( ) -def _trycycle_claim_eligibility(issue: dict[str, object]) -> tuple[bool, str | None]: - return trycycle_contract.trycycle_claim_eligible(issue) +def _refined_planning_claim_eligibility(issue: dict[str, object]) -> tuple[bool, str | None]: + return refined_planning_contract.refined_planning_claim_eligible(issue) -def _hydrate_trycycle_issue_payload( +def _hydrate_refined_planning_issue_payload( issue: dict[str, object], *, beads_root: Path, @@ -302,18 +302,18 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return is_changeset_in_progress(issue) - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: - payload = _hydrate_trycycle_issue_payload( + payload = _hydrate_refined_planning_issue_payload( issue, beads_root=self._beads_root, repo_root=self._repo_root, ) if payload is None: - return False, "unable to load changeset metadata for trycycle claim gate" - return _trycycle_claim_eligibility(payload) + return False, "unable to load changeset metadata for refined claim gate" + return _refined_planning_claim_eligibility(payload) def _no_eligible_epics_summary( @@ -952,18 +952,18 @@ def select_global_review_feedback_changeset( ) -> ReviewFeedbackSelection | None: return self._global_startup_candidates(repo_slug=repo_slug).feedback - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: - payload = _hydrate_trycycle_issue_payload( + payload = _hydrate_refined_planning_issue_payload( issue, beads_root=self._beads_root, repo_root=self._repo_root, ) if payload is None: - return False, "unable to load changeset metadata for trycycle claim gate" - return _trycycle_claim_eligibility(payload) + return False, "unable to load changeset metadata for refined claim gate" + return _refined_planning_claim_eligibility(payload) def check_inbox_before_claim(self, agent_id: str) -> bool: return check_inbox_before_claim( diff --git a/tests/atelier/skills/test_plan_changeset_guardrails_script.py b/tests/atelier/skills/test_plan_changeset_guardrails_script.py index c137b003..89b9d32e 100644 --- a/tests/atelier/skills/test_plan_changeset_guardrails_script.py +++ b/tests/atelier/skills/test_plan_changeset_guardrails_script.py @@ -36,11 +36,11 @@ def _planner_contract_text() -> str: ) -def _valid_trycycle_contract_json() -> str: +def _valid_refined_contract_json() -> str: return json.dumps( { - "objective": "Ship fail-closed trycycle contract enforcement", - "non_goals": ["Do not modify non-trycycle startup semantics"], + "objective": "Ship fail-closed refined contract enforcement", + "non_goals": ["Do not modify non-refined startup semantics"], "acceptance_criteria": [ {"statement": "Reject missing contracts", "evidence": ["guardrails tests"]} ], @@ -48,8 +48,8 @@ def _valid_trycycle_contract_json() -> str: "includes": ["planner guardrails"], "excludes": ["worker PR flow redesign"], }, - "verification_plan": ["uv run pytest tests/atelier/skills -k trycycle -v"], - "risks": [{"risk": "over-blocking", "mitigation": "targeted-only gating"}], + "verification_plan": ["uv run pytest tests/atelier/skills -k refined -v"], + "risks": [{"risk": "over-blocking", "mitigation": "refined-only gating"}], "escalation_conditions": ["validator disagreement"], "completion_definition": { "requires_terminal_pr_state": True, @@ -62,20 +62,20 @@ def _valid_trycycle_contract_json() -> str: ) -def _targeted_description(*, stage: str, contract_json: str) -> str: +def _refined_description(*, stage: str, contract_json: str) -> str: return ( f"{_planner_contract_text()}\n" "LOC estimate: 220\n" - "done_definition: Done when guardrails and worker startup reject invalid targeted work.\n" - "trycycle.targeted: true\n" - f"trycycle.plan_stage: {stage}\n" - f"trycycle.contract_json: {contract_json}\n" + "done_definition: Done when guardrails and worker startup reject invalid refined work.\n" + "execution.strategy: refined\n" + f"planning.stage: {stage}\n" + f"planning.contract_json: {contract_json}\n" ) -def test_guardrails_flags_trycycle_target_missing_contract() -> None: +def test_guardrails_flags_refined_target_missing_contract() -> None: module = _load_script_module() - child = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + child = {"id": "at-epic.1", "description": "execution.strategy: refined"} report = module._evaluate_guardrails( epic_issue=None, @@ -83,16 +83,16 @@ def test_guardrails_flags_trycycle_target_missing_contract() -> None: target_changesets=[child], ) - assert any("trycycle.contract_json" in item for item in report.violations) + assert any("planning.contract_json" in item for item in report.violations) -def test_guardrails_accepts_valid_targeted_trycycle_payload() -> None: +def test_guardrails_accepts_valid_refined_payload() -> None: module = _load_script_module() child = { "id": "at-epic.1", - "description": _targeted_description( + "description": _refined_description( stage="planning_in_review", - contract_json=_valid_trycycle_contract_json(), + contract_json=_valid_refined_contract_json(), ), "acceptance_criteria": "Done when validation fails closed before promotion.", } @@ -103,18 +103,18 @@ def test_guardrails_accepts_valid_targeted_trycycle_payload() -> None: target_changesets=[child], ) - assert not any("trycycle." in item for item in report.violations) + assert not any("planning." in item for item in report.violations) -def test_guardrails_flags_targeted_completion_definition_conflict() -> None: +def test_guardrails_flags_refined_completion_definition_conflict() -> None: module = _load_script_module() - contract_payload = json.loads(_valid_trycycle_contract_json()) + contract_payload = json.loads(_valid_refined_contract_json()) contract_payload["completion_definition"]["allow_close_without_terminal_or_integrated_sha"] = ( True ) child = { "id": "at-epic.1", - "description": _targeted_description( + "description": _refined_description( stage="planning_in_review", contract_json=json.dumps(contract_payload, separators=(",", ":")), ), @@ -130,17 +130,17 @@ def test_guardrails_flags_targeted_completion_definition_conflict() -> None: assert any("completion_definition conflicts" in item for item in report.violations) -def test_guardrails_require_planning_in_review_for_targeted_payloads() -> None: +def test_guardrails_require_planning_in_review_for_refined_payloads() -> None: module = _load_script_module() child = { "id": "at-epic.1", - "description": _targeted_description( + "description": _refined_description( stage="approved", - contract_json=_valid_trycycle_contract_json(), + contract_json=_valid_refined_contract_json(), ) - + "trycycle.approved_by: operator\n" - + "trycycle.approved_at: 2026-03-26T12:00:00Z\n" - + "trycycle.approval_message_id: at-msg.1\n", + + "planning.approved_by: operator\n" + + "planning.approved_at: 2026-03-26T12:00:00Z\n" + + "planning.approval_message_id: at-msg.1\n", "acceptance_criteria": "Done when approval metadata is persisted.", } diff --git a/tests/atelier/skills/test_plan_promote_epic_script.py b/tests/atelier/skills/test_plan_promote_epic_script.py index 0b47f1cc..e4876744 100644 --- a/tests/atelier/skills/test_plan_promote_epic_script.py +++ b/tests/atelier/skills/test_plan_promote_epic_script.py @@ -48,20 +48,20 @@ def _issue( ) -def _valid_trycycle_contract_json() -> str: +def _valid_refined_contract_json() -> str: return json.dumps( { - "objective": "Enforce fail-closed trycycle promotion gate", - "non_goals": ["Do not alter non-trycycle promotion"], + "objective": "Enforce fail-closed refined promotion gate", + "non_goals": ["Do not alter non-refined promotion"], "acceptance_criteria": [ - {"statement": "Reject invalid targeted payloads", "evidence": ["pytest"]} + {"statement": "Reject invalid refined payloads", "evidence": ["pytest"]} ], "scope": { "includes": ["plan-promote-epic"], "excludes": ["worker runtime redesign"], }, - "verification_plan": ["uv run pytest tests/atelier/skills -k trycycle -v"], - "risks": [{"risk": "over-blocking", "mitigation": "targeted-only checks"}], + "verification_plan": ["uv run pytest tests/atelier/skills -k refined -v"], + "risks": [{"risk": "over-blocking", "mitigation": "refined-only checks"}], "escalation_conditions": ["validator disagreement"], "completion_definition": { "requires_terminal_pr_state": True, @@ -74,17 +74,17 @@ def _valid_trycycle_contract_json() -> str: ) -def _targeted_description(*, contract_json: str, stage: str = "planning_in_review") -> str: +def _refined_description(*, contract_json: str, stage: str = "planning_in_review") -> str: return ( "related_context: at-context\n" "changeset_strategy: Keep review scope small.\n" - "trycycle.targeted: true\n" - f"trycycle.plan_stage: {stage}\n" - f"trycycle.contract_json: {contract_json}\n" + "execution.strategy: refined\n" + f"planning.stage: {stage}\n" + f"planning.contract_json: {contract_json}\n" ) -def test_promote_epic_blocks_trycycle_target_without_valid_contract( +def test_promote_epic_blocks_refined_target_without_valid_contract( monkeypatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, @@ -109,7 +109,7 @@ def test_promote_epic_blocks_trycycle_target_without_valid_contract( child_issue = _issue( "at-epic.1", title="Child", - description=_targeted_description(contract_json="{bad-json}"), + description=_refined_description(contract_json="{bad-json}"), ) class FakeStore: @@ -141,10 +141,10 @@ async def show(self, request): module.main() assert excinfo.value.code == 1 - assert "trycycle readiness failed" in capsys.readouterr().err + assert "refined readiness failed" in capsys.readouterr().err -def test_promote_epic_records_trycycle_approval_metadata_for_child_changeset( +def test_promote_epic_records_refined_approval_metadata_for_child_changeset( monkeypatch, tmp_path: Path, ) -> None: @@ -172,7 +172,7 @@ def test_promote_epic_records_trycycle_approval_metadata_for_child_changeset( child_issue = _issue( "at-epic.1", title="Child", - description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + description=_refined_description(contract_json=_valid_refined_contract_json()), notes="child notes", ) @@ -224,13 +224,13 @@ def _record_metadata( assert [request.issue_id for request in transitions] == ["at-epic", "at-epic.1"] assert created_messages - assert metadata_updates["at-epic.1"]["trycycle.plan_stage"] == "approved" - assert metadata_updates["at-epic.1"]["trycycle.approved_by"] == "atelier/planner/codex/p1" - assert metadata_updates["at-epic.1"]["trycycle.approval_message_id"] == "at-msg.1" - assert metadata_updates["at-epic.1"]["trycycle.approved_at"] is not None + assert metadata_updates["at-epic.1"]["planning.stage"] == "approved" + assert metadata_updates["at-epic.1"]["planning.approved_by"] == "atelier/planner/codex/p1" + assert metadata_updates["at-epic.1"]["planning.approval_message_id"] == "at-msg.1" + assert metadata_updates["at-epic.1"]["planning.approved_at"] is not None -def test_promote_epic_requires_explicit_operator_identity_for_trycycle_approval( +def test_promote_epic_requires_explicit_operator_identity_for_refined_approval( monkeypatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, @@ -258,7 +258,7 @@ def test_promote_epic_requires_explicit_operator_identity_for_trycycle_approval( child_issue = _issue( "at-epic.1", title="Child", - description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + description=_refined_description(contract_json=_valid_refined_contract_json()), notes="child notes", ) @@ -299,12 +299,12 @@ async def show(self, request): module.main() assert excinfo.value.code == 1 - assert "ATELIER_AGENT_ID must be set for trycycle approvals" in capsys.readouterr().err + assert "ATELIER_AGENT_ID must be set for refined approvals" in capsys.readouterr().err assert created_messages == [] assert transitions == [] -def test_promote_epic_does_not_transition_lifecycle_when_trycycle_approval_fails( +def test_promote_epic_does_not_transition_lifecycle_when_refined_approval_fails( monkeypatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, @@ -331,7 +331,7 @@ def test_promote_epic_does_not_transition_lifecycle_when_trycycle_approval_fails child_issue = _issue( "at-epic.1", title="Child", - description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + description=_refined_description(contract_json=_valid_refined_contract_json()), notes="child notes", ) @@ -382,7 +382,7 @@ async def show(self, request): assert transitions == [] -def test_promote_epic_records_trycycle_approval_metadata_for_epic_single_unit( +def test_promote_epic_records_refined_approval_metadata_for_epic_single_unit( monkeypatch, tmp_path: Path, ) -> None: @@ -400,7 +400,7 @@ def test_promote_epic_records_trycycle_approval_metadata_for_epic_single_unit( epic_issue = _issue( "at-epic", title="Epic", - description=_targeted_description(contract_json=_valid_trycycle_contract_json()), + description=_refined_description(contract_json=_valid_refined_contract_json()), notes="epic notes", ) @@ -449,11 +449,11 @@ def _record_metadata( module.main() assert created_messages - assert metadata_updates["at-epic"]["trycycle.plan_stage"] == "approved" - assert metadata_updates["at-epic"]["trycycle.approval_message_id"] == "at-msg.2" + assert metadata_updates["at-epic"]["planning.stage"] == "approved" + assert metadata_updates["at-epic"]["planning.approval_message_id"] == "at-msg.2" -def test_promote_epic_non_targeted_changesets_do_not_write_trycycle_approval( +def test_promote_epic_non_refined_changesets_do_not_write_refined_approval( monkeypatch, tmp_path: Path, ) -> None: diff --git a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py index 25cccc58..9355d111 100644 --- a/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py +++ b/tests/atelier/skills/test_projected_skill_runtime_bootstrap.py @@ -583,15 +583,15 @@ def test_projected_check_guardrails_reorders_repo_src_ahead_of_installed_package "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), - "trycycle_contract.py": ( + "refined_planning_contract.py": ( "class _Readiness:\n" " def __init__(self):\n" - " self.targeted = False\n" + " self.refined = False\n" " self.contract_present = False\n" " self.stage = None\n" " self.errors = ()\n" "\n" - "def evaluate_issue_trycycle_readiness(_issue):\n" + "def evaluate_issue_refined_planning_readiness(_issue):\n" " return _Readiness()\n" ), }, @@ -621,15 +621,15 @@ def test_projected_check_guardrails_reorders_repo_src_ahead_of_installed_package "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), - "trycycle_contract.py": ( + "refined_planning_contract.py": ( "class _Readiness:\n" " def __init__(self):\n" - " self.targeted = False\n" + " self.refined = False\n" " self.contract_present = False\n" " self.stage = None\n" " self.errors = ()\n" "\n" - "def evaluate_issue_trycycle_readiness(_issue):\n" + "def evaluate_issue_refined_planning_readiness(_issue):\n" " return _Readiness()\n" ), }, @@ -684,15 +684,15 @@ def test_projected_check_guardrails_ignores_inherited_pythonpath_when_repo_runti "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), - "trycycle_contract.py": ( + "refined_planning_contract.py": ( "class _Readiness:\n" " def __init__(self):\n" - " self.targeted = False\n" + " self.refined = False\n" " self.contract_present = False\n" " self.stage = None\n" " self.errors = ()\n" "\n" - "def evaluate_issue_trycycle_readiness(_issue):\n" + "def evaluate_issue_refined_planning_readiness(_issue):\n" " return _Readiness()\n" ), }, @@ -713,15 +713,15 @@ def test_projected_check_guardrails_ignores_inherited_pythonpath_when_repo_runti "planner_contract.py": ( "def validate_authoring_contract(*_args, **_kwargs):\n return []\n" ), - "trycycle_contract.py": ( + "refined_planning_contract.py": ( "class _Readiness:\n" " def __init__(self):\n" - " self.targeted = False\n" + " self.refined = False\n" " self.contract_present = False\n" " self.stage = None\n" " self.errors = ()\n" "\n" - "def evaluate_issue_trycycle_readiness(_issue):\n" + "def evaluate_issue_refined_planning_readiness(_issue):\n" " return _Readiness()\n" ), }, @@ -763,7 +763,7 @@ def test_projected_promote_epic_reorders_repo_src_ahead_of_installed_package( ) repo_root = _fake_repo( tmp_path, - sentinel_import="trycycle_contract", + sentinel_import="refined_planning_contract", extra_modules={ "beads_context.py": ( "class _Context:\n" @@ -808,7 +808,7 @@ def test_projected_promote_epic_reorders_repo_src_ahead_of_installed_package( installed_root = _fake_installed_package( tmp_path, modules={ - "trycycle_contract.py": ( + "refined_planning_contract.py": ( "from pathlib import Path\n" "import os\n" "\n" @@ -866,7 +866,7 @@ def test_projected_promote_epic_reorders_repo_src_ahead_of_installed_package( assert completed.returncode == 0 assert sentinel_path.read_text(encoding="utf-8") == str( - repo_root / "src" / "atelier" / "trycycle_contract.py" + repo_root / "src" / "atelier" / "refined_planning_contract.py" ) diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index b2edfbab..ee1341fc 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -19,6 +19,7 @@ def test_planner_agents_template_contains_core_sections() -> None: assert "Promotion" in content assert "External providers" in content assert "plan-changeset-guardrails" in content + assert "plan-refined-deliberation" in content assert "plan-promote-epic" in content assert "planner-startup-check" in content assert "mail-send" in content @@ -60,8 +61,8 @@ 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 "trycycle.targeted: true" in content - assert "trycycle.contract_json" in content - assert "trycycle.plan_stage: planning_in_review" in content - assert "trycycle.plan_stage: approved" in content + assert "execution.strategy: refined" in content + assert "planning.contract_json" in content + assert "planning.stage: planning_in_review" in content + assert "planning.stage: approved" in content assert "fail closed" in content diff --git a/tests/atelier/test_trycycle_contract.py b/tests/atelier/test_refined_planning_contract.py similarity index 60% rename from tests/atelier/test_trycycle_contract.py rename to tests/atelier/test_refined_planning_contract.py index 312792aa..26551677 100644 --- a/tests/atelier/test_trycycle_contract.py +++ b/tests/atelier/test_refined_planning_contract.py @@ -4,7 +4,7 @@ from pathlib import Path from types import SimpleNamespace -from atelier import trycycle_contract +from atelier import refined_planning_contract from atelier.worker import work_startup_runtime @@ -19,14 +19,14 @@ def _valid_contract_json( ) evidence = ["tests"] if acceptance_evidence is None else acceptance_evidence return ( - '{"objective":"Ship fail-closed trycycle gating",' - '"non_goals":["Do not alter non-trycycle flows"],' + '{"objective":"Ship fail-closed refined gating",' + '"non_goals":["Do not alter non-refined flows"],' '"acceptance_criteria":[{"statement":"Gate invalid changesets","evidence":' f"{json.dumps(evidence)}" "}]," '"scope":{"includes":["worker startup"],"excludes":["CLI redesign"]},' '"verification_plan":["uv run pytest tests/atelier/worker/test_session_startup.py -v"],' - '"risks":[{"risk":"Over-broad blocking","mitigation":"targeted-only gate"}],' + '"risks":[{"risk":"Over-broad blocking","mitigation":"refined-only gate"}],' f'"escalation_conditions":{json.dumps(escalation)},' '"completion_definition":{"requires_terminal_pr_state":true,' '"allowed_terminal_pr_states":["merged","closed"],' @@ -75,9 +75,9 @@ def _load_promote_epic_script_module(): def _planner_contract_text() -> str: return ( - "intent: Keep targeted startup selections fail-closed.\n" + "intent: Keep refined startup selections fail-closed.\n" "rationale: Workers need deterministic claim eligibility.\n" - "non_goals: Do not alter non-trycycle startup behavior.\n" + "non_goals: Do not alter non-refined startup behavior.\n" "constraints: Preserve lifecycle invariants and explicit audits.\n" "edge_cases: Missing metadata must not silently bypass gates.\n" "related_context: at-719.\n" @@ -86,7 +86,7 @@ def _planner_contract_text() -> str: ) -def _targeted_description( +def _refined_description( *, contract_json: str, stage: str, @@ -95,16 +95,16 @@ def _targeted_description( approval_message_id: str | None = None, ) -> str: lines = [ - "trycycle.targeted: true", - f"trycycle.plan_stage: {stage}", - f"trycycle.contract_json: {contract_json}", + "execution.strategy: refined", + f"planning.stage: {stage}", + f"planning.contract_json: {contract_json}", ] if approved_by is not None: - lines.append(f"trycycle.approved_by: {approved_by}") + lines.append(f"planning.approved_by: {approved_by}") if approved_at is not None: - lines.append(f"trycycle.approved_at: {approved_at}") + lines.append(f"planning.approved_at: {approved_at}") if approval_message_id is not None: - lines.append(f"trycycle.approval_message_id: {approval_message_id}") + lines.append(f"planning.approval_message_id: {approval_message_id}") return _planner_contract_text() + "\n".join(lines) + "\n" @@ -121,47 +121,47 @@ def _guardrails_issue(issue_id: str, description: str) -> dict[str, object]: def test_validate_contract_accepts_complete_payload() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + f"planning.contract_json: {_valid_contract_json()}\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is True - assert result.targeted is True + assert result.refined is True assert result.claim_eligible is False assert result.claim_blockers == ( - "targeted changesets require trycycle.plan_stage=approved before worker claim", + "refined changesets require planning.stage=approved before worker claim", ) def test_validate_contract_rejects_malformed_json() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - "trycycle.contract_json: {not-json}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + "planning.contract_json: {not-json}\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.contract_json must be valid JSON" in result.summary + assert "planning.contract_json must be valid JSON" in result.summary def test_validate_contract_rejects_missing_escalation_conditions() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - f"trycycle.contract_json: {_valid_contract_json(escalation_conditions=[])}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + f"planning.contract_json: {_valid_contract_json(escalation_conditions=[])}\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False assert "escalation_conditions" in result.summary @@ -170,13 +170,13 @@ def test_validate_contract_rejects_missing_escalation_conditions() -> None: def test_validate_contract_rejects_non_testable_acceptance_criteria() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - f"trycycle.contract_json: {_valid_contract_json(acceptance_evidence=[])}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + f"planning.contract_json: {_valid_contract_json(acceptance_evidence=[])}\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False assert "evidence" in result.summary @@ -185,14 +185,14 @@ def test_validate_contract_rejects_non_testable_acceptance_criteria() -> None: def test_validate_contract_rejects_completion_definition_conflicts() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - "trycycle.contract_json: " + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + "planning.contract_json: " f"{_valid_contract_json(completion_allow_unsafe_close=True)}\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False assert "completion_definition conflicts" in result.summary @@ -201,93 +201,93 @@ def test_validate_contract_rejects_completion_definition_conflicts() -> None: def test_validate_contract_approved_stage_requires_full_evidence_fields() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" - "trycycle.approved_by: operator\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_contract_json()}\n" + "planning.approved_by: operator\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.approved_at" in result.summary - assert "trycycle.approval_message_id" in result.summary + assert "planning.approved_at" in result.summary + assert "planning.approval_message_id" in result.summary def test_validate_contract_rejects_malformed_approved_timestamp() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" - "trycycle.approved_by: atelier/planner/codex/p1\n" - "trycycle.approved_at: yesterday\n" - "trycycle.approval_message_id: at-msg.1\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_contract_json()}\n" + "planning.approved_by: atelier/planner/codex/p1\n" + "planning.approved_at: yesterday\n" + "planning.approval_message_id: at-msg.1\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + assert "planning.approved_at must be an ISO-8601 timestamp" in result.summary def test_validate_contract_rejects_date_only_approved_timestamp() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" - "trycycle.approved_by: atelier/planner/codex/p1\n" - "trycycle.approved_at: 2026-03-27\n" - "trycycle.approval_message_id: at-msg.1\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_contract_json()}\n" + "planning.approved_by: atelier/planner/codex/p1\n" + "planning.approved_at: 2026-03-27\n" + "planning.approval_message_id: at-msg.1\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + assert "planning.approved_at must be an ISO-8601 timestamp" in result.summary def test_validate_contract_rejects_naive_approved_timestamp() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" - "trycycle.approved_by: atelier/planner/codex/p1\n" - "trycycle.approved_at: 2026-03-27T01:00:00\n" - "trycycle.approval_message_id: at-msg.1\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_contract_json()}\n" + "planning.approved_by: atelier/planner/codex/p1\n" + "planning.approved_at: 2026-03-27T01:00:00\n" + "planning.approval_message_id: at-msg.1\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.approved_at must be an ISO-8601 timestamp" in result.summary + assert "planning.approved_at must be an ISO-8601 timestamp" in result.summary def test_validate_contract_rejects_malformed_approval_message_id() -> None: issue = { "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_contract_json()}\n" - "trycycle.approved_by: atelier/planner/codex/p1\n" - "trycycle.approved_at: 2026-03-27T01:00:00Z\n" - "trycycle.approval_message_id: bad id\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_contract_json()}\n" + "planning.approved_by: atelier/planner/codex/p1\n" + "planning.approved_at: 2026-03-27T01:00:00Z\n" + "planning.approval_message_id: bad id\n" ) } - result = trycycle_contract.evaluate_issue_trycycle_readiness(issue) + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) assert result.ok is False - assert "trycycle.approval_message_id must be an identifier" in result.summary + assert "planning.approval_message_id must be an identifier" in result.summary -def test_trycycle_readiness_parity_across_entrypoints() -> None: +def test_refined_readiness_parity_across_entrypoints() -> None: guardrails = _load_guardrails_script_module() promote_epic = _load_promote_epic_script_module() worker_service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] @@ -302,7 +302,7 @@ def test_trycycle_readiness_parity_across_entrypoints() -> None: cases = ( ( "valid_approved", - _targeted_description( + _refined_description( contract_json=_valid_contract_json(), stage="approved", approved_by="atelier/planner/codex/p1", @@ -312,14 +312,14 @@ def test_trycycle_readiness_parity_across_entrypoints() -> None: ), ( "valid_unapproved", - _targeted_description( + _refined_description( contract_json=_valid_contract_json(), stage="planning_in_review", ), ), ( "malformed_json", - _targeted_description( + _refined_description( contract_json="{bad-json}", stage="planning_in_review", ), @@ -327,11 +327,11 @@ def test_trycycle_readiness_parity_across_entrypoints() -> None: ( "missing_contract", _planner_contract_text() - + "trycycle.targeted: true\ntrycycle.plan_stage: planning_in_review\n", + + "execution.strategy: refined\nplanning.stage: planning_in_review\n", ), ( "completion_conflict", - _targeted_description( + _refined_description( contract_json=json.dumps(conflict_payload, separators=(",", ":")), stage="planning_in_review", ), @@ -341,21 +341,25 @@ def test_trycycle_readiness_parity_across_entrypoints() -> None: for label, description in cases: issue_id = f"at-parity.{label}" issue_payload = {"id": issue_id, "description": description} - readiness = trycycle_contract.evaluate_issue_trycycle_readiness(issue_payload) - promotion_error = promote_epic._trycycle_validation_error( # pyright: ignore[reportPrivateUsage] + readiness = refined_planning_contract.evaluate_issue_refined_planning_readiness( + issue_payload + ) + promotion_error = promote_epic._refined_validation_error( # pyright: ignore[reportPrivateUsage] SimpleNamespace(id=issue_id, description=description) ) - worker_eligible, worker_reason = worker_service.trycycle_claim_eligible(issue_payload) + worker_eligible, worker_reason = worker_service.refined_planning_claim_eligible( + issue_payload + ) report = guardrails._evaluate_guardrails( # pyright: ignore[reportPrivateUsage] epic_issue=None, child_changesets=[], target_changesets=[_guardrails_issue(issue_id, description)], ) - trycycle_violations = tuple( + refined_violations = tuple( violation for violation in report.violations if violation.startswith(f"{issue_id}:") - and ("trycycle." in violation or "completion_definition conflicts" in violation) + and ("planning." in violation or "completion_definition conflicts" in violation) ) assert (promotion_error is None) is readiness.ok @@ -363,8 +367,8 @@ def test_trycycle_readiness_parity_across_entrypoints() -> None: if not worker_eligible: assert worker_reason is not None if readiness.ok and readiness.stage == "planning_in_review": - assert trycycle_violations == () + assert refined_violations == () elif readiness.ok and readiness.stage == "approved": - assert any("planning_in_review" in violation for violation in trycycle_violations) + assert any("planning_in_review" in violation for violation in refined_violations) else: - assert trycycle_violations + assert refined_violations diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 0da4d090..4d8b8cdc 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -42,6 +42,7 @@ def test_packaged_skills_include_core_set() -> None: "plan-changesets", "plan-changeset-guardrails", "plan-promote-epic", + "plan-refined-deliberation", "planner-startup-check", }.issubset(names) assert all("_" not in name for name in names) @@ -94,6 +95,7 @@ def test_install_workspace_skills_writes_skill_docs() -> None: "plan-changesets", "plan-changeset-guardrails", "plan-promote-epic", + "plan-refined-deliberation", "planner-startup-check", ): assert (workspace_dir / "skills" / name / "SKILL.md").exists() @@ -194,12 +196,12 @@ def test_github_issues_skill_mentions_list_script() -> None: assert "list_issues.py" in text -def test_plan_promote_epic_skill_mentions_trycycle_approval_gate() -> None: +def test_plan_promote_epic_skill_mentions_refined_approval_gate() -> None: skill = skills.load_packaged_skills()["plan-promote-epic"] text = skill.files["SKILL.md"].decode("utf-8") - assert "trycycle.plan_stage" in text - assert "trycycle.approved_by" in text - assert "trycycle.approval_message_id" in text + assert "planning.stage" in text + assert "planning.approved_by" in text + assert "planning.approval_message_id" in text def test_ensure_project_skills_installs_if_missing() -> None: diff --git a/tests/atelier/worker/test_lifecycle_matrix.py b/tests/atelier/worker/test_lifecycle_matrix.py index 216399ad..454a8765 100644 --- a/tests/atelier/worker/test_lifecycle_matrix.py +++ b/tests/atelier/worker/test_lifecycle_matrix.py @@ -8,7 +8,7 @@ import pytest -from atelier import config, lifecycle, planner_overview, trycycle_contract +from atelier import config, lifecycle, planner_overview, refined_planning_contract from atelier.worker import finalize_pipeline, reconcile, selection from atelier.worker.session import startup @@ -77,7 +77,7 @@ def list_descendant_changesets( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return lifecycle.canonical_lifecycle_status(issue.get("status")) == "in_progress" - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: @@ -367,11 +367,11 @@ def test_lifecycle_matrix_reconcile_ignores_terminal_labels_on_active_status() - assert candidates == {"at-1": ["at-1.7"]} -def _valid_trycycle_contract_json() -> str: +def _valid_refined_contract_json() -> str: return json.dumps( { "objective": "Protect worker startup from unapproved targeted claims", - "non_goals": ["Do not alter non-trycycle flows"], + "non_goals": ["Do not alter non-refined flows"], "acceptance_criteria": [ {"statement": "Skip unapproved targeted work", "evidence": ["startup tests"]} ], @@ -379,7 +379,7 @@ def _valid_trycycle_contract_json() -> str: "includes": ["worker startup"], "excludes": ["planner UX redesign"], }, - "verification_plan": ["uv run pytest tests/atelier/worker -k trycycle -v"], + "verification_plan": ["uv run pytest tests/atelier/worker -k refined -v"], "risks": [{"risk": "over-blocking", "mitigation": "targeted-only checks"}], "escalation_conditions": ["validator disagreement"], "completion_definition": { @@ -393,16 +393,16 @@ def _valid_trycycle_contract_json() -> str: ) -def test_lifecycle_matrix_trycycle_unapproved_target_is_not_selected() -> None: +def test_lifecycle_matrix_refined_unapproved_target_is_not_selected() -> None: issue = { "id": "at-epic", "status": "open", "labels": ["at:epic"], "assignee": None, "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - f"trycycle.contract_json: {_valid_trycycle_contract_json()}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + f"planning.contract_json: {_valid_refined_contract_json()}\n" ), } startup_context = startup.NextChangesetContext( @@ -415,26 +415,26 @@ def test_lifecycle_matrix_trycycle_unapproved_target_is_not_selected() -> None: selected = startup.next_changeset_service( context=startup_context, service=_NextChangesetMatrixService( - issue, claim_eligibility=trycycle_contract.trycycle_claim_eligible + issue, claim_eligibility=refined_planning_contract.refined_planning_claim_eligible ), ) assert selected is None -def test_lifecycle_matrix_trycycle_approved_target_is_selected() -> None: +def test_lifecycle_matrix_refined_approved_target_is_selected() -> None: issue = { "id": "at-epic", "status": "open", "labels": ["at:epic"], "assignee": None, "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: approved\n" - f"trycycle.contract_json: {_valid_trycycle_contract_json()}\n" - "trycycle.approved_by: atelier/planner/codex/p1\n" - "trycycle.approved_at: 2026-03-26T18:00:00Z\n" - "trycycle.approval_message_id: at-msg.1\n" + "execution.strategy: refined\n" + "planning.stage: approved\n" + f"planning.contract_json: {_valid_refined_contract_json()}\n" + "planning.approved_by: atelier/planner/codex/p1\n" + "planning.approved_at: 2026-03-26T18:00:00Z\n" + "planning.approval_message_id: at-msg.1\n" ), } startup_context = startup.NextChangesetContext( @@ -447,7 +447,7 @@ def test_lifecycle_matrix_trycycle_approved_target_is_selected() -> None: selected = startup.next_changeset_service( context=startup_context, service=_NextChangesetMatrixService( - issue, claim_eligibility=trycycle_contract.trycycle_claim_eligible + issue, claim_eligibility=refined_planning_contract.refined_planning_claim_eligible ), ) diff --git a/tests/atelier/worker/test_session_next_changeset.py b/tests/atelier/worker/test_session_next_changeset.py index ded48ef2..0778abcb 100644 --- a/tests/atelier/worker/test_session_next_changeset.py +++ b/tests/atelier/worker/test_session_next_changeset.py @@ -16,7 +16,7 @@ def __init__( waiting_by_id: dict[str, bool] | None = None, integrated_by_id: dict[str, bool] | None = None, work_children_by_id: dict[str, list[dict[str, object]]] | None = None, - trycycle_eligibility_by_id: dict[str, tuple[bool, str | None]] | None = None, + refined_eligibility_by_id: dict[str, tuple[bool, str | None]] | None = None, ) -> None: self._issues_by_id = issues_by_id self._ready_changesets = ready_changesets @@ -25,7 +25,7 @@ def __init__( self._waiting_by_id = waiting_by_id or {} self._integrated_by_id = integrated_by_id or {} self._work_children_by_id = work_children_by_id or {} - self._trycycle_eligibility_by_id = trycycle_eligibility_by_id or {} + self._refined_eligibility_by_id = refined_eligibility_by_id or {} def show_issue(self, issue_id: str) -> dict[str, object] | None: return self._issues_by_id.get(issue_id) @@ -122,14 +122,14 @@ def changeset_integration_signal( def is_changeset_in_progress(self, issue: dict[str, object]) -> bool: return str(issue.get("status") or "").strip().lower() == "in_progress" - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: issue_id = issue.get("id") if not isinstance(issue_id, str): return True, None - return self._trycycle_eligibility_by_id.get(issue_id, (True, None)) + return self._refined_eligibility_by_id.get(issue_id, (True, None)) def _changeset( @@ -636,14 +636,14 @@ def test_next_changeset_service_rehydrates_sparse_ready_candidates() -> None: assert selected["id"] == "at-epic.1" -def test_next_changeset_service_skips_unapproved_trycycle_changeset() -> None: +def test_next_changeset_service_skips_unapproved_refined_changeset() -> None: blocked = _changeset("at-epic.1", status="open", work_branch="feat/at-epic.1") allowed = _changeset("at-epic.2", status="open", work_branch="feat/at-epic.2") service = FakeNextChangesetService( issues_by_id={"at-epic": _epic(), blocked["id"]: blocked, allowed["id"]: allowed}, ready_changesets=[], descendants=[blocked, allowed], - trycycle_eligibility_by_id={"at-epic.1": (False, "missing trycycle approval")}, + refined_eligibility_by_id={"at-epic.1": (False, "missing refined approval")}, ) selected = startup.next_changeset_service(context=_context(), service=service) diff --git a/tests/atelier/worker/test_session_startup.py b/tests/atelier/worker/test_session_startup.py index 778a9899..fb8c05c9 100644 --- a/tests/atelier/worker/test_session_startup.py +++ b/tests/atelier/worker/test_session_startup.py @@ -7,7 +7,7 @@ from typing import Any from unittest.mock import patch -from atelier import trycycle_contract +from atelier import refined_planning_contract from atelier.worker import integration, work_startup_runtime from atelier.worker.models_boundary import parse_issue_boundary from atelier.worker.review import MergeConflictSelection, ReviewFeedbackSelection @@ -71,8 +71,8 @@ def __init__(self, **overrides: Any) -> None: self._select_global_review_feedback_changeset = overrides.pop( "select_global_review_feedback_changeset", lambda **_kwargs: None ) - self._trycycle_claim_eligible = overrides.pop( - "trycycle_claim_eligible", lambda _issue: (True, None) + self._refined_planning_claim_eligible = overrides.pop( + "refined_planning_claim_eligible", lambda _issue: (True, None) ) self._check_inbox_before_claim = overrides.pop( "check_inbox_before_claim", lambda *_args: False @@ -222,11 +222,11 @@ def select_global_review_feedback_changeset( ) -> ReviewFeedbackSelection | None: return self._select_global_review_feedback_changeset(repo_slug=repo_slug) - def trycycle_claim_eligible( + def refined_planning_claim_eligible( self, issue: dict[str, object], ) -> tuple[bool, str | None]: - return self._trycycle_claim_eligible(issue) + return self._refined_planning_claim_eligible(issue) def check_inbox_before_claim(self, agent_id: str) -> bool: return self._check_inbox_before_claim(agent_id) @@ -311,11 +311,11 @@ def _run_startup(**overrides: Any) -> startup.StartupContractResult: return startup.run_startup_contract_service(context=context, service=service) -def _valid_trycycle_contract_json() -> str: +def _valid_refined_contract_json() -> str: return json.dumps( { - "objective": "Gate all startup claim paths with shared trycycle checks", - "non_goals": ["Do not alter non-trycycle selection"], + "objective": "Gate all startup claim paths with shared refined checks", + "non_goals": ["Do not alter non-refined selection"], "acceptance_criteria": [ {"statement": "Reject unapproved targeted work", "evidence": ["pytest"]} ], @@ -334,22 +334,22 @@ def _valid_trycycle_contract_json() -> str: ) -def _trycycle_target_description(*, approved: bool) -> str: +def _refined_target_description(*, approved: bool) -> str: lines = [ - "trycycle.targeted: true", - f"trycycle.contract_json: {_valid_trycycle_contract_json()}", + "execution.strategy: refined", + f"planning.contract_json: {_valid_refined_contract_json()}", ] if approved: lines.extend( [ - "trycycle.plan_stage: approved", - "trycycle.approved_by: atelier/planner/codex/p1", - "trycycle.approved_at: 2026-03-26T18:00:00Z", - "trycycle.approval_message_id: at-msg.1", + "planning.stage: approved", + "planning.approved_by: atelier/planner/codex/p1", + "planning.approved_at: 2026-03-26T18:00:00Z", + "planning.approval_message_id: at-msg.1", ] ) else: - lines.append("trycycle.plan_stage: planning_in_review") + lines.append("planning.stage: planning_in_review") return "\n".join(lines) + "\n" @@ -465,7 +465,7 @@ def test_run_startup_contract_explicit_epic_prioritizes_merge_conflict() -> None assert result.changeset_id == "at-explicit" -def test_run_startup_contract_explicit_epic_rejects_unapproved_trycycle_next_changeset() -> None: +def test_run_startup_contract_explicit_epic_rejects_unapproved_refined_next_changeset() -> None: emitted: list[str] = [] result = _run_startup( @@ -477,8 +477,8 @@ def test_run_startup_contract_explicit_epic_rejects_unapproved_trycycle_next_cha "issue_type": "task" if issue_id != "at-explicit" else "epic", }, next_changeset=lambda **_kwargs: {"id": "at-explicit.1", "status": "open", "labels": []}, - trycycle_claim_eligible=lambda issue: ( - (False, "missing trycycle approval") + refined_planning_claim_eligible=lambda issue: ( + (False, "missing refined approval") if issue.get("id") == "at-explicit.1" else (True, None) ), @@ -487,10 +487,10 @@ def test_run_startup_contract_explicit_epic_rejects_unapproved_trycycle_next_cha assert result.should_exit is True assert result.reason == "explicit_epic_not_actionable" - assert any("missing trycycle approval" in message for message in emitted) + assert any("missing refined approval" in message for message in emitted) -def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_trycycle() -> None: +def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_refined() -> None: feedback = ReviewFeedbackSelection( epic_id="at-explicit", changeset_id="at-explicit.1", @@ -509,8 +509,8 @@ def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_trycyc }, select_review_feedback_changeset=lambda **_kwargs: feedback, next_changeset=lambda **_kwargs: None, - trycycle_claim_eligible=lambda issue: ( - (False, "missing trycycle approval") + refined_planning_claim_eligible=lambda issue: ( + (False, "missing refined approval") if issue.get("id") == "at-explicit.1" else (True, None) ), @@ -518,7 +518,7 @@ def test_run_startup_contract_explicit_review_feedback_rejects_unapproved_trycyc ) assert result.reason == "explicit_epic_not_actionable" - assert any("missing trycycle approval" in message for message in emitted) + assert any("missing refined approval" in message for message in emitted) def test_run_startup_contract_explicit_review_feedback_fails_closed_on_missing_metadata() -> None: @@ -545,12 +545,11 @@ def test_run_startup_contract_explicit_review_feedback_fails_closed_on_missing_m assert result.reason == "explicit_epic_not_actionable" assert any( - "unable to load changeset metadata for trycycle claim gate" in message - for message in emitted + "unable to load changeset metadata for refined claim gate" in message for message in emitted ) -def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycle() -> None: +def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_refined() -> None: conflict = MergeConflictSelection( epic_id="at-explicit", changeset_id="at-explicit.1", @@ -570,8 +569,8 @@ def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycl }, select_conflicted_changeset=lambda **_kwargs: conflict, next_changeset=lambda **_kwargs: None, - trycycle_claim_eligible=lambda issue: ( - (False, "missing trycycle approval") + refined_planning_claim_eligible=lambda issue: ( + (False, "missing refined approval") if issue.get("id") == "at-explicit.1" else (True, None) ), @@ -579,10 +578,10 @@ def test_run_startup_contract_explicit_merge_conflict_rejects_unapproved_trycycl ) assert result.reason == "explicit_epic_not_actionable" - assert any("missing trycycle approval" in message for message in emitted) + assert any("missing refined approval" in message for message in emitted) -def test_run_startup_contract_explicit_trycycle_paths_use_shared_validator() -> None: +def test_run_startup_contract_explicit_refined_paths_use_shared_validator() -> None: scenarios: tuple[tuple[str, bool, str], ...] = ( ("review", False, "explicit_epic_not_actionable"), ("review", True, "review_feedback"), @@ -620,7 +619,7 @@ def test_run_startup_contract_explicit_trycycle_paths_use_shared_validator() -> "status": "open", "labels": ["at:epic"] if issue_id == "at-explicit" else [], "description": ( - _trycycle_target_description(approved=approved) + _refined_target_description(approved=approved) if issue_id == "at-explicit.1" else "" ), @@ -628,13 +627,13 @@ def test_run_startup_contract_explicit_trycycle_paths_use_shared_validator() -> select_conflicted_changeset=lambda **_kwargs: conflict, select_review_feedback_changeset=lambda **_kwargs: feedback, next_changeset=lambda **_kwargs: None, - trycycle_claim_eligible=trycycle_contract.trycycle_claim_eligible, + refined_planning_claim_eligible=refined_planning_contract.refined_planning_claim_eligible, emit=lambda message: emitted.append(message), ) assert result.reason == expected_reason if not approved: - assert any("plan_stage=approved" in message for message in emitted) + assert any("planning.stage=approved" in message for message in emitted) else: assert result.changeset_id == "at-explicit.1" @@ -1527,7 +1526,7 @@ def test_run_startup_contract_skips_unclaimable_global_review_feedback() -> None assert result.epic_id == "at-claimable" -def test_run_startup_contract_global_review_feedback_rejects_unapproved_trycycle() -> None: +def test_run_startup_contract_global_review_feedback_rejects_unapproved_refined() -> None: blocked_feedback = ReviewFeedbackSelection( epic_id="at-blocked", changeset_id="at-blocked.1", @@ -1555,8 +1554,8 @@ def test_run_startup_contract_global_review_feedback_rejects_unapproved_trycycle next_changeset=lambda **kwargs: {"id": f"{kwargs['epic_id']}.1"}, select_review_feedback_changeset=lambda **_kwargs: None, select_global_review_feedback_changeset=lambda **_kwargs: blocked_feedback, - trycycle_claim_eligible=lambda issue: ( - (False, "missing trycycle approval") + refined_planning_claim_eligible=lambda issue: ( + (False, "missing refined approval") if issue.get("id") == "at-blocked.1" else (True, None) ), @@ -1565,10 +1564,10 @@ def test_run_startup_contract_global_review_feedback_rejects_unapproved_trycycle assert result.reason == "selected_auto" assert result.epic_id == "at-claimable" - assert any("missing trycycle approval" in message for message in emitted) + assert any("missing refined approval" in message for message in emitted) -def test_run_startup_contract_global_merge_conflict_rejects_unapproved_trycycle() -> None: +def test_run_startup_contract_global_merge_conflict_rejects_unapproved_refined() -> None: blocked_conflict = MergeConflictSelection( epic_id="at-blocked", changeset_id="at-blocked.1", @@ -1597,8 +1596,8 @@ def test_run_startup_contract_global_merge_conflict_rejects_unapproved_trycycle( next_changeset=lambda **kwargs: {"id": f"{kwargs['epic_id']}.1"}, select_conflicted_changeset=lambda **_kwargs: None, select_global_conflicted_changeset=lambda **_kwargs: blocked_conflict, - trycycle_claim_eligible=lambda issue: ( - (False, "missing trycycle approval") + refined_planning_claim_eligible=lambda issue: ( + (False, "missing refined approval") if issue.get("id") == "at-blocked.1" else (True, None) ), @@ -1607,10 +1606,10 @@ def test_run_startup_contract_global_merge_conflict_rejects_unapproved_trycycle( assert result.reason == "selected_auto" assert result.epic_id == "at-claimable" - assert any("missing trycycle approval" in message for message in emitted) + assert any("missing refined approval" in message for message in emitted) -def test_run_startup_contract_global_trycycle_paths_use_shared_validator() -> None: +def test_run_startup_contract_global_refined_paths_use_shared_validator() -> None: scenarios: tuple[tuple[str, bool, str], ...] = ( ("review", False, "selected_auto"), ("review", True, "review_feedback"), @@ -1656,7 +1655,7 @@ def test_run_startup_contract_global_trycycle_paths_use_shared_validator() -> No "status": "open", "labels": ["at:epic"] if issue_id in {"at-blocked", "at-claimable"} else [], "description": ( - _trycycle_target_description(approved=approved) + _refined_target_description(approved=approved) if issue_id == "at-blocked.1" else "" ), @@ -1666,14 +1665,14 @@ def test_run_startup_contract_global_trycycle_paths_use_shared_validator() -> No select_global_conflicted_changeset=lambda **_kwargs: conflict, select_review_feedback_changeset=lambda **_kwargs: None, select_global_review_feedback_changeset=lambda **_kwargs: feedback, - trycycle_claim_eligible=trycycle_contract.trycycle_claim_eligible, + refined_planning_claim_eligible=refined_planning_contract.refined_planning_claim_eligible, emit=lambda message: emitted.append(message), ) assert result.reason == expected_reason if not approved: assert result.epic_id == "at-claimable" - assert any("plan_stage=approved" in message for message in emitted) + assert any("planning.stage=approved" in message for message in emitted) else: assert result.epic_id == "at-blocked" assert result.changeset_id == "at-blocked.1" diff --git a/tests/atelier/worker/test_work_startup_runtime.py b/tests/atelier/worker/test_work_startup_runtime.py index ee73098b..8c3b84be 100644 --- a/tests/atelier/worker/test_work_startup_runtime.py +++ b/tests/atelier/worker/test_work_startup_runtime.py @@ -149,13 +149,13 @@ def _raise_on_raw_beads(*_args, **_kwargs): assert calls == [("at-epic.1", "abc1234", Path("/beads"), Path("/repo"), True)] -def test_next_changeset_service_trycycle_eligibility_uses_shared_helper(monkeypatch) -> None: +def test_next_changeset_service_refined_eligibility_uses_shared_helper(monkeypatch) -> None: calls: list[dict[str, object]] = [] - issue = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + issue = {"id": "at-epic.1", "description": "execution.strategy: refined"} monkeypatch.setattr( work_startup_runtime, - "_trycycle_claim_eligibility", + "_refined_planning_claim_eligibility", lambda candidate: (calls.append(candidate), (False, "blocked"))[1], ) service = work_startup_runtime._NextChangesetService( # pyright: ignore[reportPrivateUsage] @@ -163,19 +163,19 @@ def test_next_changeset_service_trycycle_eligibility_uses_shared_helper(monkeypa repo_root=Path("/repo"), ) - eligible, reason = service.trycycle_claim_eligible(issue) + eligible, reason = service.refined_planning_claim_eligible(issue) assert (eligible, reason) == (False, "blocked") assert calls == [issue] -def test_startup_contract_service_trycycle_eligibility_uses_shared_helper(monkeypatch) -> None: +def test_startup_contract_service_refined_eligibility_uses_shared_helper(monkeypatch) -> None: calls: list[dict[str, object]] = [] - issue = {"id": "at-epic.1", "description": "trycycle.targeted: true"} + issue = {"id": "at-epic.1", "description": "execution.strategy: refined"} monkeypatch.setattr( work_startup_runtime, - "_trycycle_claim_eligibility", + "_refined_planning_claim_eligibility", lambda candidate: (calls.append(candidate), (True, None))[1], ) service = work_startup_runtime._StartupContractService( # pyright: ignore[reportPrivateUsage] @@ -183,13 +183,13 @@ def test_startup_contract_service_trycycle_eligibility_uses_shared_helper(monkey repo_root=Path("/repo"), ) - eligible, reason = service.trycycle_claim_eligible(issue) + eligible, reason = service.refined_planning_claim_eligible(issue) assert (eligible, reason) == (True, None) assert calls == [issue] -def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( +def test_startup_contract_service_refined_eligibility_hydrates_sparse_issue( monkeypatch, ) -> None: contract_json = json.dumps( @@ -198,7 +198,7 @@ def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( "non_goals": ["Do not alter non-targeted startup paths"], "acceptance_criteria": [{"statement": "Reject unapproved", "evidence": ["pytest"]}], "scope": {"includes": ["startup"], "excludes": ["planner workflow"]}, - "verification_plan": ["uv run pytest tests/atelier/worker -k trycycle -v"], + "verification_plan": ["uv run pytest tests/atelier/worker -k refined -v"], "risks": [{"risk": "claim drift", "mitigation": "shared validator"}], "escalation_conditions": ["validator mismatch"], "completion_definition": { @@ -213,9 +213,9 @@ def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( hydrated_issue = { "id": "at-epic.1", "description": ( - "trycycle.targeted: true\n" - "trycycle.plan_stage: planning_in_review\n" - f"trycycle.contract_json: {contract_json}\n" + "execution.strategy: refined\n" + "planning.stage: planning_in_review\n" + f"planning.contract_json: {contract_json}\n" ), } calls: list[str] = [] @@ -234,16 +234,16 @@ def test_startup_contract_service_trycycle_eligibility_hydrates_sparse_issue( repo_root=Path("/repo"), ) - eligible, reason = service.trycycle_claim_eligible({"id": "at-epic.1"}) + eligible, reason = service.refined_planning_claim_eligible({"id": "at-epic.1"}) assert (eligible, reason) == ( False, - "targeted changesets require trycycle.plan_stage=approved before worker claim", + "refined changesets require planning.stage=approved before worker claim", ) assert calls == ["at-epic.1|/beads|/repo"] -def test_startup_contract_service_trycycle_eligibility_fails_closed_when_hydration_missing( +def test_startup_contract_service_refined_eligibility_fails_closed_when_hydration_missing( monkeypatch, ) -> None: calls: list[str] = [] @@ -260,10 +260,10 @@ def test_startup_contract_service_trycycle_eligibility_fails_closed_when_hydrati repo_root=Path("/repo"), ) - eligible, reason = service.trycycle_claim_eligible({"id": "at-missing.1"}) + eligible, reason = service.refined_planning_claim_eligible({"id": "at-missing.1"}) assert (eligible, reason) == ( False, - "unable to load changeset metadata for trycycle claim gate", + "unable to load changeset metadata for refined claim gate", ) assert calls == ["at-missing.1|/beads|/repo"] From c49dd2e79b4581b60874225735657b21a2fcfd9f Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Fri, 27 Mar 2026 10:34:09 -0700 Subject: [PATCH 14/16] fix(planner): remove trycycle fallback from refined contract - drop all trycycle.* metadata fallback reads in refined planning readiness checks\n- require execution.strategy=refined and planning.* fields exclusively\n- add regression coverage proving legacy trycycle fields are ignored --- src/atelier/refined_planning_contract.py | 80 +++---------------- .../atelier/test_refined_planning_contract.py | 18 +++++ 2 files changed, 28 insertions(+), 70 deletions(-) diff --git a/src/atelier/refined_planning_contract.py b/src/atelier/refined_planning_contract.py index 243ed5e0..12e83d9b 100644 --- a/src/atelier/refined_planning_contract.py +++ b/src/atelier/refined_planning_contract.py @@ -13,17 +13,11 @@ from . import beads _EXECUTION_STRATEGY_FIELD = "execution.strategy" -_LEGACY_TARGETED_FIELD = "trycycle.targeted" _CONTRACT_JSON_FIELD = "planning.contract_json" -_LEGACY_CONTRACT_JSON_FIELD = "trycycle.contract_json" _PLAN_STAGE_FIELD = "planning.stage" -_LEGACY_PLAN_STAGE_FIELD = "trycycle.plan_stage" _APPROVED_BY_FIELD = "planning.approved_by" -_LEGACY_APPROVED_BY_FIELD = "trycycle.approved_by" _APPROVED_AT_FIELD = "planning.approved_at" -_LEGACY_APPROVED_AT_FIELD = "trycycle.approved_at" _APPROVAL_MESSAGE_ID_FIELD = "planning.approval_message_id" -_LEGACY_APPROVAL_MESSAGE_ID_FIELD = "trycycle.approval_message_id" _APPROVAL_MESSAGE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._:-]*$") _PLANNING_IN_REVIEW = "planning_in_review" _APPROVED = "approved" @@ -155,12 +149,6 @@ class ReadinessResult(BaseModel): errors: tuple[str, ...] claim_blockers: tuple[str, ...] - @property - def targeted(self) -> bool: - """Compatibility alias for older call sites.""" - - return self.refined - @property def summary(self) -> str: """Return deterministic human-readable readiness diagnostics.""" @@ -239,18 +227,14 @@ def evaluate_issue_refined_planning_readiness(issue: Mapping[str, object]) -> Re errors: list[str] = [] claim_blockers: list[str] = [] - stage_raw = _field_value(fields, primary=_PLAN_STAGE_FIELD, legacy=_LEGACY_PLAN_STAGE_FIELD) + stage_raw = fields.get(_PLAN_STAGE_FIELD) stage = _normalize_field(stage_raw) if stage not in {_PLANNING_IN_REVIEW, _APPROVED}: errors.append( "refined changesets require planning.stage set to 'planning_in_review' or 'approved'" ) - contract_raw = _field_value( - fields, - primary=_CONTRACT_JSON_FIELD, - legacy=_LEGACY_CONTRACT_JSON_FIELD, - ) + contract_raw = fields.get(_CONTRACT_JSON_FIELD) contract_text = contract_raw.strip() if isinstance(contract_raw, str) else "" contract_present = bool(contract_text) contract: RefinedPlanningContract | None = None @@ -320,26 +304,10 @@ def approval_evidence_summary(issue: Mapping[str, object]) -> str: description = issue.get("description") fields = beads.parse_description_fields(description if isinstance(description, str) else "") - stage = _normalize_field( - _field_value( - fields, - primary=_PLAN_STAGE_FIELD, - legacy=_LEGACY_PLAN_STAGE_FIELD, - ) - ) - approved_by = _normalize_field( - _field_value(fields, primary=_APPROVED_BY_FIELD, legacy=_LEGACY_APPROVED_BY_FIELD) - ) - approved_at = _normalize_field( - _field_value(fields, primary=_APPROVED_AT_FIELD, legacy=_LEGACY_APPROVED_AT_FIELD) - ) - approval_message_id = _normalize_field( - _field_value( - fields, - primary=_APPROVAL_MESSAGE_ID_FIELD, - legacy=_LEGACY_APPROVAL_MESSAGE_ID_FIELD, - ) - ) + stage = _normalize_field(fields.get(_PLAN_STAGE_FIELD)) + approved_by = _normalize_field(fields.get(_APPROVED_BY_FIELD)) + approved_at = _normalize_field(fields.get(_APPROVED_AT_FIELD)) + approval_message_id = _normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) parts = [ f"stage={stage or '(missing)'}", f"approved_by={approved_by or '(missing)'}", @@ -351,25 +319,7 @@ def approval_evidence_summary(issue: Mapping[str, object]) -> str: def _is_refined_strategy(fields: Mapping[str, str]) -> bool: strategy = _normalize_field(fields.get(_EXECUTION_STRATEGY_FIELD)) - if strategy == _REFINED_STRATEGY: - return True - legacy_targeted = _normalize_field(fields.get(_LEGACY_TARGETED_FIELD)) - return legacy_targeted in {"1", "true", "yes", "on"} - - -def _field_value( - fields: Mapping[str, str], - *, - primary: str, - legacy: str, -) -> str | None: - primary_value = fields.get(primary) - if isinstance(primary_value, str) and primary_value.strip(): - return primary_value - legacy_value = fields.get(legacy) - if isinstance(legacy_value, str) and legacy_value.strip(): - return legacy_value - return None + return strategy == _REFINED_STRATEGY def _normalize_field(raw: str | None) -> str | None: @@ -383,25 +333,15 @@ def _normalize_field(raw: str | None) -> str | None: def _approval_errors(fields: Mapping[str, str]) -> list[str]: errors: list[str] = [] - approved_by = _normalize_field( - _field_value(fields, primary=_APPROVED_BY_FIELD, legacy=_LEGACY_APPROVED_BY_FIELD) - ) + approved_by = _normalize_field(fields.get(_APPROVED_BY_FIELD)) if approved_by is None: errors.append("approved stage requires planning.approved_by") - approved_at_raw = _normalize_field( - _field_value(fields, primary=_APPROVED_AT_FIELD, legacy=_LEGACY_APPROVED_AT_FIELD) - ) + approved_at_raw = _normalize_field(fields.get(_APPROVED_AT_FIELD)) if approved_at_raw is None: errors.append("approved stage requires planning.approved_at") elif not _is_valid_iso_timestamp(approved_at_raw): errors.append("planning.approved_at must be an ISO-8601 timestamp") - approval_message_id = _normalize_field( - _field_value( - fields, - primary=_APPROVAL_MESSAGE_ID_FIELD, - legacy=_LEGACY_APPROVAL_MESSAGE_ID_FIELD, - ) - ) + approval_message_id = _normalize_field(fields.get(_APPROVAL_MESSAGE_ID_FIELD)) if approval_message_id is None: errors.append("approved stage requires planning.approval_message_id") elif not _APPROVAL_MESSAGE_ID_PATTERN.fullmatch(approval_message_id): diff --git a/tests/atelier/test_refined_planning_contract.py b/tests/atelier/test_refined_planning_contract.py index 26551677..bb94d3fa 100644 --- a/tests/atelier/test_refined_planning_contract.py +++ b/tests/atelier/test_refined_planning_contract.py @@ -137,6 +137,24 @@ def test_validate_contract_accepts_complete_payload() -> None: ) +def test_validate_contract_ignores_legacy_trycycle_fields() -> None: + issue = { + "description": ( + "trycycle.targeted: true\n" + "trycycle.plan_stage: planning_in_review\n" + f"trycycle.contract_json: {_valid_contract_json()}\n" + ) + } + + result = refined_planning_contract.evaluate_issue_refined_planning_readiness(issue) + + assert result.ok is True + assert result.refined is False + assert result.claim_eligible is True + assert result.errors == () + assert result.claim_blockers == () + + def test_validate_contract_rejects_malformed_json() -> None: issue = { "description": ( From d5ee657f4ec4a4baffb7510312ecac73fd2081e7 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Fri, 27 Mar 2026 12:17:36 -0700 Subject: [PATCH 15/16] docs(planner): strengthen refined planning strategy guidance - expand plan-refined-deliberation with strategy challenge, assumption handling, and plan quality rubric\n- add planner template instructions for low-bar replan and high-bar user escalation\n- add test coverage that locks in strategy-challenge language for planner template and skill docs --- .../skills/plan-refined-deliberation/SKILL.md | 60 +++++++++++++++++++ src/atelier/templates/AGENTS.planner.md.tmpl | 12 ++++ tests/atelier/test_planner_agents_template.py | 7 +++ tests/atelier/test_skills.py | 11 ++++ 4 files changed, 90 insertions(+) diff --git a/src/atelier/skills/plan-refined-deliberation/SKILL.md b/src/atelier/skills/plan-refined-deliberation/SKILL.md index 814e3c69..12e3ef08 100644 --- a/src/atelier/skills/plan-refined-deliberation/SKILL.md +++ b/src/atelier/skills/plan-refined-deliberation/SKILL.md @@ -12,6 +12,8 @@ Use this skill to define *how* planning should be done for high-risk or cross-cutting work. This is an Atelier-native workflow and does not depend on external planning skills. +Focus on planning quality first. Bead write mechanics are secondary. + ## Inputs - issue_id: Epic or changeset bead id being planned. @@ -24,6 +26,62 @@ external planning skills. - beads_dir: Optional Beads store path. - repo_dir: Optional repo root override. +## Planning Method + +### 1) Strategy Challenge (before task breakdown) + +Before writing tasks, challenge the framing: + +- Are we solving the right problem, or just the first visible symptom? +- Is there a simpler path to the operator's actual goal? +- Does the proposed architecture remove complexity, or create it? +- Which assumptions are unvalidated and must be made explicit? + +Use a low bar for changing direction. Reframe, re-architect, or replan when a +better path is visible. + +Use a high bar for stopping to ask the user. Keep moving unless one of these is +true: + +- There is a fundamental conflict between user requirements. +- There is a fundamental conflict between requirements and reality. +- Guessing creates a real risk of harm. + +If none of those are true, decide and document the decision in the plan. + +### 2) Boundaries Before Tasks + +Define the executable boundary before decomposition: + +- exact outcome (`objective`) +- explicit non-goals +- included and excluded scope +- invariant/lifecycle constraints +- integration boundaries (external systems, side effects, adapters) +- failure and recovery expectations + +### 3) Decompose into Reviewable Units + +Break work into independently understandable slices: + +- each unit has one clear responsibility +- each unit has a test/verification story +- each unit can be reviewed without global context reload +- file ownership and interfaces are explicit +- defer follow-on work instead of inflating active scope + +### 4) Quality Self-Review + +A plan is ready only when all checks pass: + +- Right problem: Directly advances user outcome. +- Right approach: Simpler alternatives considered and rejected explicitly. +- Explicit assumptions: Unknowns and decisions are recorded. +- Verifiable: Acceptance criteria map to concrete evidence. +- Safe by default: Failure modes, rollback/recovery paths, and escalation are + defined. +- Executable: A worker can implement without asking for missing intent. + ## Strategy Contract For each executable unit that should use refined planning, record: @@ -45,6 +103,7 @@ Contract JSON should include: ## Steps +1. Run the strategy challenge and record major decisions in notes/description. 1. Draft the contract payload with explicit boundaries and testable evidence. 1. Write the metadata fields on the executable unit. 1. Run `plan-changeset-guardrails` and resolve violations. @@ -56,6 +115,7 @@ Contract JSON should include: ## Verification +- Plan includes strategy challenge outcomes and explicit assumption handling. - Refined units include `execution.strategy: refined` and valid `planning.contract_json`. - Stage is `planning_in_review` before promotion, then `approved` after explicit diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index b38498cb..0a4a8be2 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -128,6 +128,18 @@ If code changes are needed, create beads and leave implementation to workers. ## Planning Workflow +Before task decomposition, run a strategy challenge: +- Confirm you are solving the right problem, not only a local symptom. +- Prefer the simplest approach that meets the actual operator goal. +- Surface and record assumptions before locking task structure. +- Use a low bar for changing direction when a better approach appears. +- Use a high bar for stopping to ask the user. Continue unless there is: + - a fundamental conflict between requirements, + - a fundamental conflict between requirements and reality, or + - a real risk of harm if you guess. +- If you proceed without asking, document the decision and rationale on the + relevant bead. + 1. Discover context: - Read policy and specs. - Review existing epics and changesets. diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index ee1341fc..711d4f99 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -61,6 +61,13 @@ 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 "Before task decomposition, run a strategy challenge:" in content + assert "solving the right problem" in content + assert "low bar for changing direction" in content + assert "high bar for stopping to ask the user" in content + assert "fundamental conflict between requirements and reality" in content + assert "real risk of harm if you guess" in content + assert "document the decision and rationale on the" in content assert "execution.strategy: refined" in content assert "planning.contract_json" in content assert "planning.stage: planning_in_review" in content diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 4d8b8cdc..33da6cec 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -204,6 +204,17 @@ def test_plan_promote_epic_skill_mentions_refined_approval_gate() -> None: assert "planning.approval_message_id" in text +def test_plan_refined_deliberation_skill_mentions_strategy_challenge() -> None: + skill = skills.load_packaged_skills()["plan-refined-deliberation"] + text = skill.files["SKILL.md"].decode("utf-8") + assert "Strategy Challenge" in text + assert "low bar for changing direction" in text + assert "high bar for stopping to ask the user" in text + assert "fundamental conflict between requirements and reality" in text + assert "execution.strategy: refined" in text + assert "planning.contract_json" in text + + def test_ensure_project_skills_installs_if_missing() -> None: with tempfile.TemporaryDirectory() as tmp: project_dir = Path(tmp) From 972613fa41c6fe57814c305c5bb83f460237ebda Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Fri, 27 Mar 2026 14:02:44 -0700 Subject: [PATCH 16/16] docs(planner): apply shared planning discipline across skills - thread strategy-challenge and boundary-first drafting guidance through planner template and core planning skills\n- keep refined planning focused on iterative evaluation/sign-off while reusing the same drafting strategy\n- extend planner template and packaged-skill tests to lock in the cross-skill planning discipline wording --- src/atelier/skills/plan-changesets/SKILL.md | 9 +- src/atelier/skills/plan-create-epic/SKILL.md | 3 + .../skills/plan-refined-deliberation/SKILL.md | 134 +++--------------- src/atelier/skills/plan-split-tasks/SKILL.md | 3 + src/atelier/templates/AGENTS.planner.md.tmpl | 39 ++--- tests/atelier/test_planner_agents_template.py | 20 +-- tests/atelier/test_skills.py | 55 ++++--- 7 files changed, 84 insertions(+), 179 deletions(-) diff --git a/src/atelier/skills/plan-changesets/SKILL.md b/src/atelier/skills/plan-changesets/SKILL.md index d9a958a3..a0517809 100644 --- a/src/atelier/skills/plan-changesets/SKILL.md +++ b/src/atelier/skills/plan-changesets/SKILL.md @@ -10,6 +10,9 @@ Only use this when an epic should be decomposed. If the epic itself is already within guardrails, keep the epic as the executable changeset instead of creating children. +Use the shared planning discipline from `plan-refined-deliberation`. Use the +same drafting strategy for all planning passes. + Capture new executable work immediately as deferred changesets (`status=deferred`) when issues are actionable. Do not wait for approval to create/edit deferred work. @@ -31,12 +34,6 @@ create/edit deferred work. - Ensure the executable path for each changeset (the child plus inherited epic context) explicitly records `intent`, `rationale`, `non_goals`, `constraints`, `edge_cases`, `related_context`, and a done definition. -- When a changeset is refined, record: - - `execution.strategy: refined` - - `planning.contract_json: ` - - `planning.stage: planning_in_review` -- Do not mark a refined changeset runnable until explicit promotion approval - records `planning.stage: approved` and approval metadata. - Shared context can live on the epic; child changesets should add only the delta needed for worker execution. - For lifecycle/contract invariant bugs, record an upfront invariant impact map diff --git a/src/atelier/skills/plan-create-epic/SKILL.md b/src/atelier/skills/plan-create-epic/SKILL.md index 6e95aaa0..8bc3c0da 100644 --- a/src/atelier/skills/plan-create-epic/SKILL.md +++ b/src/atelier/skills/plan-create-epic/SKILL.md @@ -10,6 +10,9 @@ description: >- When a concrete issue is identified, capture it as a deferred epic immediately. Do not request approval to create or edit deferred beads. +Use the shared planning discipline from `plan-refined-deliberation`. Use the +same drafting strategy for all planning passes. + ## Inputs - title: Epic title. diff --git a/src/atelier/skills/plan-refined-deliberation/SKILL.md b/src/atelier/skills/plan-refined-deliberation/SKILL.md index 12e3ef08..41456052 100644 --- a/src/atelier/skills/plan-refined-deliberation/SKILL.md +++ b/src/atelier/skills/plan-refined-deliberation/SKILL.md @@ -1,123 +1,31 @@ --- name: plan-refined-deliberation description: >- - Create rigorous planning contracts for refined execution strategy. Use when a - changeset needs explicit objective, scope, evidence, and approval metadata - before workers can claim it. + Shared planning discipline for all planning passes, including refinement + rounds. --- -# Plan Refined Deliberation +# Plan refined deliberation -Use this skill to define *how* planning should be done for high-risk or -cross-cutting work. This is an Atelier-native workflow and does not depend on -external planning skills. +Use this shared discipline for every planning pass, including the first draft +and later refinement rounds. -Focus on planning quality first. Bead write mechanics are secondary. +## Contract -## Inputs +- Start with the strategy gate before task breakdown. +- Keep a low bar to reframe when a better path exists. +- Keep a high bar to ask the user. +- Treat each refinement round as a fresh, stateless pass over the current plan. +- stop at sign-off or loop limit. +- Target epic and changeset beads, not markdown checklists. +- This discipline applies to all planning, not just refinement rounds. -- issue_id: Epic or changeset bead id being planned. -- objective: One clear outcome statement. -- scope: Explicit includes/excludes. -- acceptance_criteria: Measurable outcomes plus evidence artifacts. -- verification_plan: Concrete checks/tests to run. -- risks: Risk plus mitigation pairs. -- escalation_conditions: Conditions that require planner/operator intervention. -- beads_dir: Optional Beads store path. -- repo_dir: Optional repo root override. +## Planning steps -## Planning Method - -### 1) Strategy Challenge (before task breakdown) - -Before writing tasks, challenge the framing: - -- Are we solving the right problem, or just the first visible symptom? -- Is there a simpler path to the operator's actual goal? -- Does the proposed architecture remove complexity, or create it? -- Which assumptions are unvalidated and must be made explicit? - -Use a low bar for changing direction. Reframe, re-architect, or replan when a -better path is visible. - -Use a high bar for stopping to ask the user. Keep moving unless one of these is -true: - -- There is a fundamental conflict between user requirements. -- There is a fundamental conflict between requirements and reality. -- Guessing creates a real risk of harm. - -If none of those are true, decide and document the decision in the plan. - -### 2) Boundaries Before Tasks - -Define the executable boundary before decomposition: - -- exact outcome (`objective`) -- explicit non-goals -- included and excluded scope -- invariant/lifecycle constraints -- integration boundaries (external systems, side effects, adapters) -- failure and recovery expectations - -### 3) Decompose into Reviewable Units - -Break work into independently understandable slices: - -- each unit has one clear responsibility -- each unit has a test/verification story -- each unit can be reviewed without global context reload -- file ownership and interfaces are explicit -- defer follow-on work instead of inflating active scope - -### 4) Quality Self-Review - -A plan is ready only when all checks pass: - -- Right problem: Directly advances user outcome. -- Right approach: Simpler alternatives considered and rejected explicitly. -- Explicit assumptions: Unknowns and decisions are recorded. -- Verifiable: Acceptance criteria map to concrete evidence. -- Safe by default: Failure modes, rollback/recovery paths, and escalation are - defined. -- Executable: A worker can implement without asking for missing intent. - -## Strategy Contract - -For each executable unit that should use refined planning, record: - -- `execution.strategy: refined` -- `planning.contract_json: ` -- `planning.stage: planning_in_review` - -Contract JSON should include: - -- `objective` -- `non_goals` -- `acceptance_criteria` (with evidence) -- `scope.includes` and `scope.excludes` -- `verification_plan` -- `risks` -- `escalation_conditions` -- `completion_definition` - -## Steps - -1. Run the strategy challenge and record major decisions in notes/description. -1. Draft the contract payload with explicit boundaries and testable evidence. -1. Write the metadata fields on the executable unit. -1. Run `plan-changeset-guardrails` and resolve violations. -1. Keep stage at `planning_in_review` until operator promotion approval. -1. Promote with `plan-promote-epic`; approval writes `planning.stage: approved` - plus planning approval evidence fields. -1. If scope grows during review, create deferred follow-on changesets instead of - inflating the active unit. - -## Verification - -- Plan includes strategy challenge outcomes and explicit assumption handling. -- Refined units include `execution.strategy: refined` and valid - `planning.contract_json`. -- Stage is `planning_in_review` before promotion, then `approved` after explicit - operator approval. -- Worker startup skips refined units lacking valid approval evidence. +1. Check the strategy gate before decomposition. +1. If a better path exists, reframe the plan instead of forcing the current one. +1. Only ask the user when the decision truly needs human input. +1. On each refinement round, begin from the current plan and evaluate it afresh. +1. Stop when the plan is approved or the loop budget is exhausted. +1. Record work as epic and changeset beads with explicit intent, rationale, + non-goals, constraints, edge cases, related context, and a done definition. diff --git a/src/atelier/skills/plan-split-tasks/SKILL.md b/src/atelier/skills/plan-split-tasks/SKILL.md index 03173390..5794396b 100644 --- a/src/atelier/skills/plan-split-tasks/SKILL.md +++ b/src/atelier/skills/plan-split-tasks/SKILL.md @@ -10,6 +10,9 @@ description: >- Use this only when the epic should be decomposed. If the epic itself is a single review-sized unit, keep it as the executable changeset. +Use the shared planning discipline from `plan-refined-deliberation`. Use the +same drafting strategy for all planning passes. + ## Inputs - epic_id: Parent epic bead id. diff --git a/src/atelier/templates/AGENTS.planner.md.tmpl b/src/atelier/templates/AGENTS.planner.md.tmpl index 0a4a8be2..aaf7a920 100644 --- a/src/atelier/templates/AGENTS.planner.md.tmpl +++ b/src/atelier/templates/AGENTS.planner.md.tmpl @@ -18,8 +18,8 @@ which version is local, ask the overseer before using a global fallback. The CLI does not enforce planning correctness. You do, via skills. - Run `planner-startup-check` before any planning work. -- Use `plan-refined-deliberation` when a changeset needs rigorous, evidence-led - planning before execution. +- Run `plan-refined-deliberation` before any plan breakdown or refinement pass; + the same drafting strategy applies to all planning passes. - Run `plan-changeset-guardrails` after creating or updating changesets. - Use `plan-promote-epic` to promote epics, with explicit user confirmation. - Use `mail-send` for planner-to-worker dispatch on the epic/changeset thread; @@ -29,6 +29,17 @@ The CLI does not enforce planning correctness. You do, via skills. - Do not use `mail-send` without an epic/changeset thread; select the owning work item first so coordination stays durable. +## Shared Planning Discipline + +`plan-refined-deliberation` is the shared planning discipline for every +planning pass. refined means iterative reevaluation of the same plan, not a +different drafting style. + +- Keep the low bar to reframe when a better path exists. +- Keep the high bar to ask the user. +- Keep plan quality separate from bead mechanics. +- Use this shared discipline before any task breakdown or refinement loop. + ## No Approval Step (Except Promotion) There is no approval step for creating or editing deferred work. When you @@ -128,18 +139,6 @@ If code changes are needed, create beads and leave implementation to workers. ## Planning Workflow -Before task decomposition, run a strategy challenge: -- Confirm you are solving the right problem, not only a local symptom. -- Prefer the simplest approach that meets the actual operator goal. -- Surface and record assumptions before locking task structure. -- Use a low bar for changing direction when a better approach appears. -- Use a high bar for stopping to ask the user. Continue unless there is: - - a fundamental conflict between requirements, - - a fundamental conflict between requirements and reality, or - - a real risk of harm if you guess. -- If you proceed without asking, document the decision and rationale on the - relevant bead. - 1. Discover context: - Read policy and specs. - Review existing epics and changesets. @@ -173,11 +172,6 @@ Before task decomposition, run a strategy challenge: - Capture new executable work as deferred changesets first, then promote when fully defined. - Record estimated size and any approval notes. -- For refined work, set `execution.strategy: refined` and include a - typed `planning.contract_json` payload on the executable unit. -- Keep refined plans in review with - `planning.stage: planning_in_review` until explicit operator approval. -- Do not promote refined work unless guardrails validate the contract payload. 4. External tickets: - {{ external_auto_export_guidance }} @@ -199,13 +193,6 @@ Before task decomposition, run a strategy challenge: before you ask for confirmation. - Do not ask for promotion while ambiguity remains. Resolve it in the bead or capture the operator's explicit decision about the remaining uncertainty. -- For refined executable units, promotion approval must persist: - - `planning.stage: approved` - - `planning.approved_by` - - `planning.approved_at` - - `planning.approval_message_id` -- Worker startup must fail closed on refined work that is missing contract - data or approval evidence. ## Bead Quality Standard diff --git a/tests/atelier/test_planner_agents_template.py b/tests/atelier/test_planner_agents_template.py index 711d4f99..562d6857 100644 --- a/tests/atelier/test_planner_agents_template.py +++ b/tests/atelier/test_planner_agents_template.py @@ -19,9 +19,9 @@ def test_planner_agents_template_contains_core_sections() -> None: assert "Promotion" in content assert "External providers" in content assert "plan-changeset-guardrails" in content - assert "plan-refined-deliberation" in content assert "plan-promote-epic" in content assert "planner-startup-check" in content + assert "plan-refined-deliberation" in content assert "mail-send" in content assert "epic-list" in content assert "one child changeset" in content @@ -61,15 +61,9 @@ 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 "Before task decomposition, run a strategy challenge:" in content - assert "solving the right problem" in content - assert "low bar for changing direction" in content - assert "high bar for stopping to ask the user" in content - assert "fundamental conflict between requirements and reality" in content - assert "real risk of harm if you guess" in content - assert "document the decision and rationale on the" in content - assert "execution.strategy: refined" in content - assert "planning.contract_json" in content - assert "planning.stage: planning_in_review" in content - assert "planning.stage: approved" in content - assert "fail closed" in content + assert "same drafting strategy applies to all planning passes" in content + assert "refined means iterative reevaluation of the same plan" in content + assert "low bar to reframe when a better path exists" in content + assert "high bar to ask the user" in content + assert "plan quality separate from bead mechanics" in content + assert "trycycle" not in content.lower() diff --git a/tests/atelier/test_skills.py b/tests/atelier/test_skills.py index 33da6cec..1ab01d77 100644 --- a/tests/atelier/test_skills.py +++ b/tests/atelier/test_skills.py @@ -40,9 +40,9 @@ def test_packaged_skills_include_core_set() -> None: "plan-create-epic", "plan-split-tasks", "plan-changesets", + "plan-refined-deliberation", "plan-changeset-guardrails", "plan-promote-epic", - "plan-refined-deliberation", "planner-startup-check", }.issubset(names) assert all("_" not in name for name in names) @@ -93,9 +93,9 @@ def test_install_workspace_skills_writes_skill_docs() -> None: "plan-create-epic", "plan-split-tasks", "plan-changesets", + "plan-refined-deliberation", "plan-changeset-guardrails", "plan-promote-epic", - "plan-refined-deliberation", "planner-startup-check", ): assert (workspace_dir / "skills" / name / "SKILL.md").exists() @@ -166,6 +166,29 @@ def test_packaged_planning_skills_include_scripts() -> None: assert "scripts/claim_message.py" in definitions["mail-queue-claim"].files +def test_refined_deliberation_skill_is_packaged_and_shared() -> None: + definitions = skills.load_packaged_skills() + skill = definitions["plan-refined-deliberation"] + text = skill.files["SKILL.md"].decode("utf-8") + + with tempfile.TemporaryDirectory() as tmp: + workspace_dir = Path(tmp) + metadata = skills.install_workspace_skills(workspace_dir) + + assert "plan-refined-deliberation" in metadata + assert (workspace_dir / "skills" / "plan-refined-deliberation" / "SKILL.md").is_file() + + assert "strategy gate before task breakdown" in text + assert "low bar to reframe when a better path exists" in text + assert "high bar to ask the user" in text + assert "fresh, stateless pass" in text + assert "stop at sign-off or loop limit" in text + assert "epic and changeset beads" in text + assert "applies to all planning, not just refinement rounds" in text + assert "epic and changeset beads, not markdown checklists" in text + assert "trycycle" not in text.lower() + + def test_work_done_skill_references_close_epic_script() -> None: skill = skills.load_packaged_skills()["work-done"] text = skill.files["SKILL.md"].decode("utf-8") @@ -196,25 +219,6 @@ def test_github_issues_skill_mentions_list_script() -> None: assert "list_issues.py" in text -def test_plan_promote_epic_skill_mentions_refined_approval_gate() -> None: - skill = skills.load_packaged_skills()["plan-promote-epic"] - text = skill.files["SKILL.md"].decode("utf-8") - assert "planning.stage" in text - assert "planning.approved_by" in text - assert "planning.approval_message_id" in text - - -def test_plan_refined_deliberation_skill_mentions_strategy_challenge() -> None: - skill = skills.load_packaged_skills()["plan-refined-deliberation"] - text = skill.files["SKILL.md"].decode("utf-8") - assert "Strategy Challenge" in text - assert "low bar for changing direction" in text - assert "high bar for stopping to ask the user" in text - assert "fundamental conflict between requirements and reality" in text - assert "execution.strategy: refined" in text - assert "planning.contract_json" in text - - def test_ensure_project_skills_installs_if_missing() -> None: with tempfile.TemporaryDirectory() as tmp: project_dir = Path(tmp) @@ -346,6 +350,15 @@ def test_plan_create_epic_skill_captures_drafts_without_approval() -> None: assert "done_definition" in text +def test_planning_skills_reference_shared_discipline() -> None: + for name in ("plan-create-epic", "plan-changesets", "plan-split-tasks"): + text = skills.load_packaged_skills()[name].files["SKILL.md"].decode("utf-8") + assert "plan-refined-deliberation" in text + assert "shared planning discipline" in text + assert "same drafting strategy" in text + assert "trycycle" not in text.lower() + + def test_beads_conventions_reference_includes_concrete_authoring_examples() -> None: reference_path = ( Path(__file__).resolve().parents[2]